Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mobile drafts #8280

Open
wants to merge 72 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
63b313f
refactor: started with draft, done until new tabs for draft
Rajat-Dabade Sep 14, 2024
11e758e
refactor: change the query and added the screen for draft
Rajat-Dabade Sep 16, 2024
d72a19f
added condition for fetching draft for channel delete or not
Rajat-Dabade Sep 17, 2024
572d65b
refactor: added draft screen
Rajat-Dabade Sep 20, 2024
d5389b8
linter fixes
Rajat-Dabade Sep 22, 2024
ae55bde
Added draft post component
Rajat-Dabade Sep 24, 2024
afbe59d
added avatar and header display name for the draft post list
Rajat-Dabade Sep 25, 2024
6c99a7c
added channel info component
Rajat-Dabade Oct 2, 2024
9fcbf84
channel info completed
Rajat-Dabade Oct 7, 2024
d688908
proper naming
Rajat-Dabade Oct 7, 2024
324aa16
added image file markdown acknowledgement support
Rajat-Dabade Oct 7, 2024
65b6edd
draft actions
Rajat-Dabade Oct 9, 2024
272c468
Fix the draft receiver in drafts
Rajat-Dabade Oct 14, 2024
1fa189a
separated send message handler
Rajat-Dabade Oct 14, 2024
ba3ee8d
Done with send drafts
Rajat-Dabade Oct 14, 2024
554123f
done with delete drafts
Rajat-Dabade Oct 15, 2024
e629247
change save to send draft
Rajat-Dabade Oct 15, 2024
f8bcead
handle lengthy message with show more button
Rajat-Dabade Oct 15, 2024
6577c6e
done with persistent message edit, send and delete drafts
Rajat-Dabade Oct 17, 2024
d331d46
added alert for sending message
Rajat-Dabade Oct 17, 2024
62d0bbb
added update at time for the drafts
Rajat-Dabade Oct 17, 2024
5308824
en.json extract fix
Rajat-Dabade Oct 22, 2024
611cff0
Updated dependencies for useCallback
Rajat-Dabade Oct 23, 2024
aac1f38
refactor: added drafts list to animated list
Rajat-Dabade Oct 24, 2024
8c153d6
added swipeable component and delete conformation for drafts
Rajat-Dabade Oct 26, 2024
112048a
done with rendering of images in markdown for drafts
Rajat-Dabade Oct 30, 2024
2589127
en.json issue fixed
Rajat-Dabade Nov 5, 2024
a70b165
fix en.json issue
Rajat-Dabade Nov 5, 2024
6f56a41
refactor: en.json fix
Rajat-Dabade Nov 5, 2024
e140826
addressed review comments
Rajat-Dabade Nov 12, 2024
a85a1a4
updated image metadata handling code
Rajat-Dabade Nov 12, 2024
c72c14d
linter fixes
Rajat-Dabade Nov 13, 2024
f5b884a
added the empty draft screen
Rajat-Dabade Nov 18, 2024
cffb22b
linter fix
Rajat-Dabade Nov 18, 2024
b9c48d5
style fix
Rajat-Dabade Nov 18, 2024
424c773
back button an android takes to the channel list page
Rajat-Dabade Nov 18, 2024
6f054ae
en.json fix
Rajat-Dabade Nov 18, 2024
15e6bc3
draft actions theme compatible
Rajat-Dabade Nov 26, 2024
faabf4e
CSS fix for draft channel_info and avatar component
Rajat-Dabade Nov 26, 2024
9b130b6
removed the badge icon and change font style drafts
Rajat-Dabade Nov 26, 2024
c595c3d
fix send alert sender name for GMs
Rajat-Dabade Nov 26, 2024
b167354
updated snapshot
Rajat-Dabade Nov 26, 2024
eee6c26
added testId to the drafts components
Rajat-Dabade Nov 26, 2024
47ac4fe
updated send draft test id
Rajat-Dabade Nov 26, 2024
a842910
clicking on draft takes to the channel
Rajat-Dabade Dec 2, 2024
b26aa59
Added toptip for draft tours
Rajat-Dabade Dec 4, 2024
3d16827
intl extract
Rajat-Dabade Dec 4, 2024
5d7bc78
Rebase to main and reverted local testing changes
Rajat-Dabade Dec 5, 2024
e667dd7
Added tooltip for drafts
Rajat-Dabade Dec 5, 2024
f152788
addressed review comments
Rajat-Dabade Dec 5, 2024
aa1ad72
reset navigation when click on a draft in draft tabs
Rajat-Dabade Dec 10, 2024
f2e2e47
Merge branch 'main' into mobile-drafts
mattermost-build Dec 11, 2024
cd93248
fix the theme issue and navigation issue
Rajat-Dabade Dec 11, 2024
8965394
reverted back the draft click navigation changes
Rajat-Dabade Dec 11, 2024
8c8e6f6
observing draft when hitting back button
Rajat-Dabade Dec 11, 2024
be07659
removed the unwanted animiation
Rajat-Dabade Dec 12, 2024
ab7ce3a
updated regex for parsing markdown
Rajat-Dabade Dec 16, 2024
2b5a671
removed unnecessary checks and change folder name
Rajat-Dabade Dec 16, 2024
22f1528
removed react memo and merge unwanted observes function
Rajat-Dabade Dec 16, 2024
a542590
removed unnecessary comments
Rajat-Dabade Dec 16, 2024
ea131a8
changed the name for observing and querying draft function
Rajat-Dabade Dec 16, 2024
1ca7ddb
removed memo from component level
Rajat-Dabade Dec 16, 2024
8a1952e
Text to FormattedText component
Rajat-Dabade Dec 16, 2024
2941ab1
Text to formatted text, change image name
Rajat-Dabade Dec 16, 2024
07ec62c
added confirmation modal for deleting draft from bottomsheet
Rajat-Dabade Dec 16, 2024
80681bd
using common send_handler for both draft and post
Rajat-Dabade Dec 16, 2024
9ea0914
removed magic number for tooltip and bottomsheet
Rajat-Dabade Dec 17, 2024
ba7fb70
renamed channel_info to draft_post_header
Rajat-Dabade Dec 17, 2024
8d427db
text to formattedText for Edit drafts
Rajat-Dabade Dec 17, 2024
2954843
removed unnecessary changes
Rajat-Dabade Dec 17, 2024
d4c988b
minor fixes
Rajat-Dabade Dec 17, 2024
2fca19c
mounting draft only when there is draft
Rajat-Dabade Dec 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/actions/app/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down
104 changes: 104 additions & 0 deletions app/actions/local/draft.ts
Original file line number Diff line number Diff line change
@@ -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, isValidUrl} 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);
Expand Down Expand Up @@ -197,3 +211,93 @@ 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 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<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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm, should this only allow http/https/ftp/ftps ? what about the extensions, do we want to do anything with that to only attempt those that are supported?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0/5 on restricting to only http/https/ftp/ftps, since users can provide any valid URL, and as long as it returns an image, it should work. Also, I don't think extensions matter here because URLs don't always include extensions but can still return valid images.

const matches = Array.from(markdown.matchAll(imageRegex));

const promises = matches.map(async (match) => {
const imageUrl = match[1];
if (isValidUrl(imageUrl)) {
const metadata = await getImageMetadata(imageUrl);
imageMetadata[imageUrl] = metadata;
}
});
Comment on lines +294 to +300
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does return anything so the promises will always be an array of undefined.

Suggested change
const promises = matches.map(async (match) => {
const imageUrl = match[1];
if (isValidUrl(imageUrl)) {
const metadata = await getImageMetadata(imageUrl);
imageMetadata[imageUrl] = metadata;
}
});
const promises = matches.reduce<Array<Promise<MetadataReturnedByGetImageImagedataType>>((result, match) => {
const imageUrl = match[1];
if (isValidUrl(imageUrl)) {
result.push(getImageMetadata(imageUrl));
}
return result;
}, []);

getImageMetadata should include the url in the return object so that you can map it after or something like that

another option would be to use for await but this will make it sequential and not parallel


await Promise.all(promises);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion app/components/channel_item/channel_item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
124 changes: 124 additions & 0 deletions app/components/draft/draft.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, TouchableHighlight, View} from 'react-native';

import {switchToThread} from '@actions/local/thread';
import {switchToChannelById} from '@actions/remote/channel';
import DraftPost from '@components/draft/draft_post';
import DraftPostHeader from '@components/draft_post_header';
import Header from '@components/post_draft/draft_input/header';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {DRAFT_OPTIONS_BUTTON} from '@screens/draft_options';
import {openAsBottomSheet} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';

import type ChannelModel from '@typings/database/models/servers/channel';
import type DraftModel from '@typings/database/models/servers/draft';
import type UserModel from '@typings/database/models/servers/user';

type Props = {
channel: ChannelModel;
location: string;
draftReceiverUser?: UserModel;
draft: DraftModel;
layoutWidth: number;
isPostPriorityEnabled: boolean;
}

const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
paddingHorizontal: 20,
paddingVertical: 16,
width: '100%',
borderTopColor: changeOpacity(theme.centerChannelColor, 0.16),
borderTopWidth: 1,
},
pressInContainer: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.16),
},
postPriority: {
marginTop: 10,
marginLeft: -12,
},
};
});

const Draft: React.FC<Props> = ({
channel,
location,
draft,
draftReceiverUser,
layoutWidth,
isPostPriorityEnabled,
}) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
const showPostPriority = Boolean(isPostPriorityEnabled && draft.metadata?.priority && draft.metadata?.priority?.priority);

const onLongPress = useCallback(() => {
Keyboard.dismiss();
const title = isTablet ? intl.formatMessage({id: 'draft.options.title', defaultMessage: 'Draft Options'}) : 'Draft Options';
openAsBottomSheet({
closeButtonId: DRAFT_OPTIONS_BUTTON,
screen: Screens.DRAFT_OPTIONS,
theme,
title,
props: {channel, rootId: draft.rootId, draft, draftReceiverUserName: draftReceiverUser?.username},
});
}, [channel, draft, draftReceiverUser?.username, intl, isTablet, theme]);

const onPress = useCallback(() => {
if (draft.rootId) {
switchToThread(serverUrl, draft.rootId, false);
return;
}
switchToChannelById(serverUrl, channel.id, channel.teamId, false);
}, [channel.id, channel.teamId, draft.rootId, serverUrl]);

return (
<TouchableHighlight
onLongPress={onLongPress}
onPress={onPress}
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
testID='draft_post'
>
<View
style={style.container}
>
<DraftPostHeader
channel={channel}
draftReceiverUser={draftReceiverUser}
rootId={draft.rootId}
testID='draft_post.channel_info'
updateAt={draft.updateAt}
/>
{showPostPriority && draft.metadata?.priority &&
<View style={style.postPriority}>
<Header
noMentionsError={false}
postPriority={draft.metadata?.priority}
/>
</View>
}
<DraftPost
draft={draft}
location={location}
layoutWidth={layoutWidth}
/>
</View>

</TouchableHighlight>
);
};

export default Draft;
18 changes: 18 additions & 0 deletions app/components/draft/draft_post/draft_files/index.ts
Original file line number Diff line number Diff line change
@@ -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));
Loading