diff --git a/app/actions/app/global.ts b/app/actions/app/global.ts index fde5c8d11f9..9c1159bc910 100644 --- a/app/actions/app/global.ts +++ b/app/actions/app/global.ts @@ -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); }; diff --git a/app/actions/local/draft.ts b/app/actions/local/draft.ts index 4deaff9843a..bf98bd48b16 100644 --- a/app/actions/local/draft.ts +++ b/app/actions/local/draft.ts @@ -1,10 +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'; +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 { const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -197,3 +211,83 @@ 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; + 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 updateDraftImages', error); + return {error}; + } +} + +async function getImageMetadata(url: string) { + let height = 0; + let width = 0; + let format; + try { + await new Promise((resolve, reject) => { + Image.getSize( + url, + (imageWidth, imageHeight) => { + width = imageWidth; + height = imageHeight; + resolve(null); + }, + (error) => { + logError('Failed to get image size', error); + reject(error); + }, + ); + }); + } catch (error) { + width = 0; + height = 0; + } + const match = url.match(/\.(\w+)(?=\?|$)/); + if (match) { + format = match[1]; + } + return { + height, + width, + format, + frame_count: 1, + }; +} + +export async function parseMarkdownImages(markdown: string, imageMetadata: Dictionary) { + let match; + const imageRegex = /!\[.*?\]\((https:\/\/[^\s)]+)\)/g; + while ((match = imageRegex.exec(markdown)) !== null) { + const imageUrl = match[1]; + // eslint-disable-next-line no-await-in-loop + imageMetadata[imageUrl] = await getImageMetadata(imageUrl); + } +} diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index e3e15f9f19c..b4ff1d1cff5 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -1053,7 +1053,7 @@ export async function switchToChannelById(serverUrl: string, channelId: string, fetchPostsForChannel(serverUrl, channelId); fetchChannelBookmarks(serverUrl, channelId); - await switchToChannel(serverUrl, channelId, teamId, skipLastUnread); + await switchToChannel(serverUrl, channelId, teamId, skipLastUnread, false); openChannelIfNeeded(serverUrl, channelId); markChannelAsRead(serverUrl, channelId); fetchChannelStats(serverUrl, channelId); diff --git a/app/components/channel_info/avatar/index.tsx b/app/components/channel_info/avatar/index.tsx new file mode 100644 index 00000000000..f1d36e49c28 --- /dev/null +++ b/app/components/channel_info/avatar/index.tsx @@ -0,0 +1,71 @@ +// 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, 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 Avatar = ({ + author, +}: Props) => { + const serverUrl = useServerUrl(); + + let uri = ''; + if (author) { + uri = buildProfileImageUrlFromUser(serverUrl, author); + } + + let picture; + if (uri) { + picture = ( + + ); + } else { + picture = ( + + ); + } + + return ( + + {picture} + + ); +}; + +export default Avatar; + diff --git a/app/components/channel_info/channel_info.tsx b/app/components/channel_info/channel_info.tsx new file mode 100644 index 00000000000..5404e1cf8ec --- /dev/null +++ b/app/components/channel_info/channel_info.tsx @@ -0,0 +1,164 @@ +// 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 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 CompassIcon from '../compass_icon'; + +import Avatar from './avatar'; + +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 ChannelInfo: React.FC = ({ + channel, + draftReceiverUser, + updateAt, + rootId, + testID, + currentUser, + isMilitaryTime, +}) => { + const theme = useTheme(); + const style = getStyleSheet(theme); + const isChannelTypeDM = channel.type === General.DM_CHANNEL; + + let headerComponent: ReactNode = null; + const profileComponent = draftReceiverUser ? : ( + + + ); + + if (rootId) { + headerComponent = ( + + + + {profileComponent} + + + ); + } else if (isChannelTypeDM) { + headerComponent = ( + + + + {profileComponent} + + + ); + } else { + headerComponent = ( + + + + {profileComponent} + + + ); + } + + return ( + + + + {headerComponent} + + {channel.displayName} + + + + + ); +}; + +export default ChannelInfo; diff --git a/app/components/channel_info/index.tsx b/app/components/channel_info/index.tsx new file mode 100644 index 00000000000..0e2a351106c --- /dev/null +++ b/app/components/channel_info/index.tsx @@ -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 ChannelInfo from './channel_info'; + +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(ChannelInfo)); diff --git a/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap b/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap index 4db607bcde8..6a64876703e 100644 --- a/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap +++ b/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap @@ -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, diff --git a/app/components/channel_item/channel_item.tsx b/app/components/channel_item/channel_item.tsx index e8f3f19b9fc..5f4eb9e7d3a 100644 --- a/app/components/channel_item/channel_item.tsx +++ b/app/components/channel_item/channel_item.tsx @@ -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, diff --git a/app/components/draft/draft.tsx b/app/components/draft/draft.tsx new file mode 100644 index 00000000000..0ad34dda48b --- /dev/null +++ b/app/components/draft/draft.tsx @@ -0,0 +1,125 @@ +// 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 ChannelInfo from '@components/channel_info'; +import DraftPost from '@components/draft/draft_post'; +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 = ({ + 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 ( + + + + {showPostPriority && draft.metadata?.priority && + +
+ + } + + + + + ); +}; + +export default Draft; diff --git a/app/components/draft/draft_post/draft_files/index.ts b/app/components/draft/draft_post/draft_files/index.ts new file mode 100644 index 00000000000..ab603b8ea4e --- /dev/null +++ b/app/components/draft/draft_post/draft_files/index.ts @@ -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)); diff --git a/app/components/draft/draft_post/draft_message/index.tsx b/app/components/draft/draft_post/draft_message/index.tsx new file mode 100644 index 00000000000..d3e170edf47 --- /dev/null +++ b/app/components/draft/draft_post/draft_message/index.tsx @@ -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 = ({ + draft, + layoutWidth, + location, +}) => { + const theme = useTheme(); + const style = getStyleSheet(theme); + const blockStyles = getMarkdownBlockStyles(theme); + const textStyles = getMarkdownTextStyles(theme); + const [height, setHeight] = useState(); + 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 = () => setOpen(!open); + + return ( + <> + + + + + + + + {(height || 0) > maxHeight && + + } + + ); +}; + +export default DraftMessage; diff --git a/app/components/draft/draft_post/index.tsx b/app/components/draft/draft_post/index.tsx new file mode 100644 index 00000000000..fcb78b4f858 --- /dev/null +++ b/app/components/draft/draft_post/index.tsx @@ -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 = ({ + draft, + location, + layoutWidth, +}) => { + const theme = useTheme(); + const hasFiles = draft.files.length > 0; + const style = getStyleSheet(theme); + + return ( + + + { + hasFiles && + + } + + ); +}; + +export default DraftPost; diff --git a/app/components/draft/index.tsx b/app/components/draft/index.tsx new file mode 100644 index 00000000000..871c77f03e3 --- /dev/null +++ b/app/components/draft/index.tsx @@ -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), + ), + ), + ), + ), +); diff --git a/app/components/drafts_buttton/drafts_button.tsx b/app/components/drafts_buttton/drafts_button.tsx new file mode 100644 index 00000000000..5793f389fbe --- /dev/null +++ b/app/components/drafts_buttton/drafts_button.tsx @@ -0,0 +1,133 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {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 FormattedText from '@components/formatted_text'; +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'; + +import CompassIcon from '../compass_icon'; + +type DraftListProps = { + currentChannelId: string; + shouldHighlighActive?: 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 = ({ + currentChannelId, + shouldHighlighActive = false, + draftsCount, +}) => { + const theme = useTheme(); + const styles = getChannelItemStyleSheet(theme); + const customStyles = getStyleSheet(theme); + const isTablet = useIsTablet(); + + const handlePress = useCallback(preventDoubleTap(() => { + switchToGlobalDrafts(); + }), []); + + const isActive = isTablet && shouldHighlighActive && !currentChannelId; + + 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]); + + if (!draftsCount) { + return null; + } + + return ( + + + + + + + {draftsCount} + + + + ); +}; + +export default DraftsButton; diff --git a/app/components/drafts_buttton/index.tsx b/app/components/drafts_buttton/index.tsx new file mode 100644 index 00000000000..3fd1c22647f --- /dev/null +++ b/app/components/drafts_buttton/index.tsx @@ -0,0 +1,30 @@ +// 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 {observeDraftCount} from '@queries/servers/drafts'; +import {observeCurrentChannelId, observeCurrentTeamId} from '@queries/servers/system'; + +import DraftsButton from './drafts_button'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type Props = { + teamId: string; +} & WithDatabaseArgs; + +const withTeamId = withObservables([], ({database}: WithDatabaseArgs) => ({ + teamId: observeCurrentTeamId(database), +})); + +const enhanced = withObservables(['teamId'], ({database, teamId}: Props) => { + const draftsCount = observeDraftCount(database, teamId); + return { + currentChannelId: observeCurrentChannelId(database), + draftsCount, + }; +}); + +export default React.memo(withDatabase(withTeamId(enhanced(DraftsButton)))); diff --git a/app/components/files/files.tsx b/app/components/files/files.tsx index 710466ea79e..1006393637c 100644 --- a/app/components/files/files.tsx +++ b/app/components/files/files.tsx @@ -23,8 +23,8 @@ type FilesProps = { layoutWidth?: number; location: string; isReplyPost: boolean; - postId: string; - postProps: Record; + postId?: string; + postProps?: Record; publicLinkEnabled: boolean; } diff --git a/app/components/post_draft/draft_input/index.tsx b/app/components/post_draft/draft_input/index.tsx index 4a2e4e7b4df..d286e088fa5 100644 --- a/app/components/post_draft/draft_input/index.tsx +++ b/app/components/post_draft/draft_input/index.tsx @@ -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) { diff --git a/app/components/post_draft/post_input/post_input.tsx b/app/components/post_draft/post_input/post_input.tsx index ea7208c9580..ea50ef45b03 100644 --- a/app/components/post_draft/post_input/post_input.tsx +++ b/app/components/post_draft/post_input/post_input.tsx @@ -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'; @@ -146,9 +147,14 @@ export default function PostInput({ onFocus(); }; - const onBlur = useCallback(() => { + const onBlur = useCallback(async () => { keyboardContext?.registerTextInputBlur(); - updateDraftMessage(serverUrl, channelId, rootId, value); + handleDraftUpdate({ + serverUrl, + channelId, + rootId, + value, + }); setIsFocused(false); }, [keyboardContext, serverUrl, channelId, rootId, value, setIsFocused]); diff --git a/app/components/post_draft/send_handler/send_handler.tsx b/app/components/post_draft/send_handler/send_handler.tsx index a936bbe81ad..2b7129aef07 100644 --- a/app/components/post_draft/send_handler/send_handler.tsx +++ b/app/components/post_draft/send_handler/send_handler.tsx @@ -1,27 +1,12 @@ // 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 DraftInput from '../draft_input'; @@ -94,178 +79,28 @@ export default function SendHandler({ persistentNotificationMaxRecipients, postPriority, }: 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]); - - 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; - } - } + }, [serverUrl, channelId, rootId]); - 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, - ]); - - 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]); + membersCount, + userIsOutOfOffice, + currentUserId, + channelType, + postPriority, + clearDraft, + }); return ( ([ export const SCREENS_AS_BOTTOM_SHEET = new Set([ BOTTOM_SHEET, + DRAFT_OPTIONS, EMOJI_PICKER, POST_OPTIONS, POST_PRIORITY_PICKER, diff --git a/app/constants/tutorial.ts b/app/constants/tutorial.ts index c830f519ada..1977ab700e8 100644 --- a/app/constants/tutorial.ts +++ b/app/constants/tutorial.ts @@ -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, }; diff --git a/app/hooks/handle_send_message.ts b/app/hooks/handle_send_message.ts new file mode 100644 index 00000000000..b3196eb5a3d --- /dev/null +++ b/app/hooks/handle_send_message.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useCallback, useEffect, 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 = useCallback(() => { + if (sendingMessage) { + return false; + } + + const messageLength = value.trim().length; + + if (messageLength > maxMessageLength) { + return false; + } + + if (files.length) { + const loadingComplete = !files.some((file) => DraftUploadManager.isUploading(file.clientId!)); + return loadingComplete; + } + + return messageLength > 0; + }, [sendingMessage, value, files, maxMessageLength]); + + const handleReaction = useCallback((emoji: string, add: boolean) => { + handleReactionToLatestPost(serverUrl, emoji, add, rootId); + clearDraft?.(); + setSendingMessage(false); + }, [serverUrl, rootId, clearDraft]); + + const doSubmitMessage = useCallback(() => { + const postFiles = files.filter((f) => !f.failed); + const post = { + user_id: currentUserId, + channel_id: channelId, + root_id: rootId, + message: value, + } 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: canSend(), + }; +}; diff --git a/app/hooks/persistent_notification_props.ts b/app/hooks/persistent_notification_props.ts new file mode 100644 index 00000000000..108dc3dd489 --- /dev/null +++ b/app/hooks/persistent_notification_props.ts @@ -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, + }; +}; diff --git a/app/queries/servers/drafts.ts b/app/queries/servers/drafts.ts index 4822385e7da..5f7e868f3c4 100644 --- a/app/queries/servers/drafts.ts +++ b/app/queries/servers/drafts.ts @@ -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 queryAllDrafts = (database: Database, teamId: string) => { + return database.collections.get(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 observeAllDrafts = (database: Database, teamId: string) => { + return queryAllDrafts(database, teamId).observeWithColumns(['update_at']); +}; + +export const observeDraftCount = (database: Database, teamId: string) => { + return queryAllDrafts(database, teamId).observeCount(); +}; diff --git a/app/screens/draft_options/delete_draft/index.tsx b/app/screens/draft_options/delete_draft/index.tsx new file mode 100644 index 00000000000..5939351be19 --- /dev/null +++ b/app/screens/draft_options/delete_draft/index.tsx @@ -0,0 +1,72 @@ +// 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 {Text} from 'react-native'; + +import {removeDraft} from '@actions/local/draft'; +import CompassIcon from '@components/compass_icon'; +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 {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 = ({ + bottomSheetId, + channelId, + rootId, +}) => { + const theme = useTheme(); + const intl = useIntl(); + const style = getStyleSheet(theme); + const serverUrl = useServerUrl(); + + const draftDeleteHandler = async () => { + await dismissBottomSheet(bottomSheetId); + removeDraft(serverUrl, channelId, rootId); + }; + + return ( + + + {intl.formatMessage({id: 'draft.options.delete.title', defaultMessage: 'Delete draft'})} + + ); +}; + +export default DeleteDraft; diff --git a/app/screens/draft_options/edit_draft/index.tsx b/app/screens/draft_options/edit_draft/index.tsx new file mode 100644 index 00000000000..2dcc7784303 --- /dev/null +++ b/app/screens/draft_options/edit_draft/index.tsx @@ -0,0 +1,78 @@ +// 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 {Text} from 'react-native'; + +import {switchToThread} from '@actions/local/thread'; +import {switchToChannelById} from '@actions/remote/channel'; +import CompassIcon from '@components/compass_icon'; +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 = ({ + bottomSheetId, + channel, + rootId, +}) => { + const theme = useTheme(); + const intl = useIntl(); + 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 ( + + + {intl.formatMessage({id: 'draft.options.edit.title', defaultMessage: 'Edit draft'})} + + ); +}; + +export default EditDraft; diff --git a/app/screens/draft_options/index.tsx b/app/screens/draft_options/index.tsx new file mode 100644 index 00000000000..c4bdd2317d5 --- /dev/null +++ b/app/screens/draft_options/index.tsx @@ -0,0 +1,91 @@ +// 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 {View, Text} from 'react-native'; + +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 SendDraft from './send_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 DraftOptions: React.FC = ({ + channel, + rootId, + draft, + draftReceiverUserName, +}) => { + const {formatMessage} = useIntl(); + const isTablet = useIsTablet(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const renderContent = () => { + return ( + + {!isTablet && {formatMessage( + {id: 'draft.option.header', defaultMessage: 'Draft actions'}, + )}} + + + + + ); + }; + + return ( + + ); +}; + +export default DraftOptions; diff --git a/app/screens/draft_options/send_draft/index.tsx b/app/screens/draft_options/send_draft/index.tsx new file mode 100644 index 00000000000..b1fd21d14b9 --- /dev/null +++ b/app/screens/draft_options/send_draft/index.tsx @@ -0,0 +1,91 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {combineLatest, of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {INITIAL_PRIORITY} from '@components/post_draft/send_handler/send_handler'; +import {General, Permissions} from '@constants'; +import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft'; +import {observeChannel, observeChannelInfo} from '@queries/servers/channel'; +import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; +import {observeFirstDraft, queryDraft} from '@queries/servers/drafts'; +import {observePermissionForChannel} from '@queries/servers/role'; +import {observeConfigBooleanValue, observeConfigIntValue, observeCurrentUserId} from '@queries/servers/system'; +import {observeUser} from '@queries/servers/user'; + +import SendDraft from './send_draft'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type Props ={ + rootId: string; + channelId: string; +} & WithDatabaseArgs; + +const enhanced = withObservables([], ({database, rootId, channelId}: Props) => { + const channel = observeChannel(database, channelId); + + const currentUserId = observeCurrentUserId(database); + const currentUser = currentUserId.pipe( + switchMap((id) => observeUser(database, id)), + ); + const userIsOutOfOffice = currentUser.pipe( + switchMap((u) => of$(u?.status === General.OUT_OF_OFFICE)), + ); + + const postPriority = queryDraft(database, channelId, rootId).observeWithColumns(['metadata']).pipe( + switchMap(observeFirstDraft), + switchMap((d) => { + if (!d?.metadata?.priority) { + return of$(INITIAL_PRIORITY); + } + + return of$(d.metadata.priority); + }), + ); + + const enableConfirmNotificationsToChannel = observeConfigBooleanValue(database, 'EnableConfirmNotificationsToChannel'); + const maxMessageLength = observeConfigIntValue(database, 'MaxPostSize', MAX_MESSAGE_LENGTH_FALLBACK); + const persistentNotificationInterval = observeConfigIntValue(database, 'PersistentNotificationIntervalMinutes'); + const persistentNotificationMaxRecipients = observeConfigIntValue(database, 'PersistentNotificationMaxRecipients'); + + const useChannelMentions = combineLatest([channel, currentUser]).pipe( + switchMap(([c, u]) => { + if (!c) { + return of$(true); + } + + return u ? observePermissionForChannel(database, c, u, Permissions.USE_CHANNEL_MENTIONS, false) : of$(false); + }), + ); + + 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))), + ); + + const customEmojis = queryAllCustomEmojis(database).observe(); + + return { + channelType, + channelName, + channelDisplayName, + currentUserId, + enableConfirmNotificationsToChannel, + maxMessageLength, + membersCount, + userIsOutOfOffice, + useChannelMentions, + customEmojis, + persistentNotificationInterval, + persistentNotificationMaxRecipients, + postPriority, + }; +}); + +export default withDatabase(enhanced(SendDraft)); diff --git a/app/screens/draft_options/send_draft/send_draft.tsx b/app/screens/draft_options/send_draft/send_draft.tsx new file mode 100644 index 00000000000..0dfd3cb74c2 --- /dev/null +++ b/app/screens/draft_options/send_draft/send_draft.tsx @@ -0,0 +1,162 @@ +// 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 {Text} from 'react-native'; + +import {removeDraft} from '@actions/local/draft'; +import CompassIcon from '@components/compass_icon'; +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 | undefined; +} + +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 = ({ + 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 ( + + + {intl.formatMessage({id: 'draft.options.send.title', defaultMessage: 'Edit draft'})} + + ); +}; + +export default SendDraft; diff --git a/app/screens/global_drafts/components/draft_empty_component/index.tsx b/app/screens/global_drafts/components/draft_empty_component/index.tsx new file mode 100644 index 00000000000..192d25fd030 --- /dev/null +++ b/app/screens/global_drafts/components/draft_empty_component/index.tsx @@ -0,0 +1,61 @@ +// 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 {Image, Text, View} from 'react-native'; + +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 intl = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + return ( + + + + {intl.formatMessage({id: 'drafts.empty.title', defaultMessage: 'No drafts at the moment'})} + + + {intl.formatMessage({id: 'drafts.empty.subtitle', defaultMessage: 'Any message you have started will show here.'})} + + + ); +}; + +export default DraftEmptyComponent; diff --git a/app/screens/global_drafts/components/global_drafts_list/SwipeableDraft/index.tsx b/app/screens/global_drafts/components/global_drafts_list/SwipeableDraft/index.tsx new file mode 100644 index 00000000000..b4f2950d122 --- /dev/null +++ b/app/screens/global_drafts/components/global_drafts_list/SwipeableDraft/index.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef} from 'react'; +import {useIntl} from 'react-intl'; +import {Text, Animated, DeviceEventEmitter, TouchableWithoutFeedback} from 'react-native'; +import {Swipeable} from 'react-native-gesture-handler'; + +import CompassIcon from '@components/compass_icon'; +import Draft from '@components/draft'; +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: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.dndIndicator, + paddingHorizontal: 24, + paddingVertical: 16, + }, + deleteText: { + color: theme.sidebarText, + ...typography('Body'), + }, + deleteIcon: { + color: theme.sidebarText, + ...typography('Heading'), + }, + }; +}); + +const SwipeableDraft: React.FC = ({ + item, + location, + layoutWidth, +}) => { + const swipeable = useRef(null); + const theme = useTheme(); + const intl = useIntl(); + const styles = getStyles(theme); + const serverUrl = useServerUrl(); + + const onSwipeableWillOpen = 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]); + + const renderAction = useCallback((progress: Animated.AnimatedInterpolation) => { + const trans = progress.interpolate({ + inputRange: [0, 1], + outputRange: [layoutWidth + 40, 0], + extrapolate: 'clamp', + }); + + return ( + + + + {intl.formatMessage({ + id: 'draft.options.delete.confirm', + defaultMessage: 'Delete', + })} + + + ); + }, [deleteDraft, intl, layoutWidth, styles.deleteContainer, styles.deleteText, theme.sidebarText]); + + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.DRAFT_SWIPEABLE, (draftId: string) => { + if (item.id !== draftId) { + swipeable.current?.close(); + } + }); + + return () => listener.remove(); + }, [item.id]); + + return ( + + + + ); +}; + +export default SwipeableDraft; diff --git a/app/screens/global_drafts/components/global_drafts_list/global_drafts_list.tsx b/app/screens/global_drafts/components/global_drafts_list/global_drafts_list.tsx new file mode 100644 index 00000000000..792fa7c7c75 --- /dev/null +++ b/app/screens/global_drafts/components/global_drafts_list/global_drafts_list.tsx @@ -0,0 +1,146 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {FlatList} from '@stream-io/flat-list-mvcp'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {InteractionManager, Platform, 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 {useIsTablet} from '@hooks/device'; +import {popTopScreen} from '@screens/navigation'; + +import DraftEmptyComponent from '../draft_empty_component'; + +import SwipeableDraft from './SwipeableDraft'; +import DraftTooltip from './tooltip'; + +import type DraftModel from '@typings/database/models/servers/draft'; + +type Props = { + allDrafts: DraftModel[]; + location: string; + tutorialWatched: boolean; +} + +const styles = StyleSheet.create({ + empty: { + alignItems: 'center', + flexGrow: 1, + justifyContent: 'center', + }, + tooltipStyle: { + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowRadius: 2, + shadowOpacity: 0.16, + }, +}); + +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); +const keyExtractor = (item: DraftModel) => item.id; + +const GlobalDraftsList: React.FC = ({ + allDrafts, + location, + tutorialWatched, +}) => { + const [layoutWidth, setLayoutWidth] = useState(0); + const [tooltipVisible, setTooltipVisible] = useState(false); + const isTablet = useIsTablet(); + 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 : ''; + + const tooltipContentStyle = useMemo(() => ({ + borderRadius: 8, + maxWidth: isTablet ? 352 : 247, + padding: 0, + height: 160, + }), [isTablet]); + + useEffect(() => { + if (!tutorialWatched) { + return; + } + InteractionManager.runAfterInteractions(() => { + setTooltipVisible(true); + }); + }, []); + + const collapse = useCallback(() => { + if (Platform.OS === 'android') { + popTopScreen(Screens.GLOBAL_DRAFTS); + } + }, []); + + useAndroidHardwareBackHandler(Screens.GLOBAL_DRAFTS, collapse); + + const close = useCallback(() => { + setTooltipVisible(false); + storeDraftsTutorial(); + }, []); + + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + if (item.id === firstDraftId && tutorialWatched) { + return ( + } + onClose={close} + tooltipStyle={styles.tooltipStyle} + > + + + + + ); + } + return ( + + ); + }, [close, firstDraftId, layoutWidth, location, tooltipContentStyle, tooltipVisible, tutorialWatched]); + + return ( + + + + ); +}; + +export default GlobalDraftsList; diff --git a/app/screens/global_drafts/components/global_drafts_list/index.tsx b/app/screens/global_drafts/components/global_drafts_list/index.tsx new file mode 100644 index 00000000000..82355e1eaab --- /dev/null +++ b/app/screens/global_drafts/components/global_drafts_list/index.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +/* eslint-disable max-nested-callbacks */ + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import React from 'react'; + +import {Tutorial} from '@constants'; +import {observeTutorialWatched} from '@queries/app/global'; +import {observeAllDrafts} 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 = observeAllDrafts(database, teamId); + const tutorialWatched = observeTutorialWatched(Tutorial.DRAFTS); + + return { + allDrafts, + tutorialWatched, + }; +}); + +export default React.memo(withDatabase(withTeamId(enhanced(GlobalDraftsList)))); diff --git a/app/screens/global_drafts/components/global_drafts_list/tooltip.tsx b/app/screens/global_drafts/components/global_drafts_list/tooltip.tsx new file mode 100644 index 00000000000..5a4f6e3b2b8 --- /dev/null +++ b/app/screens/global_drafts/components/global_drafts_list/tooltip.tsx @@ -0,0 +1,89 @@ +// 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 logo = require('@assets/images/emojis/swipe.png'); + +const hitSlop = {top: 10, bottom: 10, left: 10, right: 10}; + +const styles = StyleSheet.create({ + container: { + marginHorizontal: 24, + }, + 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', + marginTop: 22, + position: 'relative', + }, + title: { + color: Preferences.THEMES.denim.centerChannelColor, + ...typography('Body', 200, 'SemiBold'), + }, + image: { + height: 68, + width: 68, + }, +}); + +const DraftTooltip = ({onClose}: Props) => { + return ( + + + + + + + + + + + + ); +}; + +export default DraftTooltip; diff --git a/app/screens/global_drafts/index.tsx b/app/screens/global_drafts/index.tsx new file mode 100644 index 00000000000..0ca96c48958 --- /dev/null +++ b/app/screens/global_drafts/index.tsx @@ -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 (); + }, [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 ( + + + + + + {!switchingTeam && + + + + } + + ); +}; + +export default GlobalDrafts; diff --git a/app/screens/home/channel_list/additional_tablet_view/additional_tablet_view.tsx b/app/screens/home/channel_list/additional_tablet_view/additional_tablet_view.tsx index 95e4cc9a5cd..1a1263f486e 100644 --- a/app/screens/home/channel_list/additional_tablet_view/additional_tablet_view.tsx +++ b/app/screens/home/channel_list/additional_tablet_view/additional_tablet_view.tsx @@ -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> = { [Screens.CHANNEL]: Channel, [Screens.GLOBAL_THREADS]: GlobalThreads, + [Screens.GLOBAL_DRAFTS]: GlobalDrafts, }; const channelScreen: SelectedView = {id: Screens.CHANNEL, Component: Channel}; diff --git a/app/screens/home/channel_list/categories_list/index.tsx b/app/screens/home/channel_list/categories_list/index.tsx index d61417580d5..085e8597361 100644 --- a/app/screens/home/channel_list/categories_list/index.tsx +++ b/app/screens/home/channel_list/categories_list/index.tsx @@ -5,6 +5,7 @@ import React, {useEffect, useMemo} from 'react'; import {useWindowDimensions} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import DraftsButtton from '@components/drafts_buttton'; import ThreadsButton from '@components/threads_button'; import {TABLET_SIDEBAR_WIDTH, TEAM_SIDEBAR_WIDTH} from '@constants/view'; import {useTheme} from '@context/theme'; @@ -72,6 +73,7 @@ const CategoriesList = ({hasChannels, iconPad, isCRTEnabled, moreThanOneTeam}: C shouldHighlighActive={true} /> } + ); diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 30e9b206825..540c80b1521 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -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; diff --git a/app/utils/draft/index.ts b/app/utils/draft/index.ts index 3f88fca614b..d16daebd317 100644 --- a/app/utils/draft/index.ts +++ b/app/utils/draft/index.ts @@ -3,11 +3,13 @@ import {Alert, type AlertButton} from 'react-native'; +import {parseMarkdownImages, removeDraft, updateDraftMarkdownImageMetadata, updateDraftMessage} from '@actions/local/draft'; import {General} from '@constants'; import {CODE_REGEX} from '@constants/autocomplete'; import {t} from '@i18n'; import type {IntlShape, MessageDescriptor} from 'react-intl'; +import type {Swipeable} from 'react-native-gesture-handler'; type AlertCallback = (value?: string) => void; @@ -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 = {}; + 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; +}) { + 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, + }, + ], + ); +} diff --git a/app/utils/post/index.ts b/app/utils/post/index.ts index dd29cdd5c6e..202fb08cac2 100644 --- a/app/utils/post/index.ts +++ b/app/utils/post/index.ts @@ -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, + ); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 76f2e399ed9..38f62b4838a 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -167,6 +167,8 @@ "channel_info.copy_link": "Copy Link", "channel_info.copy_purpose_text": "Copy Purpose Text", "channel_info.custom_status": "Custom status:", + "channel_info.draft_in_channel": "In:", + "channel_info.draft_to_user": "To:", "channel_info.edit_header": "Edit Header", "channel_info.error_close": "Close", "channel_info.favorite": "Favorite", @@ -194,6 +196,7 @@ "channel_info.send_a_mesasge": "Send a message", "channel_info.send_mesasge": "Send message", "channel_info.set_header": "Set Header", + "channel_info.thread_in": "Thread in:", "channel_info.unarchive": "Unarchive Channel", "channel_info.unarchive_description": "Are you sure you want to unarchive the {term} {name}?", "channel_info.unarchive_failed": "An error occurred trying to unarchive the channel {displayName}", @@ -305,6 +308,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 +1025,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.", diff --git a/assets/base/images/Draft_Message.png b/assets/base/images/Draft_Message.png new file mode 100644 index 00000000000..0f065934acf Binary files /dev/null and b/assets/base/images/Draft_Message.png differ diff --git a/assets/base/images/emojis/swipe.png b/assets/base/images/emojis/swipe.png new file mode 100644 index 00000000000..8ef4d61366a Binary files /dev/null and b/assets/base/images/emojis/swipe.png differ