-
Notifications
You must be signed in to change notification settings - Fork 1.4k
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
base: main
Are you sure you want to change the base?
Mobile drafts #8280
Changes from all commits
63b313f
11e758e
d72a19f
572d65b
d5389b8
ae55bde
afbe59d
6c99a7c
9fcbf84
d688908
324aa16
65b6edd
272c468
1fa189a
ba3ee8d
554123f
e629247
f8bcead
6577c6e
d331d46
62d0bbb
5308824
611cff0
aac1f38
8c153d6
112048a
2589127
a70b165
6f56a41
e140826
a85a1a4
c72c14d
f5b884a
cffb22b
b9c48d5
424c773
6f054ae
15e6bc3
faabf4e
9b130b6
c595c3d
b167354
eee6c26
47ac4fe
a842910
b26aa59
3d16827
5d7bc78
e667dd7
f152788
aa1ad72
f2e2e47
cd93248
8965394
8c8e6f6
be07659
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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); | ||
|
@@ -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; | ||
while ((match = imageRegex.exec(markdown)) !== null) { | ||
const imageUrl = match[1]; | ||
// eslint-disable-next-line no-await-in-loop | ||
imageMetadata[imageUrl] = await getImageMetadata(imageUrl); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks prone to race |
||
} | ||
} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
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'; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
||||||
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; |
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)); |
There was a problem hiding this comment.
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?