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 56 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 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
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
94 changes: 94 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} 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,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<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>) {
let match;
const imageRegex = /!\[.*?\]\((https:\/\/[^\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.

If the url is only with http this will not match. Is that what we want?

while ((match = imageRegex.exec(markdown)) !== null) {
const imageUrl = match[1];
// eslint-disable-next-line no-await-in-loop
imageMetadata[imageUrl] = await getImageMetadata(imageUrl);
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks prone to race

}
}
2 changes: 1 addition & 1 deletion app/actions/remote/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions app/components/channel_info/avatar/index.tsx
Copy link
Contributor

Choose a reason for hiding this comment

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

The name of the folder does not make a lot of sense to me

Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Based on the prop, author should always be defined, something I'm missing?

uri = buildProfileImageUrlFromUser(serverUrl, author);
}

let picture;
if (uri) {
picture = (
<Image
source={{uri: buildAbsoluteUrl(serverUrl, uri)}}
style={[styles.avatar, styles.avatarRadius]}
/>
);
} else {
picture = (
<CompassIcon
name='account-outline'
size={22}
color={changeOpacity('#fff', 0.48)}
/>
);
}

return (
<View style={[styles.avatarContainer, styles.avatarRadius]}>
{picture}
</View>
);
};

export default Avatar;

164 changes: 164 additions & 0 deletions app/components/channel_info/channel_info.tsx
Original file line number Diff line number Diff line change
@@ -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';
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
import CompassIcon from '../compass_icon';
import CompassIcon from '@components/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<Props> = ({
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 ? <Avatar author={draftReceiverUser}/> : (
<View style={style.categoryIconContainer}>
<CompassIcon
color={changeOpacity(theme.centerChannelColor, 0.64)}
name='globe'
size={16}
/>
</View>);

if (rootId) {
headerComponent = (
<View style={style.channelInfo}>
<FormattedText
id='channel_info.thread_in'
defaultMessage={'Thread in:'}
style={style.category}
/>
<View style={style.profileComponentContainer}>
{profileComponent}
</View>
</View>
);
} else if (isChannelTypeDM) {
headerComponent = (
<View style={style.channelInfo}>
<FormattedText
id='channel_info.draft_to_user'
defaultMessage={'To:'}
style={style.category}
/>
<View style={style.profileComponentContainer}>
{profileComponent}
</View>
</View>
);
} else {
headerComponent = (
<View style={style.channelInfo}>
<FormattedText
id='channel_info.draft_in_channel'
defaultMessage={'In:'}
style={style.category}
/>
<View style={style.profileComponentContainer}>
{profileComponent}
</View>
</View>
);
}

return (

<View
style={style.container}
testID={testID}
>
<View style={style.infoContainer}>
{headerComponent}
<Text style={style.displayName}>
{channel.displayName}
</Text>
</View>
<FormattedTime
timezone={getUserTimezone(currentUser)}
isMilitaryTime={isMilitaryTime}
value={updateAt}
style={style.time}
testID='post_header.date_time'
/>
</View>
);
};

export default ChannelInfo;
25 changes: 25 additions & 0 deletions app/components/channel_info/index.tsx
Original file line number Diff line number Diff line change
@@ -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));
Loading