Compare commits

...

15 Commits

Author SHA1 Message Date
Mattermost Build
13b36d1eab Bump app build number to 356 (#5385) (#5386)
(cherry picked from commit f47f872b1a)

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2021-05-11 11:03:12 -07:00
Mattermost Build
51e3c4c8b4 Allow post option modal to close before opening app modal (#5383) (#5384)
* Allow post option modal to close before opening app modal

* lint

* revert commits

25ea111d9a and c0d9e21286

* wait for post options to close before kicking off request

(cherry picked from commit 7227675320)

Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com>
2021-05-11 12:39:25 -04:00
Mattermost Build
2abe5cb8b1 MM-35320 Fix race condition when saving draft on tablets (#5379) (#5382)
(cherry picked from commit 71e0150d27)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-05-10 22:14:03 -04:00
Mattermost Build
49be62695e Bump app build number to 355 (#5372) (#5374)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
(cherry picked from commit fe48da091f)

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2021-05-04 09:18:18 -04:00
Mattermost Build
d7d140b1b4 Bump app version number to 1.43.0 (#5371) (#5373) 2021-05-04 08:28:09 -04:00
Mattermost Build
d4259e7f16 MM-31510 fix: last message is rendered behind the input box on iPad (#5365) (#5369)
(cherry picked from commit d42aba8287)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-05-03 17:24:49 -04:00
Mattermost Build
0d94f74237 MM-31874 Properly use theme mention highlight link color (#5366) (#5368)
(cherry picked from commit 4880689bdc)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-05-03 17:24:32 -04:00
Mattermost Build
483f7187fc MM-34069 include current user in return values from getProfile* actions (#5308) (#5363)
(cherry picked from commit f14d4ee70f)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-04-30 09:04:52 -04:00
Mattermost Build
a5297c328c [MM-34746] Fix ephemeral error message response (#5304) (#5360)
* fix ephemeral error message response

* extract helpers for posting call responses

(cherry picked from commit 3eaa538707)

Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com>
2021-04-29 11:26:51 -04:00
Mattermost Build
3f9a941aa1 [MM-34203] Send team id on bindings call (#5316) (#5361) 2021-04-29 11:16:51 -04:00
Mattermost Build
a60d3eeb92 MM-34407 Use Avatars for Sidebar DMs (#5334) (#5358)
* MM-34407 Use Avatars for Sidebar DMs

* Update app/components/channel_icon.js

Co-authored-by: Joseph Baylon <joseph.baylon@mattermost.com>

* UI feedback

* Align the private channel icon

Co-authored-by: Joseph Baylon <joseph.baylon@mattermost.com>
(cherry picked from commit 1b36529853)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2021-04-29 09:18:48 -04:00
Mattermost Build
562d3ac9ad [MM-26160] Updates to check if text is string before attaching as string (#5353) (#5359)
* Updates to check if text is string before attaching as string
* Code tidy-up

(cherry picked from commit f01981ca25)

Co-authored-by: Shaz Amjad <shaz.amjad@mattermost.com>
2021-04-29 11:05:00 +10:00
Mattermost Build
42cee12c19 MM-24693 Add since query param to the getGroups api request (#5163) (#5356)
* Added since query param to the getGroups api request

* Update app/actions/views/channel.js

Co-authored-by: Hossein <hahmadia@users.noreply.github.com>

* Replaced lastConnectAt with lastDisconnectAt

* Reverted client4.ts file

* Updated client file

* Fetches all groups enabled/disabled

Co-authored-by: Hossein <hahmadia@users.noreply.github.com>
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
(cherry picked from commit e7d5f2c194)

Co-authored-by: Anurag Shivarathri <anurag6713@gmail.com>
2021-04-28 00:19:27 +05:30
Mattermost Build
df00b86d26 Updates input height on edit post (#5349) (#5355) 2021-04-27 13:09:14 -04:00
Mattermost Build
a11aad3a5a Migrate message attachments to typescript (#5127) (#5348) 2021-04-24 06:50:36 -04:00
117 changed files with 2045 additions and 1881 deletions

View File

@@ -132,8 +132,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
versionCode 354
versionName "1.42.0"
versionCode 356
versionName "1.43.0"
multiDexEnabled = true
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

View File

@@ -27,6 +27,7 @@ import org.json.JSONException;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
@@ -45,6 +46,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
super(reactContext);
mApplication = application;
}
private File tempFolder;
@Override
@@ -131,6 +133,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
String text = "";
String type = "";
String action = "";
String extra = "";
Activity currentActivity = getCurrentActivity();
@@ -139,20 +142,21 @@ public class ShareModule extends ReactContextBaseJavaModule {
Intent intent = currentActivity.getIntent();
action = intent.getAction();
type = intent.getType();
extra = intent.getStringExtra(Intent.EXTRA_TEXT);
if (type == null) {
type = "";
}
if (Intent.ACTION_SEND.equals(action) && "text/plain".equals(type)) {
text = intent.getStringExtra(Intent.EXTRA_TEXT);
map.putString("value", text);
if (Intent.ACTION_SEND.equals(action) && "text/plain".equals(type) && extra != null) {
map.putString("value", extra);
map.putString("type", type);
map.putBoolean("isString", true);
items.pushMap(map);
} else if (Intent.ACTION_SEND.equals(action)) {
Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (uri != null) {
text = "file://" + RealPathUtil.getRealPathFromURI(currentActivity, uri);
map.putString("value", text);
map.putString("value", "file://" + RealPathUtil.getRealPathFromURI(currentActivity, uri));
if (type.equals("image/*")) {
type = "image/jpeg";
@@ -161,17 +165,16 @@ public class ShareModule extends ReactContextBaseJavaModule {
}
map.putString("type", type);
map.putBoolean("isString", false);
items.pushMap(map);
}
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
ArrayList<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
for (Uri uri : uris) {
String filePath = RealPathUtil.getRealPathFromURI(currentActivity, uri);
for (Uri uri : Objects.requireNonNull(uris)) {
map = Arguments.createMap();
text = "file://" + filePath;
map.putString("value", text);
map.putString("value", "file://" + RealPathUtil.getRealPathFromURI(currentActivity, uri));
type = RealPathUtil.getMimeTypeFromUri(currentActivity, uri);
if (type != null) {
if (type.equals("image/*")) {
type = "image/jpeg";
@@ -182,6 +185,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
type = "application/octet-stream";
}
map.putString("type", type);
map.putBoolean("isString", false);
items.pushMap(map);
}
}
@@ -221,7 +225,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
MultipartBody.Builder builder = new MultipartBody.Builder()
.setType(MultipartBody.FORM);
for(int i = 0 ; i < files.size() ; i++) {
for (int i = 0; i < files.size(); i++) {
ReadableMap file = files.getMap(i);
String filePath = file.getString("fullPath").replaceFirst("file://", "");
File fileInfo = new File(filePath);
@@ -245,7 +249,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
JSONObject responseJson = new JSONObject(responseData);
JSONArray fileInfoArray = responseJson.getJSONArray("file_infos");
JSONArray file_ids = new JSONArray();
for(int i = 0 ; i < fileInfoArray.length() ; i++) {
for (int i = 0; i < fileInfoArray.length(); i++) {
JSONObject fileInfo = fileInfoArray.getJSONObject(i);
file_ids.put(fileInfo.getString("id"));
}

View File

@@ -2,8 +2,11 @@
// See LICENSE.txt for license information.
import {Client4} from '@client/rest';
import {ActionFunc} from '@mm-redux/types/actions';
import {AppCallResponse, AppForm, AppCallRequest, AppCallType} from '@mm-redux/types/apps';
import {ActionFunc, DispatchFunc} from '@mm-redux/types/actions';
import {AppCallResponse, AppForm, AppCallRequest, AppCallType, AppContext} from '@mm-redux/types/apps';
import {Post} from '@mm-redux/types/posts';
import {AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {showModal} from './navigation';
@@ -12,6 +15,8 @@ import CompassIcon from '@components/compass_icon';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import EphemeralStore from '@store/ephemeral_store';
import {makeCallErrorResponse} from '@utils/apps';
import {sendEphemeralPost} from '@actions/views/post';
import {CommandArgs} from '@mm-redux/types/integrations';
export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType, intl: any): ActionFunc {
return async (dispatch, getState) => {
@@ -114,3 +119,47 @@ const showAppForm = async (form: AppForm, call: AppCallRequest, theme: Theme) =>
const passProps = {form, call};
showModal('AppForm', form.title, passProps, options);
};
export function postEphemeralCallResponseForPost(response: AppCallResponse, message: string, post: Post): ActionFunc {
return (dispatch: DispatchFunc) => {
return dispatch(sendEphemeralPost(
message,
post.channel_id,
post.root_id || post.id,
response.app_metadata?.bot_user_id,
));
};
}
export function postEphemeralCallResponseForChannel(response: AppCallResponse, message: string, channelID: string): ActionFunc {
return (dispatch: DispatchFunc) => {
return dispatch(sendEphemeralPost(
message,
channelID,
'',
response.app_metadata?.bot_user_id,
));
};
}
export function postEphemeralCallResponseForContext(response: AppCallResponse, message: string, context: AppContext): ActionFunc {
return (dispatch: DispatchFunc) => {
return dispatch(sendEphemeralPost(
message,
context.channel_id,
context.root_id || context.post_id,
response.app_metadata?.bot_user_id,
));
};
}
export function postEphemeralCallResponseForCommandArgs(response: AppCallResponse, message: string, args: CommandArgs): ActionFunc {
return (dispatch: DispatchFunc) => {
return dispatch(sendEphemeralPost(
message,
args.channel_id,
args.root_id,
response.app_metadata?.bot_user_id,
));
};
}

View File

@@ -632,7 +632,7 @@ function setLoadMorePostsVisible(visible) {
};
}
function loadGroupData() {
function loadGroupData(isReconnect = false) {
return async (dispatch, getState) => {
const state = getState();
const actions = [];
@@ -665,9 +665,10 @@ function loadGroupData() {
});
}
} else {
const getGroupsSince = isReconnect ? (state.websocket?.lastDisconnectAt || 0) : undefined;
const [getAllGroupsAssociatedToChannelsInTeam, getGroups] = await Promise.all([ //eslint-disable-line no-await-in-loop
Client4.getAllGroupsAssociatedToChannelsInTeam(team.id, true),
Client4.getGroups(true, 0, 0),
Client4.getGroups(false, 0, 0, getGroupsSince),
]);
if (getAllGroupsAssociatedToChannelsInTeam.groups) {
@@ -713,7 +714,7 @@ function loadGroupData() {
};
}
export function loadChannelsForTeam(teamId, skipDispatch = false) {
export function loadChannelsForTeam(teamId, skipDispatch = false, isReconnect = false) {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
@@ -784,7 +785,7 @@ export function loadChannelsForTeam(teamId, skipDispatch = false) {
dispatch(loadUnreadChannelPosts(data.channels, data.channelMembers));
}
dispatch(loadGroupData());
dispatch(loadGroupData(isReconnect));
}
return {data};

View File

@@ -8,13 +8,13 @@ import {executeCommand as executeCommandService} from '@mm-redux/actions/integra
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps';
import {DispatchFunc, GetStateFunc, ActionFunc} from '@mm-redux/types/actions';
import {CommandArgs} from '@mm-redux/types/integrations';
import {AppCommandParser} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser';
import {doAppCall} from '@actions/apps';
import {doAppCall, postEphemeralCallResponseForCommandArgs} from '@actions/apps';
import {appsEnabled} from '@utils/apps';
import {AppCallResponse} from '@mm-redux/types/apps';
import {sendEphemeralPost} from './post';
export function executeCommand(message: string, channelId: string, rootId: string, intl: typeof intlShape): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
@@ -22,7 +22,7 @@ export function executeCommand(message: string, channelId: string, rootId: strin
const teamId = getCurrentTeamId(state);
const args = {
const args: CommandArgs = {
channel_id: channelId,
team_id: teamId,
root_id: rootId,
@@ -65,7 +65,7 @@ export function executeCommand(message: string, channelId: string, rootId: strin
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
dispatch(sendEphemeralPost(callResp.markdown, args.channel_id, args.parent_id, callResp.app_metadata?.bot_user_id));
dispatch(postEphemeralCallResponseForCommandArgs(callResp, callResp.markdown, args));
}
return {data: {}};
case AppCallResponseTypes.FORM:

View File

@@ -155,7 +155,7 @@ export function doReconnect(now: number) {
const currentTeamMembership = me.teamMembers.find((tm: TeamMembership) => tm.team_id === currentTeamId && tm.delete_at === 0);
if (currentTeamMembership) {
const {data: myData}: any = await dispatch(loadChannelsForTeam(currentTeamId, true));
const {data: myData}: any = await dispatch(loadChannelsForTeam(currentTeamId, true, true));
if (myData?.channels && myData?.channelMembers) {
actions.push({

View File

@@ -2,10 +2,11 @@
// See LICENSE.txt for license information.
import type {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {buildQueryString} from '@mm-redux/utils/helpers';
export interface ClientAppsMix {
executeAppCall: (call: AppCallRequest, type: AppCallType) => Promise<AppCallResponse>;
getAppsBindings: (userID: string, channelID: string) => Promise<AppBinding[]>;
getAppsBindings: (userID: string, channelID: string, teamID: string) => Promise<AppBinding[]>;
}
const ClientApps = (superclass: any) => class extends superclass {
@@ -25,9 +26,16 @@ const ClientApps = (superclass: any) => class extends superclass {
);
}
getAppsBindings = async (userID: string, channelID: string) => {
getAppsBindings = async (userID: string, channelID: string, teamID: string) => {
const params = {
user_id: userID,
channel_id: channelID,
team_id: teamID,
user_agent: 'mobile',
};
return this.doFetch(
this.getAppsProxyRoute() + `/api/v1/bindings?user_id=${userID}&channel_id=${channelID}&user_agent_type=mobile`,
`${this.getAppsProxyRoute()}/api/v1/bindings${buildQueryString(params)}`,
{method: 'get'},
);
}

View File

@@ -15,9 +15,9 @@ export interface ClientGroupsMix {
}
const ClientGroups = (superclass: any) => class extends superclass {
getGroups = async (filterAllowReference = false, page = 0, perPage = PER_PAGE_DEFAULT) => {
getGroups = async (filterAllowReference = false, page = 0, perPage = PER_PAGE_DEFAULT, since = 0) => {
return this.doFetch(
`${this.getBaseRoute()}/groups${buildQueryString({filter_allow_reference: filterAllowReference, page, per_page: perPage})}`,
`${this.getBaseRoute()}/groups${buildQueryString({filter_allow_reference: filterAllowReference, page, per_page: perPage, since})}`,
{method: 'get'},
);
};

View File

@@ -34,7 +34,7 @@ exports[`AtMention should match snapshot, with highlight 1`] = `
},
Object {
"backgroundColor": "#ffe577",
"color": "#145dbf",
"color": "#166de0",
},
]
}

View File

@@ -205,7 +205,7 @@ export default class AtMention extends React.PureComponent {
}
if (highlighted) {
mentionTextStyle.push({backgroundColor, color: theme.mentionColor});
mentionTextStyle.push({backgroundColor, color: theme.mentionHighlightLink});
}
return (

View File

@@ -49,7 +49,6 @@ export {getUserByUsername as selectUserByUsername} from '@mm-redux/selectors/ent
export {getUserByUsername} from '@mm-redux/actions/users';
export {getChannelByNameAndTeamName} from '@mm-redux/actions/channels';
export {sendEphemeralPost} from '@actions/views/post';
export {doAppCall} from '@actions/apps';
export {createCallRequest} from '@utils/apps';

View File

@@ -1,236 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {
Text,
View,
} from 'react-native';
import {General} from '@mm-redux/constants';
import CompassIcon from '@components/compass_icon';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
export default class ChannelIcon extends React.PureComponent {
static propTypes = {
isActive: PropTypes.bool,
isInfo: PropTypes.bool,
isUnread: PropTypes.bool,
hasDraft: PropTypes.bool,
membersCount: PropTypes.number,
size: PropTypes.number,
status: PropTypes.string,
theme: PropTypes.object.isRequired,
type: PropTypes.string.isRequired,
isArchived: PropTypes.bool.isRequired,
isBot: PropTypes.bool.isRequired,
testID: PropTypes.string,
};
static defaultProps = {
isActive: false,
isInfo: false,
isUnread: false,
size: 12,
};
render() {
const {
isActive,
isUnread,
isInfo,
hasDraft,
membersCount,
size,
status,
theme,
type,
isArchived,
isBot,
testID,
} = this.props;
const style = getStyleSheet(theme);
let activeIcon;
let unreadIcon;
let activeGroupBox;
let unreadGroupBox;
let activeGroup;
let unreadGroup;
let offlineColor = changeOpacity(theme.sidebarText, 0.5);
if (isUnread) {
unreadIcon = style.iconUnread;
unreadGroupBox = style.groupBoxUnread;
unreadGroup = style.groupUnread;
}
if (isActive) {
activeIcon = style.iconActive;
activeGroupBox = style.groupBoxActive;
activeGroup = style.groupActive;
}
if (isInfo) {
activeIcon = style.iconInfo;
activeGroupBox = style.groupBoxInfo;
activeGroup = style.groupInfo;
offlineColor = changeOpacity(theme.centerChannelColor, 0.5);
}
let icon;
if (isArchived) {
icon = (
<CompassIcon
name='archive-outline'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size}]}
testID={`${testID}.archive`}
/>
);
} else if (isBot) {
icon = (
<CompassIcon
name='robot-happy'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size, left: -1.5, top: -1}]}
testID={`${testID}.bot`}
/>
);
} else if (hasDraft) {
icon = (
<CompassIcon
name='pencil-outline'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size}]}
testID={`${testID}.draft`}
/>
);
} else if (type === General.OPEN_CHANNEL) {
icon = (
<CompassIcon
name='globe'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size}]}
testID={`${testID}.public`}
/>
);
} else if (type === General.PRIVATE_CHANNEL) {
icon = (
<CompassIcon
name='lock-outline'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size, left: 0.5}]}
testID={`${testID}.private`}
/>
);
} else if (type === General.GM_CHANNEL) {
const fontSize = (size - 10);
icon = (
<View style={[style.groupBox, unreadGroupBox, activeGroupBox, {width: size, height: size}]}>
<Text
style={[style.group, unreadGroup, activeGroup, {fontSize}]}
testID={`${testID}.gm_member_count`}
>
{membersCount}
</Text>
</View>
);
} else if (type === General.DM_CHANNEL) {
switch (status) {
case General.AWAY:
icon = (
<CompassIcon
name='clock'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size, color: theme.awayIndicator}]}
testID={`${testID}.away`}
/>
);
break;
case General.DND:
icon = (
<CompassIcon
name='minus-circle'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size, color: theme.dndIndicator}]}
testID={`${testID}.dnd`}
/>
);
break;
case General.ONLINE:
icon = (
<CompassIcon
name='check-circle'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size, color: theme.onlineIndicator}]}
testID={`${testID}.online`}
/>
);
break;
default:
icon = (
<CompassIcon
name='circle-outline'
style={[style.icon, unreadIcon, activeIcon, {fontSize: size, color: offlineColor}]}
testID={`${testID}.offline`}
/>
);
break;
}
}
return (
<View style={[style.container, {height: size}]}>
{icon}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
marginRight: 8,
alignItems: 'center',
},
icon: {
color: changeOpacity(theme.sidebarText, 0.4),
},
iconActive: {
color: theme.sidebarTextActiveColor,
},
iconUnread: {
color: theme.sidebarUnreadText,
},
iconInfo: {
color: theme.centerChannelColor,
},
groupBox: {
alignSelf: 'flex-start',
alignItems: 'center',
backgroundColor: changeOpacity(theme.sidebarText, 0.3),
borderColor: changeOpacity(theme.sidebarText, 0.3),
borderWidth: 1,
borderRadius: 2,
justifyContent: 'center',
},
groupBoxActive: {
backgroundColor: changeOpacity(theme.sidebarTextActiveColor, 0.3),
},
groupBoxUnread: {
backgroundColor: changeOpacity(theme.sidebarUnreadText, 0.3),
},
groupBoxInfo: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.3),
},
group: {
color: changeOpacity(theme.sidebarText, 0.6),
fontSize: 10,
fontWeight: '600',
},
groupActive: {
color: theme.sidebarTextActiveColor,
},
groupUnread: {
color: theme.sidebarUnreadText,
},
groupInfo: {
color: theme.centerChannelColor,
},
};
});

View File

@@ -0,0 +1,184 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, Text, View, ViewStyle} from 'react-native';
import CompassIcon from '@components/compass_icon';
import ProfilePicture from '@components/profile_picture';
import {General} from '@mm-redux/constants';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {Theme} from '@mm-redux/types/preferences';
type Props = {
hasDraft: boolean;
isActive: boolean;
isArchived: boolean;
isInfo: boolean;
isUnread: boolean;
membersCount: number;
size: number;
statusStyle?: StyleProp<ViewStyle>;
testID?: string;
theme: Theme;
type: string;
userId?: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
alignItems: 'center',
justifyContent: 'center',
},
icon: {
color: changeOpacity(theme.sidebarText, 0.4),
},
iconActive: {
color: theme.sidebarTextActiveColor,
},
iconUnread: {
color: theme.sidebarUnreadText,
},
iconInfo: {
color: theme.centerChannelColor,
},
groupBox: {
alignItems: 'center',
backgroundColor: changeOpacity(theme.sidebarText, 0.16),
borderRadius: 4,
justifyContent: 'center',
},
groupBoxActive: {
backgroundColor: changeOpacity(theme.sidebarTextActiveColor, 0.3),
},
groupBoxUnread: {
backgroundColor: changeOpacity(theme.sidebarUnreadText, 0.3),
},
groupBoxInfo: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.3),
},
group: {
color: theme.sidebarText,
fontSize: 10,
fontWeight: '600',
},
groupActive: {
color: theme.sidebarTextActiveColor,
},
groupUnread: {
color: theme.sidebarUnreadText,
},
groupInfo: {
color: theme.centerChannelColor,
},
};
});
const ChannelIcon = (props: Props) => {
const style = getStyleSheet(props.theme);
let activeIcon;
let unreadIcon;
let activeGroupBox;
let unreadGroupBox;
let activeGroup;
let unreadGroup;
if (props.isUnread) {
unreadIcon = style.iconUnread;
unreadGroupBox = style.groupBoxUnread;
unreadGroup = style.groupUnread;
}
if (props.isActive) {
activeIcon = style.iconActive;
activeGroupBox = style.groupBoxActive;
activeGroup = style.groupActive;
}
if (props.isInfo) {
activeIcon = style.iconInfo;
activeGroupBox = style.groupBoxInfo;
activeGroup = style.groupInfo;
}
let icon;
let extraStyle;
if (props.isArchived) {
icon = (
<CompassIcon
name='archive-outline'
style={[style.icon, unreadIcon, activeIcon, {fontSize: props.size, left: 1}]}
testID={`${props.testID}.archive`}
/>
);
} else if (props.hasDraft) {
icon = (
<CompassIcon
name='pencil-outline'
style={[style.icon, unreadIcon, activeIcon, {fontSize: props.size, left: 2}]}
testID={`${props.testID}.draft`}
/>
);
} else if (props.type === General.OPEN_CHANNEL) {
icon = (
<CompassIcon
name='globe'
style={[style.icon, unreadIcon, activeIcon, {fontSize: props.size, left: 1}]}
testID={`${props.testID}.public`}
/>
);
} else if (props.type === General.PRIVATE_CHANNEL) {
icon = (
<CompassIcon
name='lock-outline'
style={[style.icon, unreadIcon, activeIcon, {fontSize: props.size, left: 0.5}]}
testID={`${props.testID}.private`}
/>
);
} else if (props.type === General.GM_CHANNEL) {
const fontSize = (props.size - 12);
const boxSize = (props.size - 4);
icon = (
<View style={[style.groupBox, unreadGroupBox, activeGroupBox, {width: boxSize, height: boxSize}]}>
<Text
style={[style.group, unreadGroup, activeGroup, {fontSize}]}
testID={`${props.testID}.gm_member_count`}
>
{props.membersCount}
</Text>
</View>
);
} else if (props.type === General.DM_CHANNEL) {
// extraStyle = {marginRight: 6};
icon = (
<ProfilePicture
size={props.size}
statusSize={12}
userId={props.userId}
testID={props.testID}
statusStyle={props.statusStyle}
/>
);
}
return (
<View style={[style.container, extraStyle, {width: props.size, height: props.size}]}>
{icon}
</View>
);
};
ChannelIcon.defaultProps = {
hasDraft: false,
isActive: false,
isArchived: false,
isInfo: false,
isUnread: false,
membersCount: 0,
size: 12,
};
export default ChannelIcon;

View File

@@ -11,18 +11,18 @@ import {getStatusColors} from '@utils/message_attachment_colors';
import ButtonBindingText from './button_binding_text';
import {Theme} from '@mm-redux/types/preferences';
import {ActionResult} from '@mm-redux/types/actions';
import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {AppBinding} from '@mm-redux/types/apps';
import {Post} from '@mm-redux/types/posts';
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {createCallContext, createCallRequest} from '@utils/apps';
import {Channel} from '@mm-redux/types/channels';
import {SendEphemeralPost} from 'types/actions/posts';
type Props = {
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
doAppCall: DoAppCall;
getChannel: (channelId: string) => Promise<ActionResult>;
sendEphemeralPost: SendEphemeralPost;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
};
post: Post;
binding: AppBinding;
@@ -72,14 +72,13 @@ export default class ButtonBinding extends PureComponent<Props> {
this.setState({executing: false});
}
const ephemeral = (message: string) => this.props.actions.sendEphemeralPost(message, this.props.post.channel_id, this.props.post.root_id, res.data?.app_metadata?.bot_user_id);
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.error || intl.formatMessage(
{id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
ephemeral(errorMessage);
this.props.actions.postEphemeralCallResponseForPost(errorResponse, errorMessage, post);
return;
}
@@ -88,7 +87,7 @@ export default class ButtonBinding extends PureComponent<Props> {
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
ephemeral(callResp.markdown);
this.props.actions.postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
}
return;
case AppCallResponseTypes.NAVIGATE:
@@ -104,7 +103,7 @@ export default class ButtonBinding extends PureComponent<Props> {
type: callResp.type,
},
);
ephemeral(errorMessage);
this.props.actions.postEphemeralCallResponseForPost(callResp, errorMessage, post);
}
}
}, 4000);

View File

@@ -10,7 +10,7 @@ import {reEmoji, reEmoticon, reMain} from '@constants/emoji';
import {getEmoticonName} from '@utils/emoji_utils';
export default function ButtonBindingText({message, style}: {message: string; style: StyleProp<any>}) {
const components = [] as JSX.Element[];
const components = [] as React.ReactNode[];
let text = message;
while (text) {

View File

@@ -8,15 +8,13 @@ import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {GlobalState} from '@mm-redux/types/store';
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
import {AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {doAppCall} from '@actions/apps';
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
import {getPost} from '@mm-redux/selectors/entities/posts';
import ButtonBinding from './button_binding';
import {getChannel} from '@mm-redux/actions/channels';
import {sendEphemeralPost} from '@actions/views/post';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {SendEphemeralPost} from 'types/actions/posts';
type OwnProps = {
postId: string;
@@ -31,9 +29,9 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
}
type Actions = {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
doAppCall: DoAppCall;
getChannel: (channelId: string) => Promise<ActionResult>;
sendEphemeralPost: SendEphemeralPost;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
@@ -41,7 +39,7 @@ function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
getChannel,
sendEphemeralPost,
postEphemeralCallResponseForPost,
}, dispatch),
};
}

View File

@@ -21,7 +21,7 @@ export default function EmbeddedSubBindings(props: Props) {
return null;
}
const content = [] as JSX.Element[];
const content = [] as React.ReactNode[];
bindings.forEach((binding) => {
if (!binding.app_id || !binding.call) {

View File

@@ -31,7 +31,7 @@ export default function EmbeddedBindings(props: Props) {
theme,
textStyles,
} = props;
const content = [] as JSX.Element[];
const content = [] as React.ReactNode[];
embeds.forEach((embed, i) => {
content.push(

View File

@@ -5,16 +5,14 @@ import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import {GlobalState} from '@mm-redux/types/store';
import {doAppCall} from '@actions/apps';
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
import {AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {SendEphemeralPost} from 'types/actions/posts';
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
import {getPost} from '@mm-redux/selectors/entities/posts';
import MenuBinding from './menu_binding';
import {getChannel} from '@mm-redux/actions/channels';
import {sendEphemeralPost} from '@actions/views/post';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
type OwnProps = {
postId: string;
@@ -28,9 +26,9 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
}
type Actions = {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
doAppCall: DoAppCall;
getChannel: (channelId: string) => Promise<ActionResult>;
sendEphemeralPost: SendEphemeralPost;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
@@ -38,7 +36,7 @@ function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
getChannel,
sendEphemeralPost,
postEphemeralCallResponseForPost,
}, dispatch),
};
}

View File

@@ -7,18 +7,18 @@ import AutocompleteSelector from 'app/components/autocomplete_selector';
import {intlShape} from 'react-intl';
import {PostActionOption} from '@mm-redux/types/integration_actions';
import {Post} from '@mm-redux/types/posts';
import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {AppBinding} from '@mm-redux/types/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {Channel} from '@mm-redux/types/channels';
import {createCallContext, createCallRequest} from '@utils/apps';
import {SendEphemeralPost} from 'types/actions/posts';
type Props = {
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
doAppCall: DoAppCall;
getChannel: (channelId: string) => Promise<ActionResult>;
sendEphemeralPost: SendEphemeralPost;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
};
binding?: AppBinding;
post: Post;
@@ -82,16 +82,14 @@ export default class MenuBinding extends PureComponent<Props, State> {
{post: AppExpandLevels.EXPAND_ALL},
);
const res = await actions.doAppCall(call, AppCallTypes.SUBMIT, this.context.intl);
const ephemeral = (message: string) => this.props.actions.sendEphemeralPost(message, this.props.post.channel_id, this.props.post.root_id, res.data?.app_metadata?.bot_user_id);
const res = await actions.doAppCall(call, AppCallTypes.SUBMIT, intl);
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.error || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
ephemeral(errorMessage);
this.props.actions.postEphemeralCallResponseForPost(res.error, errorMessage, post);
return;
}
@@ -99,7 +97,7 @@ export default class MenuBinding extends PureComponent<Props, State> {
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
ephemeral(callResp.markdown);
this.props.actions.postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
}
return;
case AppCallResponseTypes.NAVIGATE:
@@ -112,7 +110,7 @@ export default class MenuBinding extends PureComponent<Props, State> {
}, {
type: callResp.type,
});
ephemeral(errorMessage);
this.props.actions.postEphemeralCallResponseForPost(callResp, errorMessage, post);
}
}
};

View File

@@ -1,117 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {
Platform,
StyleSheet,
Text,
} from 'react-native';
import FastImage from 'react-native-fast-image';
export default class Emoji extends React.PureComponent {
static propTypes = {
/*
* Emoji text name.
*/
emojiName: PropTypes.string.isRequired,
/*
* Image URL for the emoji.
*/
imageUrl: PropTypes.string.isRequired,
/*
* Set if this is a custom emoji.
*/
isCustomEmoji: PropTypes.bool.isRequired,
/*
* Set to render only the text and no image.
*/
displayTextOnly: PropTypes.bool,
literal: PropTypes.string,
size: PropTypes.number,
textStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
unicode: PropTypes.string,
customEmojiStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
testID: PropTypes.string,
};
static defaultProps = {
customEmojis: new Map(),
literal: '',
imageUrl: '',
isCustomEmoji: false,
};
render() {
const {
customEmojiStyle,
displayTextOnly,
imageUrl,
literal,
unicode,
testID,
textStyle,
} = this.props;
let size = this.props.size;
let fontSize = size;
if (!size && textStyle) {
const flatten = StyleSheet.flatten(textStyle);
fontSize = flatten.fontSize;
size = fontSize;
}
if (displayTextOnly) {
return (
<Text
style={textStyle}
testID={testID}
>
{literal}
</Text>);
}
const width = size;
const height = size;
// Android can't change the size of an image after its first render, so
// force a new image to be rendered when the size changes
const key = Platform.OS === 'android' ? (`${imageUrl}-${height}-${width}`) : null;
if (unicode && !imageUrl) {
const codeArray = unicode.split('-');
const code = codeArray.reduce((acc, c) => {
return acc + String.fromCodePoint(parseInt(c, 16));
}, '');
return (
<Text
style={[textStyle, {fontSize: size}]}
testID={testID}
>
{code}
</Text>
);
}
if (!imageUrl) {
return null;
}
return (
<FastImage
key={key}
style={[customEmojiStyle, {width, height}]}
source={{uri: imageUrl}}
onError={this.onError}
resizeMode={FastImage.resizeMode.contain}
testID={testID}
/>
);
}
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {
Platform,
StyleProp,
StyleSheet,
Text,
TextStyle,
} from 'react-native';
import FastImage, {ImageStyle} from 'react-native-fast-image';
type Props = {
/*
* Emoji text name.
*/
emojiName: string;
/*
* Image URL for the emoji.
*/
imageUrl: string;
/*
* Set if this is a custom emoji.
*/
isCustomEmoji: boolean;
/*
* Set to render only the text and no image.
*/
displayTextOnly?: boolean;
literal?: string;
size?: number;
textStyle?: StyleProp<TextStyle>;
unicode?: string;
customEmojiStyle?: StyleProp<ImageStyle>;
testID?: string;
}
const Emoji: React.FC<Props> = (props: Props) => {
const {
customEmojiStyle,
displayTextOnly,
imageUrl,
literal,
unicode,
testID,
textStyle,
} = props;
let size = props.size;
let fontSize = size;
if (!size && textStyle) {
const flatten = StyleSheet.flatten(textStyle);
fontSize = flatten.fontSize;
size = fontSize;
}
if (displayTextOnly) {
return (
<Text
style={textStyle}
testID={testID}
>
{literal}
</Text>);
}
const width = size;
const height = size;
if (unicode && !imageUrl) {
const codeArray = unicode.split('-');
const code = codeArray.reduce((acc, c) => {
return acc + String.fromCodePoint(parseInt(c, 16));
}, '');
return (
<Text
style={[textStyle, {fontSize: size}]}
testID={testID}
>
{code}
</Text>
);
}
if (!imageUrl) {
return null;
}
// Android can't change the size of an image after its first render, so
// force a new image to be rendered when the size changes
const key = Platform.OS === 'android' ? (`${imageUrl}-${height}-${width}`) : null;
return (
<FastImage
key={key}
style={[customEmojiStyle, {width, height}]}
source={{uri: imageUrl}}
resizeMode={FastImage.resizeMode.contain}
testID={testID}
/>
);
};
Emoji.defaultProps = {
literal: '',
imageUrl: '',
isCustomEmoji: false,
};
export default Emoji;

View File

@@ -8,12 +8,17 @@ import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {Client4} from '@client/rest';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {GlobalState} from '@mm-redux/types/store';
import {BuiltInEmojis, EmojiIndicesByAlias, Emojis} from 'app/utils/emojis';
import {BuiltInEmojis, EmojiIndicesByAlias, Emojis} from '@utils/emojis';
import Emoji from './emoji';
function mapStateToProps(state, ownProps) {
type OwnProps = {
emojiName: string;
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const config = getConfig(state);
const emojiName = ownProps.emojiName;
const customEmojis = getCustomEmojisByName(state);
@@ -24,7 +29,7 @@ function mapStateToProps(state, ownProps) {
let isCustomEmoji = false;
let displayTextOnly = false;
if (EmojiIndicesByAlias.has(emojiName) || BuiltInEmojis.includes(emojiName)) {
const emoji = Emojis[EmojiIndicesByAlias.get(emojiName)];
const emoji = Emojis[EmojiIndicesByAlias.get(emojiName)!];
unicode = emoji.filename;
if (BuiltInEmojis.includes(emojiName)) {
if (serverUrl) {
@@ -35,7 +40,7 @@ function mapStateToProps(state, ownProps) {
}
} else if (customEmojis.has(emojiName) && serverUrl) {
const emoji = customEmojis.get(emojiName);
imageUrl = Client4.getCustomEmojiImageUrl(emoji.id);
imageUrl = Client4.getCustomEmojiImageUrl(emoji!.id);
isCustomEmoji = true;
} else {
displayTextOnly = state.entities.emojis.nonExistentEmoji.has(emojiName) ||

View File

@@ -10,7 +10,7 @@ import {
} from 'react-native';
import shallowEqual from 'shallow-equals';
import Emoji from 'app/components/emoji';
import Emoji from '@components/emoji';
export default class EmojiPickerRow extends Component {
static propTypes = {

View File

@@ -13,10 +13,10 @@ import {
import * as Utils from '@mm-redux/utils/file_utils';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {isDocument, isImage} from 'app/utils/file';
import {calculateDimensions} from 'app/utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {isDocument, isImage} from '@utils/file';
import {calculateDimensions} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import FileAttachmentDocument from './file_attachment_document';
import FileAttachmentIcon from './file_attachment_icon';

View File

@@ -11,13 +11,13 @@ import {
View,
} from 'react-native';
import AtMention from 'app/components/at_mention';
import ChannelLink from 'app/components/channel_link';
import Emoji from 'app/components/emoji';
import FormattedText from 'app/components/formatted_text';
import Hashtag from 'app/components/markdown/hashtag';
import {blendColors, concatStyles, makeStyleSheetFromTheme} from 'app/utils/theme';
import {getScheme} from 'app/utils/url';
import AtMention from '@components/at_mention';
import ChannelLink from '@components/channel_link';
import Emoji from '@components/emoji';
import FormattedText from '@components/formatted_text';
import Hashtag from '@components/markdown/hashtag';
import {blendColors, concatStyles, makeStyleSheetFromTheme} from '@utils/theme';
import {getScheme} from '@utils/url';
import MarkdownBlockQuote from './markdown_block_quote';
import MarkdownCodeBlock from './markdown_code_block';

View File

@@ -7,9 +7,9 @@ import React, {PureComponent} from 'react';
import {Platform, Text, View} from 'react-native';
import PropTypes from 'prop-types';
import Emoji from 'app/components/emoji';
import FormattedText from 'app/components/formatted_text';
import {blendColors, concatStyles, makeStyleSheetFromTheme} from 'app/utils/theme';
import Emoji from '@components/emoji';
import FormattedText from '@components/formatted_text';
import {blendColors, concatStyles, makeStyleSheetFromTheme} from '@utils/theme';
export default class MarkdownEmoji extends PureComponent {
static propTypes = {

View File

@@ -13,6 +13,7 @@ import {calculateDimensions, isGifTooLarge, openGalleryAtIndex} from '@utils/ima
import {generateId} from '@utils/file';
import type {PostImage} from '@mm-redux/types/posts';
import {FileInfo} from '@mm-redux/types/files';
type MarkdownTableImageProps = {
disable: boolean;
@@ -78,8 +79,11 @@ const MarkTableImage = ({disable, imagesMetadata, postId, serverURL, source}: Ma
return;
}
const files = [getFileInfo()];
openGalleryAtIndex(0, files);
const file = getFileInfo() as FileInfo;
if (!file) {
return;
}
openGalleryAtIndex(0, [file]);
}, []);
const onLoadFailed = useCallback(() => {

View File

@@ -27,7 +27,9 @@ exports[`AttachmentFooter it matches snapshot when both footer and footer_icon a
}
/>
<Text
ellipsizeMode="tail"
key="footer_text"
numberOfLines={1}
style={
Object {
"color": "rgba(61,60,64,0.5)",
@@ -51,7 +53,9 @@ exports[`AttachmentFooter it matches snapshot when footer text is provided 1`] =
}
>
<Text
ellipsizeMode="tail"
key="footer_text"
numberOfLines={1}
style={
Object {
"color": "rgba(61,60,64,0.5)",
@@ -67,43 +71,3 @@ exports[`AttachmentFooter it matches snapshot when footer text is provided 1`] =
exports[`AttachmentFooter it matches snapshot when no footer is provided 1`] = `""`;
exports[`AttachmentFooter it matches snapshot when only the footer icon is provided 1`] = `""`;
exports[`AttachmentFooter it matches snapshot when the footer is longer than the maximum length 1`] = `
<View
style={
Object {
"flex": 1,
"flexDirection": "row",
"marginTop": 5,
}
}
>
<FastImage
key="footer_icon"
source={
Object {
"uri": "https://images.com/image.png",
}
}
style={
Object {
"height": 12,
"marginRight": 5,
"marginTop": 1,
"width": 12,
}
}
/>
<Text
key="footer_text"
style={
Object {
"color": "rgba(61,60,64,0.5)",
"fontSize": 11,
}
}
>
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…
</Text>
</View>
`;

View File

@@ -3,9 +3,11 @@
import React from 'react';
import {shallow} from 'enzyme';
import ActionButton from './action_button';
import {changeOpacity} from 'app/utils/theme';
import {getStatusColors} from 'app/utils/message_attachment_colors';
import {changeOpacity} from '@utils/theme';
import {getStatusColors} from '@utils/message_attachment_colors';
import Preferences from '@mm-redux/constants/preferences';
@@ -106,7 +108,6 @@ describe('ActionButton', () => {
cookie: '',
name: buttonConfig.name,
postId: buttonConfig.id,
buttonColor: buttonConfig.style,
theme: Preferences.THEMES.default,
actions: {
doPostActionWithCookie: jest.fn(),

View File

@@ -2,31 +2,33 @@
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import Button from 'react-native-button';
import {preventDoubleTap} from 'app/utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import {getStatusColors} from 'app/utils/message_attachment_colors';
import ActionButtonText from './action_button_text';
export default class ActionButton extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
doPostActionWithCookie: PropTypes.func.isRequired,
}).isRequired,
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
postId: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
cookie: PropTypes.string,
disabled: PropTypes.bool,
buttonColor: PropTypes.string,
};
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {getStatusColors} from '@utils/message_attachment_colors';
import {Theme} from '@mm-redux/types/preferences';
import {ActionResult} from '@mm-redux/types/actions';
type Props = {
actions: {
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise<ActionResult>;
};
id: string;
name: string;
postId: string;
theme: Theme,
cookie?: string,
disabled?: boolean,
buttonColor?: string,
}
export default class ActionButton extends PureComponent<Props> {
handleActionPress = preventDoubleTap(() => {
const {actions, id, postId, cookie} = this.props;
actions.doPostActionWithCookie(postId, id, cookie);
actions.doPostActionWithCookie(postId, id, cookie || '');
}, 4000);
render() {
@@ -58,7 +60,7 @@ export default class ActionButton extends PureComponent {
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const STATUS_COLORS = getStatusColors(theme);
return {
button: {

View File

@@ -3,6 +3,7 @@
import React from 'react';
import {shallow} from 'enzyme';
import ActionButtonText from './action_button_text';
describe('ActionButtonText emojis', () => {

View File

@@ -3,13 +3,14 @@
import React from 'react';
import PropTypes from 'prop-types';
import {Text, View, StyleSheet} from 'react-native';
import Emoji from 'app/components/emoji';
import {getEmoticonName} from 'app/utils/emoji_utils';
import {reEmoji, reEmoticon, reMain} from 'app/constants/emoji';
import {Text, View, StyleSheet, StyleProp, TextStyle} from 'react-native';
export default function ActionButtonText({message, style}) {
const components = [];
import Emoji from '@components/emoji';
import {reEmoji, reEmoticon, reMain} from '@constants/emoji';
import {getEmoticonName} from '@utils/emoji_utils';
export default function ActionButtonText({message, style}: {message: string; style: StyleProp<TextStyle>}) {
const components = [] as React.ReactNode[];
let text = message;
while (text) {
@@ -49,6 +50,9 @@ export default function ActionButtonText({message, style}) {
// This is plain text, so capture as much text as possible until we hit the next possible emoji. Note that
// reMain always captures at least one character, so text will always be getting shorter
match = text.match(reMain);
if (!match) {
continue;
}
components.push(
<Text

View File

@@ -1,26 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {doPostActionWithCookie} from '@mm-redux/actions/posts';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import ActionButton from './action_button';
function mapStateToProps(state) {
return {
theme: getTheme(state),
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
doPostActionWithCookie,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ActionButton);

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import ActionButton from './action_button';
import {doPostActionWithCookie} from '@mm-redux/actions/posts';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {GlobalState} from '@mm-redux/types/store';
import {ActionFunc, ActionResult} from '@mm-redux/types/actions';
function mapStateToProps(state: GlobalState) {
return {
theme: getTheme(state),
};
}
type Actions = {
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string | undefined) => Promise<ActionResult>;
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doPostActionWithCookie,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ActionButton);

View File

@@ -1,81 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import AutocompleteSelector from 'app/components/autocomplete_selector';
export default class ActionMenu extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
selectAttachmentMenuAction: PropTypes.func.isRequired,
}).isRequired,
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
dataSource: PropTypes.string,
defaultOption: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.object),
postId: PropTypes.string.isRequired,
selected: PropTypes.object,
disabled: PropTypes.bool,
};
constructor(props) {
super(props);
let selected;
if (props.defaultOption && props.options) {
selected = props.options.find((option) => option.value === props.defaultOption);
}
this.state = {
selected,
};
}
static getDerivedStateFromProps(props, state) {
if (props.selected && props.selected !== state.selected) {
return {
selected: props.selected,
};
}
return null;
}
handleSelect = (selected) => {
if (!selected) {
return;
}
const {
actions,
id,
postId,
} = this.props;
actions.selectAttachmentMenuAction(postId, id, selected.text, selected.value);
};
render() {
const {
name,
dataSource,
options,
disabled,
} = this.props;
const {selected} = this.state;
return (
<AutocompleteSelector
placeholder={name}
dataSource={dataSource}
options={options}
selected={selected}
onSelected={this.handleSelect}
disabled={disabled}
/>
);
}
}

View File

@@ -29,7 +29,7 @@ describe('ActionMenu', () => {
test('should start with nothing selected when no default is selected', () => {
const wrapper = shallow(<ActionMenu {...baseProps}/>);
expect(wrapper.state('selected')).toBeUndefined();
expect(wrapper.props().selected).toBeUndefined();
});
test('should set selected based on default option', () => {
@@ -39,7 +39,7 @@ describe('ActionMenu', () => {
};
const wrapper = shallow(<ActionMenu {...props}/>);
expect(wrapper.state('selected')).toBe(props.options[1]);
expect(wrapper.props().selected).toBe(props.options[1]);
});
test('should start with previous value selected', () => {
@@ -50,7 +50,7 @@ describe('ActionMenu', () => {
};
const wrapper = shallow(<ActionMenu {...props}/>);
expect(wrapper.state('selected')).toBe(props.selected);
expect(wrapper.props().selected).toBe(props.selected);
});
test('disabled works', () => {

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import AutocompleteSelector from '@components/autocomplete_selector';
import {PostActionOption} from '@mm-redux/types/integration_actions';
type Props = {
actions: {
selectAttachmentMenuAction: (postId: string, actionId: string, text: string, value: string) => void;
};
id: string;
name: string;
dataSource?: string;
defaultOption?: string;
options?: PostActionOption[];
postId: string;
selected?: PostActionOption;
disabled?: boolean;
}
const ActionMenu: React.FC<Props> = (props: Props) => {
let selected: PostActionOption | undefined;
if (props.defaultOption && props.options) {
selected = props.options.find((option) => option.value === props.defaultOption);
}
if (props.selected) {
selected = props.selected;
}
const handleSelect = (selectedItem?: PostActionOption) => {
if (!selectedItem) {
return;
}
const {
actions,
id,
postId,
} = props;
actions.selectAttachmentMenuAction(postId, id, selectedItem.text, selectedItem.value);
};
const {
name,
dataSource,
options,
disabled,
} = props;
return (
<AutocompleteSelector
placeholder={name}
dataSource={dataSource}
options={options}
selected={selected}
onSelected={handleSelect}
disabled={disabled}
/>
);
};
export default ActionMenu;

View File

@@ -1,14 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {bindActionCreators} from 'redux';
import {bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import {selectAttachmentMenuAction} from 'app/actions/views/post';
import {GlobalState} from '@mm-redux/types/store';
import {selectAttachmentMenuAction} from '@actions/views/post';
import ActionMenu from './action_menu';
function mapStateToProps(state, ownProps) {
type OwnProps = {
postId: string;
id: string;
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const actions = state.views.post.submittedMenuActions[ownProps.postId];
const selected = actions?.[ownProps.id];
@@ -17,7 +24,7 @@ function mapStateToProps(state, ownProps) {
};
}
function mapDispatchToProps(dispatch) {
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
selectAttachmentMenuAction,

View File

@@ -1,67 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import ActionMenu from './action_menu';
import ActionButton from './action_button';
export default class AttachmentActions extends PureComponent {
static propTypes = {
actions: PropTypes.array,
postId: PropTypes.string.isRequired,
};
render() {
const {
actions,
postId,
} = this.props;
if (!actions?.length) {
return null;
}
const content = [];
actions.forEach((action) => {
if (!action.id || !action.name) {
return;
}
switch (action.type) {
case 'select':
content.push(
<ActionMenu
key={action.id}
id={action.id}
name={action.name}
dataSource={action.data_source}
defaultOption={action.default_option}
options={action.options}
postId={postId}
disabled={action.disabled}
/>,
);
break;
case 'button':
default:
content.push(
<ActionButton
key={action.id}
id={action.id}
cookie={action.cookie}
name={action.name}
postId={postId}
disabled={action.disabled}
buttonColor={action.style}
/>,
);
break;
}
});
return content.length ? content : null;
}
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import ActionMenu from './action_menu';
import ActionButton from './action_button';
import {PostAction} from '@mm-redux/types/integration_actions';
type Props = {
actions?: PostAction[];
postId: string;
}
export default function AttachmentActions(props: Props) {
const {
actions,
postId,
} = props;
if (!actions?.length) {
return null;
}
const content = [] as React.ReactNode[];
actions.forEach((action) => {
if (!action.id || !action.name) {
return;
}
switch (action.type) {
case 'select':
content.push(
<ActionMenu
key={action.id}
id={action.id}
name={action.name}
dataSource={action.data_source}
defaultOption={action.default_option}
options={action.options}
postId={postId}
disabled={action.disabled}
/>,
);
break;
case 'button':
default:
content.push(
<ActionButton
key={action.id}
id={action.id}
cookie={action.cookie}
name={action.name}
postId={postId}
disabled={action.disabled}
buttonColor={action.style}
/>,
);
break;
}
});
return content.length ? (<>{content}</>) : null;
}

View File

@@ -5,19 +5,18 @@ import React, {PureComponent} from 'react';
import {Alert, Text, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import {intlShape} from 'react-intl';
import PropTypes from 'prop-types';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {tryOpenURL} from '@utils/url';
import {Theme} from '@mm-redux/types/preferences';
export default class AttachmentAuthor extends PureComponent {
static propTypes = {
icon: PropTypes.string,
link: PropTypes.string,
name: PropTypes.string,
theme: PropTypes.object.isRequired,
};
type Props = {
icon?: string;
link?: string;
name?: string;
theme: Theme;
}
export default class AttachmentAuthor extends PureComponent<Props> {
static contextTypes = {
intl: intlShape.isRequired,
};
@@ -81,7 +80,7 @@ export default class AttachmentAuthor extends PureComponent {
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,

View File

@@ -1,142 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {Text, View} from 'react-native';
import PropTypes from 'prop-types';
import Markdown from '@components/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
export default class AttachmentFields extends PureComponent {
static propTypes = {
baseTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
blockStyles: PropTypes.object.isRequired,
fields: PropTypes.array,
metadata: PropTypes.object,
onPermalinkPress: PropTypes.func,
textStyles: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
};
render() {
const {
baseTextStyle,
blockStyles,
fields,
metadata,
onPermalinkPress,
textStyles,
theme,
} = this.props;
if (!fields?.length) {
return null;
}
const style = getStyleSheet(theme);
const fieldTables = [];
let fieldInfos = [];
let rowPos = 0;
let lastWasLong = false;
let nrTables = 0;
fields.forEach((field, i) => {
if (rowPos === 2 || !(field.short === true) || lastWasLong) {
fieldTables.push(
<View
key={`attachment__table__${nrTables}`}
style={style.field}
>
{fieldInfos}
</View>,
);
fieldInfos = [];
rowPos = 0;
nrTables += 1;
lastWasLong = false;
}
fieldInfos.push(
<View
style={style.flex}
key={`attachment__field-${i}__${nrTables}`}
>
{Boolean(field.title) && (
<View
style={style.headingContainer}
key={`attachment__field-caption-${i}__${nrTables}`}
>
<View>
<Text style={style.heading}>
{field.title}
</Text>
</View>
</View>
)}
<View
style={style.flex}
key={`attachment__field-${i}__${nrTables}`}
>
<Markdown
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
imagesMetadata={metadata?.images}
value={(field.value || '')}
onPermalinkPress={onPermalinkPress}
/>
</View>
</View>,
);
rowPos += 1;
lastWasLong = !(field.short === true);
});
if (fieldInfos.length > 0) { // Flush last fields
fieldTables.push(
<View
key={`attachment__table__${nrTables}`}
style={style.table}
>
{fieldInfos}
</View>,
);
}
return (
<View>
{fieldTables}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
field: {
alignSelf: 'stretch',
flexDirection: 'row',
},
flex: {
flex: 1,
},
headingContainer: {
alignSelf: 'stretch',
flexDirection: 'row',
marginBottom: 5,
marginTop: 10,
},
heading: {
color: theme.centerChannelColor,
fontWeight: '600',
},
table: {
flex: 1,
flexDirection: 'row',
},
};
});

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, Text, TextStyle, View, ViewStyle} from 'react-native';
import Markdown from '@components/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {Theme} from '@mm-redux/types/preferences';
import {MessageAttachmentField} from '@mm-redux/types/message_attachments';
import {PostMetadata} from '@mm-redux/types/posts';
type Props = {
baseTextStyle: StyleProp<TextStyle>,
blockStyles?: StyleProp<ViewStyle>[],
fields?: MessageAttachmentField[],
metadata?: PostMetadata,
onPermalinkPress?: () => void,
textStyles?: StyleProp<TextStyle>[],
theme: Theme,
}
export default function AttachmentFields(props: Props) {
const {
baseTextStyle,
blockStyles,
fields,
metadata,
onPermalinkPress,
textStyles,
theme,
} = props;
if (!fields?.length) {
return null;
}
const style = getStyleSheet(theme);
const fieldTables = [];
let fieldInfos = [] as React.ReactNode[];
let rowPos = 0;
let lastWasLong = false;
let nrTables = 0;
fields.forEach((field, i) => {
if (rowPos === 2 || !(field.short === true) || lastWasLong) {
fieldTables.push(
<View
key={`attachment__table__${nrTables}`}
style={style.field}
>
{fieldInfos}
</View>,
);
fieldInfos = [];
rowPos = 0;
nrTables += 1;
lastWasLong = false;
}
fieldInfos.push(
<View
style={style.flex}
key={`attachment__field-${i}__${nrTables}`}
>
{Boolean(field.title) && (
<View
style={style.headingContainer}
key={`attachment__field-caption-${i}__${nrTables}`}
>
<View>
<Text style={style.heading}>
{field.title}
</Text>
</View>
</View>
)}
<View
style={style.flex}
key={`attachment__field-${i}__${nrTables}`}
>
<Markdown
//TODO: remove conversion when markdown is migrated to typescript
baseTextStyle={baseTextStyle as any}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
imagesMetadata={metadata?.images}
value={(field.value || '')}
onPermalinkPress={onPermalinkPress}
/>
</View>
</View>,
);
rowPos += 1;
lastWasLong = !(field.short === true);
});
if (fieldInfos.length > 0) { // Flush last fields
fieldTables.push(
<View
key={`attachment__table__${nrTables}`}
style={style.table}
>
{fieldInfos}
</View>,
);
}
return (
<View>
{fieldTables}
</View>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
field: {
alignSelf: 'stretch',
flexDirection: 'row',
},
flex: {
flex: 1,
},
headingContainer: {
alignSelf: 'stretch',
flexDirection: 'row',
marginBottom: 5,
marginTop: 10,
},
heading: {
color: theme.centerChannelColor,
fontWeight: '600',
},
table: {
flex: 1,
flexDirection: 'row',
},
};
});

View File

@@ -1,78 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {Text, View, Platform} from 'react-native';
import FastImage from 'react-native-fast-image';
import PropTypes from 'prop-types';
import truncate from 'lodash/truncate';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {MAX_ATTACHMENT_FOOTER_LENGTH} from 'app/constants/attachment';
export default class AttachmentFooter extends PureComponent {
static propTypes = {
text: PropTypes.string,
icon: PropTypes.string,
theme: PropTypes.object.isRequired,
};
render() {
const {
text,
icon,
theme,
} = this.props;
if (!text) {
return null;
}
const style = getStyleSheet(theme);
return (
<View style={style.container}>
{Boolean(icon) &&
<FastImage
source={{uri: icon}}
key='footer_icon'
style={style.icon}
/>
}
<Text
key='footer_text'
style={style.text}
>
{truncate(text, {length: MAX_ATTACHMENT_FOOTER_LENGTH, omission: '…'})}
</Text>
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
flexDirection: 'row',
marginTop: 5,
},
icon: {
height: 12,
width: 12,
marginRight: 5,
...Platform.select({
ios: {
marginTop: 1,
},
android: {
marginTop: 2,
},
}),
},
text: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 11,
},
};
});

View File

@@ -4,7 +4,6 @@
import React from 'react';
import {shallow} from 'enzyme';
import {MAX_ATTACHMENT_FOOTER_LENGTH} from 'app/constants/attachment';
import AttachmentFooter from './attachment_footer';
import Preferences from '@mm-redux/constants/preferences';
@@ -51,14 +50,4 @@ describe('AttachmentFooter', () => {
const wrapper = shallow(<AttachmentFooter {...baseProps}/>);
expect(wrapper).toMatchSnapshot();
});
test('it matches snapshot when the footer is longer than the maximum length', () => {
const props = {
...baseProps,
text: 'a'.repeat(MAX_ATTACHMENT_FOOTER_LENGTH + 1),
};
const wrapper = shallow(<AttachmentFooter {...props}/>);
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text, View, Platform} from 'react-native';
import FastImage from 'react-native-fast-image';
import {Theme} from '@mm-redux/types/preferences';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
text?: string;
icon?: string;
theme: Theme;
}
export default function AttachmentFooter(props: Props) {
const {
text,
icon,
theme,
} = props;
if (!text) {
return null;
}
const style = getStyleSheet(theme);
return (
<View style={style.container}>
{Boolean(icon) &&
<FastImage
source={{uri: icon}}
key='footer_icon'
style={style.icon}
/>
}
<Text
key='footer_text'
style={style.text}
ellipsizeMode='tail'
numberOfLines={1}
>
{text}
</Text>
</View>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
flexDirection: 'row',
marginTop: 5,
},
icon: {
height: 12,
width: 12,
marginRight: 5,
...Platform.select({
ios: {
marginTop: 1,
},
android: {
marginTop: 2,
},
}),
},
text: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 11,
},
};
});

View File

@@ -6,7 +6,7 @@ import React from 'react';
import Preferences from '@mm-redux/constants/preferences';
import AttachmentImage from './index';
import AttachmentImage, {State, Props} from './index';
describe('AttachmentImage', () => {
const baseProps = {
@@ -15,7 +15,7 @@ describe('AttachmentImage', () => {
imageMetadata: {width: 32, height: 32},
imageUrl: 'https://images.com/image.png',
theme: Preferences.THEMES.default,
};
} as Props;
test('it matches snapshot', () => {
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
@@ -23,7 +23,7 @@ describe('AttachmentImage', () => {
});
test('it sets state based on props', () => {
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...baseProps}/>);
const state = wrapper.state();
expect(state.hasImage).toBe(true);
@@ -32,8 +32,11 @@ describe('AttachmentImage', () => {
});
test('it does not render image if no imageUrl is provided', () => {
const props = {...baseProps, imageUrl: null, imageMetadata: null};
const wrapper = shallow(<AttachmentImage {...props}/>);
const props = {...baseProps};
delete props.imageUrl;
delete props.imageMetadata;
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...props}/>);
const state = wrapper.state();
expect(state.hasImage).toBe(false);
@@ -41,7 +44,7 @@ describe('AttachmentImage', () => {
});
test('it updates image when imageUrl prop changes', () => {
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...baseProps}/>);
wrapper.setProps({
imageUrl: 'https://someothersite.com/picture.png',
@@ -58,7 +61,7 @@ describe('AttachmentImage', () => {
});
test('it does not update image when an unrelated prop changes', () => {
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
const wrapper = shallow<AttachmentImage, Props, State>(<AttachmentImage {...baseProps}/>);
wrapper.setProps({
theme: {...Preferences.THEMES.default},

View File

@@ -2,7 +2,6 @@
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
import ProgressiveImage from '@components/progressive_image';
@@ -11,20 +10,37 @@ import {generateId} from '@utils/file';
import {isGifTooLarge, openGalleryAtIndex, calculateDimensions} from '@utils/images';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {Theme} from '@mm-redux/types/preferences';
import {PostImage} from '@mm-redux/types/posts';
import {FileInfo} from '@mm-redux/types/files';
const VIEWPORT_IMAGE_OFFSET = 100;
const VIEWPORT_IMAGE_CONTAINER_OFFSET = 10;
export default class AttachmentImage extends PureComponent {
static propTypes = {
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
imageMetadata: PropTypes.object,
imageUrl: PropTypes.string,
postId: PropTypes.string,
theme: PropTypes.object.isRequired,
};
export type Props = {
deviceHeight: number;
deviceWidth: number;
imageMetadata?: PostImage;
imageUrl?: string;
postId?: string;
theme: Theme;
}
constructor(props) {
export type State = {
hasImage: boolean;
imageUri: string | null;
originalHeight?: number;
originalWidth?: number;
height?: number;
width?: number;
}
export default class AttachmentImage extends PureComponent<Props, State> {
private fileId: string;
private mounted = false;
private maxImageWidth = 0;
constructor(props: Props) {
super(props);
this.fileId = generateId();
@@ -48,7 +64,7 @@ export default class AttachmentImage extends PureComponent {
}
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps: Props) {
if (this.props.imageUrl && (prevProps.imageUrl !== this.props.imageUrl)) {
this.setImageUrl(this.props.imageUrl);
}
@@ -62,6 +78,16 @@ export default class AttachmentImage extends PureComponent {
originalWidth,
} = this.state;
if (!imageUrl) {
return {
id: this.fileId,
post_id: postId,
uri,
width: originalWidth,
height: originalHeight,
} as FileInfo;
}
let filename = imageUrl.substring(imageUrl.lastIndexOf('/') + 1, imageUrl.indexOf('?') === -1 ? imageUrl.length : imageUrl.indexOf('?'));
const extension = filename.split('.').pop();
@@ -70,7 +96,7 @@ export default class AttachmentImage extends PureComponent {
filename = `${filename}${ext}`;
}
return {
const out = {
id: this.fileId,
name: filename,
extension,
@@ -79,7 +105,8 @@ export default class AttachmentImage extends PureComponent {
uri,
width: originalWidth,
height: originalHeight,
};
} as FileInfo;
return out;
}
handlePreviewImage = () => {
@@ -87,7 +114,7 @@ export default class AttachmentImage extends PureComponent {
openGalleryAtIndex(0, files);
};
setImageDimensions = (imageUri, dimensions, originalWidth, originalHeight) => {
setImageDimensions = (imageUri: string | null, dimensions: {width?: number; height?: number;}, originalWidth: number, originalHeight: number) => {
if (this.mounted) {
this.setState({
...dimensions,
@@ -98,12 +125,12 @@ export default class AttachmentImage extends PureComponent {
}
};
setImageDimensionsFromMeta = (imageUri, imageMetadata) => {
setImageDimensionsFromMeta = (imageUri: string | null, imageMetadata: PostImage) => {
const dimensions = calculateDimensions(imageMetadata.height, imageMetadata.width, this.maxImageWidth);
this.setImageDimensions(imageUri, dimensions, imageMetadata.width, imageMetadata.height);
};
setImageUrl = (imageURL) => {
setImageUrl = (imageURL: string) => {
const {imageMetadata} = this.props;
if (imageMetadata) {
@@ -158,7 +185,7 @@ export default class AttachmentImage extends PureComponent {
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
marginTop: 5,

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {StyleSheet, View} from 'react-native';
import PropTypes from 'prop-types';
import Markdown from '@components/markdown';
export default class AttachmentPreText extends PureComponent {
static propTypes = {
baseTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
blockStyles: PropTypes.object.isRequired,
metadata: PropTypes.object,
onPermalinkPress: PropTypes.func,
textStyles: PropTypes.object.isRequired,
value: PropTypes.string,
};
render() {
const {
baseTextStyle,
blockStyles,
metadata,
onPermalinkPress,
value,
textStyles,
} = this.props;
if (!value) {
return null;
}
return (
<View style={style.container}>
<Markdown
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
imagesMetadata={metadata?.images}
value={value}
onPermalinkPress={onPermalinkPress}
/>
</View>
);
}
}
const style = StyleSheet.create({
container: {
marginTop: 5,
},
});

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native';
import Markdown from '@components/markdown';
import {PostMetadata} from '@mm-redux/types/posts';
type Props = {
baseTextStyle: StyleProp<TextStyle>;
blockStyles?: StyleProp<ViewStyle>[];
metadata?: PostMetadata;
onPermalinkPress?: () => void;
textStyles?: StyleProp<TextStyle>[];
value?: string;
}
export default function AttachmentPreText(props: Props) {
const {
baseTextStyle,
blockStyles,
metadata,
onPermalinkPress,
value,
textStyles,
} = props;
if (!value) {
return null;
}
return (
<View style={style.container}>
<Markdown
// TODO: remove any when migrating Markdown to typescript
baseTextStyle={baseTextStyle as any}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
imagesMetadata={metadata?.images}
value={value}
onPermalinkPress={onPermalinkPress}
/>
</View>
);
}
const style = StyleSheet.create({
container: {
marginTop: 5,
},
});

View File

@@ -2,30 +2,42 @@
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {ScrollView, StyleSheet, View} from 'react-native';
import PropTypes from 'prop-types';
import {LayoutChangeEvent, ScrollView, StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native';
import Markdown from '@components/markdown';
import ShowMoreButton from '@components/show_more_button';
import {PostMetadata} from '@mm-redux/types/posts';
import {Theme} from '@mm-redux/types/preferences';
const SHOW_MORE_HEIGHT = 60;
export default class AttachmentText extends PureComponent {
static propTypes = {
baseTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
blockStyles: PropTypes.object.isRequired,
deviceHeight: PropTypes.number.isRequired,
hasThumbnail: PropTypes.bool,
metadata: PropTypes.object,
onPermalinkPress: PropTypes.func,
textStyles: PropTypes.object.isRequired,
theme: PropTypes.object,
value: PropTypes.string,
};
type Props = {
baseTextStyle: StyleProp<TextStyle>,
blockStyles?: StyleProp<ViewStyle>[],
deviceHeight: number,
hasThumbnail?: boolean,
metadata?: PostMetadata,
onPermalinkPress?: () => void,
textStyles?: StyleProp<TextStyle>[],
theme?: Theme,
value?: string,
}
static getDerivedStateFromProps(nextProps, prevState) {
type State = {
collapsed: boolean;
isLongText: boolean;
maxHeight: number;
}
function getMaxHeight(deviceHeight: number) {
return Math.round((deviceHeight * 0.4) + SHOW_MORE_HEIGHT);
}
export default class AttachmentText extends PureComponent<Props, State> {
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
const {deviceHeight} = nextProps;
const maxHeight = Math.round((deviceHeight * 0.4) + SHOW_MORE_HEIGHT);
const maxHeight = getMaxHeight(deviceHeight);
if (maxHeight !== prevState.maxHeight) {
return {
@@ -36,16 +48,18 @@ export default class AttachmentText extends PureComponent {
return null;
}
constructor(props) {
constructor(props: Props) {
super(props);
const maxHeight = getMaxHeight(props.deviceHeight);
this.state = {
collapsed: true,
isLongText: false,
maxHeight,
};
}
handleLayout = (event) => {
handleLayout = (event: LayoutChangeEvent) => {
const {height} = event.nativeEvent.layout;
const {maxHeight} = this.state;
@@ -81,7 +95,7 @@ export default class AttachmentText extends PureComponent {
return (
<View style={hasThumbnail && style.container}>
<ScrollView
style={{maxHeight: (collapsed ? maxHeight : null), overflow: 'hidden'}}
style={{maxHeight: (collapsed ? maxHeight : undefined), overflow: 'hidden'}}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
@@ -91,7 +105,9 @@ export default class AttachmentText extends PureComponent {
removeClippedSubviews={isLongText && collapsed}
>
<Markdown
baseTextStyle={baseTextStyle}
// TODO: remove any when migrating Markdown to typescript
baseTextStyle={baseTextStyle as any}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}

View File

@@ -1,44 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {StyleSheet, View} from 'react-native';
import PropTypes from 'prop-types';
import FastImage from 'react-native-fast-image';
export default class AttachmentThumbnail extends PureComponent {
static propTypes = {
url: PropTypes.string,
};
render() {
const {url: uri} = this.props;
if (!uri) {
return null;
}
return (
<View style={style.container}>
<FastImage
source={{uri}}
resizeMode='contain'
resizeMethod='scale'
style={style.image}
/>
</View>
);
}
}
const style = StyleSheet.create({
container: {
position: 'absolute',
right: 10,
top: 10,
},
image: {
height: 45,
width: 45,
},
});

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import {isValidUrl} from '@utils/url';
type Props = {
url?: string;
}
export default function AttachmentThumbnail(props: Props) {
const {url: uri} = props;
if (!isValidUrl(uri)) {
return null;
}
return (
<View style={style.container}>
<FastImage
source={{uri}}
resizeMode='contain'
style={style.image}
/>
</View>
);
}
const style = StyleSheet.create({
container: {
position: 'absolute',
right: 10,
top: 10,
},
image: {
height: 45,
width: 45,
},
});

View File

@@ -4,19 +4,19 @@
import React, {PureComponent} from 'react';
import {Alert, Text, View} from 'react-native';
import {intlShape} from 'react-intl';
import PropTypes from 'prop-types';
import Markdown from '@components/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {tryOpenURL} from '@utils/url';
export default class AttachmentTitle extends PureComponent {
static propTypes = {
link: PropTypes.string,
theme: PropTypes.object.isRequired,
value: PropTypes.string,
};
import {Theme} from '@mm-redux/types/preferences';
type Props = {
link?: string;
theme: Theme;
value?: string;
}
export default class AttachmentTitle extends PureComponent<Props> {
static contextTypes = {
intl: intlShape.isRequired,
};
@@ -93,7 +93,7 @@ export default class AttachmentTitle extends PureComponent {
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
marginTop: 3,

View File

@@ -1,66 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import MessageAttachment from './message_attachment';
export default class MessageAttachments extends PureComponent {
static propTypes = {
attachments: PropTypes.array.isRequired,
baseTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
blockStyles: PropTypes.object,
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
postId: PropTypes.string.isRequired,
metadata: PropTypes.object,
onHashtagPress: PropTypes.func,
onPermalinkPress: PropTypes.func,
theme: PropTypes.object,
textStyles: PropTypes.object,
};
render() {
const {
attachments,
baseTextStyle,
blockStyles,
deviceHeight,
deviceWidth,
metadata,
onHashtagPress,
onPermalinkPress,
postId,
theme,
textStyles,
} = this.props;
const content = [];
attachments.forEach((attachment, i) => {
content.push(
<MessageAttachment
attachment={attachment}
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
key={'att_' + i}
metadata={metadata}
onHashtagPress={onHashtagPress}
onPermalinkPress={onPermalinkPress}
postId={postId}
theme={theme}
textStyles={textStyles}
/>,
);
});
return (
<View style={{flex: 1, flexDirection: 'column'}}>
{content}
</View>
);
}
}

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
import {MessageAttachment as MessageAttachmentType} from '@mm-redux/types/message_attachments';
import {PostMetadata} from '@mm-redux/types/posts';
import {Theme} from '@mm-redux/types/preferences';
import MessageAttachment from './message_attachment';
type Props = {
attachments: MessageAttachmentType[],
baseTextStyle?: StyleProp<TextStyle>,
blockStyles?: StyleProp<ViewStyle>[],
deviceHeight: number,
deviceWidth: number,
postId: string,
metadata?: PostMetadata,
onPermalinkPress?: () => void,
theme: Theme,
textStyles?: StyleProp<TextStyle>[],
}
export default function MessageAttachments(props: Props) {
const {
attachments,
baseTextStyle,
blockStyles,
deviceHeight,
deviceWidth,
metadata,
onPermalinkPress,
postId,
theme,
textStyles,
} = props;
const content = [] as React.ReactNode[];
attachments.forEach((attachment, i) => {
content.push(
<MessageAttachment
attachment={attachment}
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
key={'att_' + i}
metadata={metadata}
onPermalinkPress={onPermalinkPress}
postId={postId}
theme={theme}
textStyles={textStyles}
/>,
);
});
return (
<View style={{flex: 1, flexDirection: 'column'}}>
{content}
</View>
);
}

View File

@@ -1,147 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
import {getStatusColors} from '@utils/message_attachment_colors';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import AttachmentActions from './attachment_actions';
import AttachmentAuthor from './attachment_author';
import AttachmentFields from './attachment_fields';
import AttachmentImage from './attachment_image';
import AttachmentPreText from './attachment_pretext';
import AttachmentText from './attachment_text';
import AttachmentThumbnail from './attachment_thumbnail';
import AttachmentTitle from './attachment_title';
import AttachmentFooter from './attachment_footer';
export default class MessageAttachment extends PureComponent {
static propTypes = {
attachment: PropTypes.object.isRequired,
baseTextStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number, PropTypes.array]),
blockStyles: PropTypes.object,
deviceHeight: PropTypes.number.isRequired,
deviceWidth: PropTypes.number.isRequired,
metadata: PropTypes.object,
postId: PropTypes.string.isRequired,
onPermalinkPress: PropTypes.func,
theme: PropTypes.object,
textStyles: PropTypes.object,
};
render() {
const {
attachment,
baseTextStyle,
blockStyles,
deviceHeight,
deviceWidth,
metadata,
onPermalinkPress,
postId,
textStyles,
theme,
} = this.props;
const style = getStyleSheet(theme);
const STATUS_COLORS = getStatusColors(theme);
const hasImage = Boolean(metadata?.images?.[attachment.image_url]);
let borderStyle;
if (attachment.color) {
if (attachment.color[0] === '#') {
borderStyle = {borderLeftColor: attachment.color};
} else if (STATUS_COLORS.hasOwnProperty(attachment.color)) {
borderStyle = {borderLeftColor: STATUS_COLORS[attachment.color]};
}
}
return (
<React.Fragment>
<AttachmentPreText
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
metadata={metadata}
onPermalinkPress={onPermalinkPress}
textStyles={textStyles}
value={attachment.pretext}
/>
<View style={[style.container, style.border, borderStyle]}>
<AttachmentAuthor
icon={attachment.author_icon}
link={attachment.author_link}
name={attachment.author_name}
theme={theme}
/>
<AttachmentTitle
link={attachment.title_link}
theme={theme}
value={attachment.title}
/>
<AttachmentThumbnail url={attachment.thumb_url}/>
<AttachmentText
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
deviceHeight={deviceHeight}
hasThumbnail={Boolean(attachment.thumb_url)}
metadata={metadata}
onPermalinkPress={onPermalinkPress}
textStyles={textStyles}
value={attachment.text}
theme={theme}
/>
<AttachmentFields
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
fields={attachment.fields}
metadata={metadata}
onPermalinkPress={onPermalinkPress}
textStyles={textStyles}
theme={theme}
/>
<AttachmentFooter
icon={attachment.footer_icon}
text={attachment.footer}
theme={theme}
/>
<AttachmentActions
actions={attachment.actions}
postId={postId}
/>
{hasImage &&
<AttachmentImage
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
imageUrl={attachment.image_url}
imageMetadata={metadata?.images?.[attachment.image_url]}
postId={postId}
theme={theme}
/>
}
</View>
</React.Fragment>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.15),
borderRightColor: changeOpacity(theme.centerChannelColor, 0.15),
borderTopColor: changeOpacity(theme.centerChannelColor, 0.15),
borderBottomWidth: 1,
borderRightWidth: 1,
borderTopWidth: 1,
marginTop: 5,
padding: 12,
},
border: {
borderLeftColor: changeOpacity(theme.linkColor, 0.6),
borderLeftWidth: 3,
},
};
});

View File

@@ -0,0 +1,147 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
import {getStatusColors} from '@utils/message_attachment_colors';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {MessageAttachment as MessageAttachmentType} from '@mm-redux/types/message_attachments';
import {PostMetadata} from '@mm-redux/types/posts';
import {Theme} from '@mm-redux/types/preferences';
import AttachmentActions from './attachment_actions';
import AttachmentAuthor from './attachment_author';
import AttachmentFields from './attachment_fields';
import AttachmentImage from './attachment_image';
import AttachmentPreText from './attachment_pretext';
import AttachmentText from './attachment_text';
import AttachmentThumbnail from './attachment_thumbnail';
import AttachmentTitle from './attachment_title';
import AttachmentFooter from './attachment_footer';
type Props = {
attachment: MessageAttachmentType,
baseTextStyle?: StyleProp<TextStyle>,
blockStyles?: StyleProp<ViewStyle>[],
deviceHeight: number,
deviceWidth: number,
postId: string,
metadata?: PostMetadata,
onPermalinkPress?: () => void,
theme: Theme,
textStyles?: StyleProp<TextStyle>[],
}
export default function MessageAttachment(props: Props) {
const {
attachment,
baseTextStyle,
blockStyles,
deviceHeight,
deviceWidth,
metadata,
onPermalinkPress,
postId,
textStyles,
theme,
} = props;
const style = getStyleSheet(theme);
const STATUS_COLORS = getStatusColors(theme);
const hasImage = Boolean(metadata?.images?.[attachment.image_url]);
let borderStyle;
if (attachment.color) {
if (attachment.color[0] === '#') {
borderStyle = {borderLeftColor: attachment.color};
} else if (STATUS_COLORS.hasOwnProperty(attachment.color)) {
borderStyle = {borderLeftColor: STATUS_COLORS[attachment.color]};
}
}
return (
<React.Fragment>
<AttachmentPreText
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
metadata={metadata}
onPermalinkPress={onPermalinkPress}
textStyles={textStyles}
value={attachment.pretext}
/>
<View style={[style.container, style.border, borderStyle]}>
<AttachmentAuthor
icon={attachment.author_icon}
link={attachment.author_link}
name={attachment.author_name}
theme={theme}
/>
<AttachmentTitle
link={attachment.title_link}
theme={theme}
value={attachment.title}
/>
<AttachmentThumbnail url={attachment.thumb_url}/>
<AttachmentText
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
deviceHeight={deviceHeight}
hasThumbnail={Boolean(attachment.thumb_url)}
metadata={metadata}
onPermalinkPress={onPermalinkPress}
textStyles={textStyles}
value={attachment.text}
theme={theme}
/>
<AttachmentFields
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
fields={attachment.fields}
metadata={metadata}
onPermalinkPress={onPermalinkPress}
textStyles={textStyles}
theme={theme}
/>
<AttachmentFooter
icon={attachment.footer_icon}
text={attachment.footer}
theme={theme}
/>
<AttachmentActions
actions={attachment.actions}
postId={postId}
/>
{hasImage &&
<AttachmentImage
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
imageUrl={attachment.image_url}
imageMetadata={metadata?.images?.[attachment.image_url]}
postId={postId}
theme={theme}
/>
}
</View>
</React.Fragment>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme:Theme) => {
return {
container: {
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.15),
borderRightColor: changeOpacity(theme.centerChannelColor, 0.15),
borderTopColor: changeOpacity(theme.centerChannelColor, 0.15),
borderBottomWidth: 1,
borderRightWidth: 1,
borderTopWidth: 1,
marginTop: 5,
padding: 12,
},
border: {
borderLeftColor: changeOpacity(theme.linkColor, 0.6),
borderLeftWidth: 3,
},
};
});

View File

@@ -5,9 +5,9 @@ import PropTypes from 'prop-types';
import React from 'react';
import {StyleSheet, View} from 'react-native';
import ProgressiveImage from 'app/components/progressive_image';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {isGifTooLarge} from 'app/utils/images';
import ProgressiveImage from '@components/progressive_image';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {isGifTooLarge} from '@utils/images';
export default class PostAttachmentImage extends React.PureComponent {
static propTypes = {

View File

@@ -355,7 +355,7 @@ export default class PostBodyAdditionalContent extends ImageViewPort {
if (attachments && attachments.length) {
if (!MessageAttachments) {
MessageAttachments = require('app/components/message_attachments').default;
MessageAttachments = require('@components/message_attachments').default;
}
return (

View File

@@ -209,7 +209,9 @@ exports[`PostDraft Should render the Archived for deactivatedChannel 1`] = `
`;
exports[`PostDraft Should render the DraftInput 1`] = `
<KeyboardTrackingView>
<KeyboardTrackingView
inverted={false}
>
<View
style={
Object {

View File

@@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
import {Platform} from 'react-native';
import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view';
import {DeviceTypes} from '@constants';
import {UPDATE_NATIVE_SCROLLVIEW} from '@constants/post_draft';
import EventEmitter from '@mm-redux/utils/event_emitter';
@@ -121,6 +122,7 @@ export default class PostDraft extends PureComponent {
accessoriesContainerID={accessoriesContainerID}
ref={this.keyboardTracker}
scrollViewNativeID={scrollViewNativeID}
inverted={DeviceTypes.IS_TABLET}
>
{draftInput}
</KeyboardTrackingView>

View File

@@ -10,6 +10,7 @@ import PasteableTextInput from '@components/pasteable_text_input';
import {NavigationTypes} from '@constants';
import DEVICE from '@constants/device';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from '@constants/post_draft';
import {debounce} from '@mm-redux/actions/helpers';
import EventEmitter from '@mm-redux/utils/event_emitter';
import {t} from '@utils/i18n';
import {switchKeyboardForCodeBlocks} from '@utils/markdown';
@@ -86,7 +87,7 @@ export default class PostInput extends PureComponent {
}
};
changeDraft = (text) => {
changeDraft = debounce((text) => {
const {
channelId,
handleCommentDraftChanged,
@@ -99,7 +100,7 @@ export default class PostInput extends PureComponent {
} else {
handlePostDraftChanged(channelId, text);
}
};
}, 200);
checkMessageLength = (value) => {
const {intl} = this.context;
@@ -163,7 +164,7 @@ export default class PostInput extends PureComponent {
};
handleEndEditing = (e) => {
if (e && e.nativeEvent) {
if (e && e.nativeEvent && !DEVICE.IS_TABLET) {
this.changeDraft(e.nativeEvent.text || '');
}
};
@@ -196,6 +197,9 @@ export default class PostInput extends PureComponent {
} = this.props;
this.value = value;
updateInitialValue(value);
if (DEVICE.IS_TABLET) {
this.changeDraft(value);
}
if (inputEventType) {
EventEmitter.emit(inputEventType, value);

View File

@@ -4,7 +4,9 @@
import React from 'react';
import {Alert} from 'react-native';
import {shallowWithIntl} from 'test/intl-test-helper';
import TestHelper from 'test/test_helper';
import Device from '@constants/device';
import Preferences from '@mm-redux/constants/preferences';
import PostInput from './post_input';
@@ -48,7 +50,7 @@ describe('PostInput', () => {
expect(instance.changeDraft).not.toBeCalled();
});
test('should emit the event and text is save to draft', () => {
test('should emit the event and text is save to draft', async () => {
const wrapper = shallowWithIntl(
<PostInput {...baseProps}/>,
);
@@ -58,6 +60,7 @@ describe('PostInput', () => {
instance.setValue(value);
instance.handleAppStateChange('background');
await TestHelper.wait(200);
expect(baseProps.handlePostDraftChanged).toHaveBeenCalledWith(baseProps.channelId, value);
expect(baseProps.handlePostDraftChanged).toHaveBeenCalledTimes(1);
});
@@ -76,5 +79,36 @@ describe('PostInput', () => {
expect(Alert.alert).toBeCalled();
expect(Alert.alert).toHaveBeenCalledTimes(1);
});
test('should save the draft onChangeText for tablets', async () => {
Device.IS_TABLET = true;
const wrapper = shallowWithIntl(
<PostInput {...baseProps}/>,
);
const instance = wrapper.instance();
const value = 'some text';
instance.handleTextChange(value);
await TestHelper.wait(200);
expect(baseProps.handlePostDraftChanged).toBeCalled();
expect(baseProps.handlePostDraftChanged).toHaveBeenCalledTimes(1);
});
test('should not save the draft onEndEditing for tablets', async () => {
Device.IS_TABLET = true;
const wrapper = shallowWithIntl(
<PostInput {...baseProps}/>,
);
const instance = wrapper.instance();
const value = 'some text';
instance.handleEndEditing(value);
await TestHelper.wait(200);
expect(baseProps.handlePostDraftChanged).not.toBeCalled();
});
});

View File

@@ -1,221 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Platform, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import {Client4} from '@client/rest';
import CompassIcon from '@components/compass_icon';
import UserStatus from '@components/user_status';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const STATUS_BUFFER = Platform.select({
ios: 3,
android: 2,
});
export default class ProfilePicture extends PureComponent {
static propTypes = {
isCurrentUser: PropTypes.bool,
size: PropTypes.number,
statusSize: PropTypes.number,
iconSize: PropTypes.number,
user: PropTypes.object,
userId: PropTypes.string,
showStatus: PropTypes.bool,
status: PropTypes.string,
edit: PropTypes.bool,
imageUri: PropTypes.string,
profileImageUri: PropTypes.string,
profileImageRemove: PropTypes.bool,
theme: PropTypes.object,
testID: PropTypes.string,
actions: PropTypes.shape({
getStatusForId: PropTypes.func.isRequired,
setProfileImageUri: PropTypes.func.isRequired,
}),
};
static defaultProps = {
showStatus: true,
size: 128,
statusSize: 14,
edit: false,
};
state = {
pictureUrl: null,
};
componentDidMount() {
const {actions, edit, imageUri, profileImageUri, user, status} = this.props;
this.mounted = true;
if (!status && user) {
actions.getStatusForId(user.id);
}
if (profileImageUri) {
this.setImageURL(profileImageUri);
} else if (edit && imageUri) {
this.setImageURL(imageUri);
} else if (user) {
const uri = Client4.getProfilePictureUrl(user.id, user.last_picture_update);
this.setImageURL(uri);
this.clearProfileImageUri();
}
}
componentWillUnmount() {
this.mounted = false;
}
setImageURL = (pictureUrl) => {
if (this.mounted) {
this.setState({pictureUrl});
}
};
clearProfileImageUri = () => {
if (this.props.isCurrentUser && this.props.profileImageUri !== '') {
this.props.actions.setProfileImageUri('');
}
}
componentDidUpdate(prevProps) {
if (this.props.profileImageRemove !== prevProps.profileImageRemove) {
this.setImageURL(null);
} else if (this.mounted) {
if (this.props.edit && this.props.imageUri && this.props.imageUri !== prevProps.imageUri) {
this.setImageURL(this.props.imageUri);
return;
}
if (this.props.profileImageUri !== '' && this.props.profileImageUri !== prevProps.profileImageUri) {
this.setImageURL(this.props.profileImageUri);
}
const url = prevProps.user ? Client4.getProfilePictureUrl(prevProps.user.id, prevProps.user.last_picture_update) : null;
const nextUrl = this.props.user ? Client4.getProfilePictureUrl(this.props.user.id, this.props.user.last_picture_update) : null;
if (nextUrl && url !== nextUrl) {
// empty function is so that promise unhandled is not triggered in dev mode
this.setImageURL(nextUrl);
this.clearProfileImageUri();
}
}
}
render() {
const {edit, showStatus, theme, user, size, testID} = this.props;
const {pictureUrl} = this.state;
const style = getStyleSheet(theme);
let statusIcon;
let statusStyle;
let containerStyle = {
width: size + STATUS_BUFFER,
height: size + STATUS_BUFFER,
};
if (edit) {
const iconColor = changeOpacity(theme.centerChannelColor, 0.6);
statusStyle = {
width: this.props.statusSize,
height: this.props.statusSize,
backgroundColor: theme.centerChannelBg,
};
statusIcon = (
<CompassIcon
name='camera-outline'
size={this.props.statusSize / 1.7}
color={iconColor}
/>
);
} else if (this.props.status && !edit) {
statusIcon = (
<UserStatus
size={this.props.statusSize}
status={this.props.status}
/>
);
}
let source = null;
let image;
if (pictureUrl) {
let prefix = '';
if (Platform.OS === 'android' && !pictureUrl.startsWith('content://') &&
!pictureUrl.startsWith('http://') && !pictureUrl.startsWith('https://')) {
prefix = 'file://';
}
source = {
uri: `${prefix}${pictureUrl}`,
};
image = (
<FastImage
key={pictureUrl}
style={{width: this.props.size, height: this.props.size, borderRadius: this.props.size / 2}}
source={source}
/>
);
} else {
containerStyle = {
width: size + (STATUS_BUFFER - 1),
height: size + (STATUS_BUFFER - 1),
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
};
image = (
<CompassIcon
name='account-outline'
size={this.props.iconSize || this.props.size}
style={style.icon}
/>
);
}
return (
<View
style={[style.container, containerStyle]}
testID={`${testID}.${user?.id}`}
>
{image}
{(showStatus || edit) && (user && !user.is_bot) &&
<View style={[style.statusWrapper, statusStyle, {borderRadius: this.props.statusSize / 2}]}>
{statusIcon}
</View>
}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
justifyContent: 'center',
alignItems: 'center',
borderRadius: 80,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.48),
},
statusWrapper: {
position: 'absolute',
bottom: 0,
right: 0,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.centerChannelBg,
},
status: {
color: theme.centerChannelBg,
},
};
});

View File

@@ -0,0 +1,216 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {Platform, StyleProp, View, ViewProps, ViewStyle} from 'react-native';
import FastImage from 'react-native-fast-image';
import {Client4} from '@client/rest';
import CompassIcon from '@components/compass_icon';
import UserStatus from '@components/user_status';
import {useDidUpdate} from '@hooks';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import type {Theme} from '@mm-redux/types/preferences';
import type {UserProfile} from '@mm-redux/types/users';
const STATUS_BUFFER = Platform.select({
ios: 3,
android: 2,
});
type ProfilePictureProps = {
actions: {
setProfileImageUri: (imageUri?: string) => void;
getStatusForId: (id: string) => void;
};
edit: boolean;
iconSize?: number;
imageUri?: string;
isCurrentUser: boolean;
profileImageRemove?: boolean;
profileImageUri?: string;
showStatus: boolean;
size: number;
status?: string;
statusSize: number;
statusStyle?: StyleProp<ViewProps> | any;
user?: UserProfile;
userId: string;
theme: Theme;
testID?: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
justifyContent: 'center',
alignItems: 'center',
borderRadius: 80,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.48),
},
statusWrapper: {
position: 'absolute',
bottom: 0,
right: 0,
overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.centerChannelBg,
borderWidth: 1,
borderColor: theme.centerChannelBg,
},
status: {
color: theme.centerChannelBg,
},
};
});
const ProfilePicture = (props: ProfilePictureProps) => {
const [pictureUrl, setPictureUrl] = useState<string|undefined>();
const style = getStyleSheet(props.theme);
const buffer = STATUS_BUFFER || 0;
const clearProfileImageUri = () => {
if (props.isCurrentUser && props.profileImageUri !== '') {
props.actions.setProfileImageUri('');
}
};
useEffect(() => {
const {edit, imageUri, profileImageUri, user, status} = props;
const {getStatusForId} = props.actions;
if (!status && user) {
getStatusForId(user.id);
}
if (profileImageUri) {
setPictureUrl(profileImageUri);
} else if (edit && imageUri) {
setPictureUrl(imageUri);
} else if (user) {
const uri = Client4.getProfilePictureUrl(user.id, user.last_picture_update);
setPictureUrl(uri);
clearProfileImageUri();
}
}, []);
useDidUpdate(() => {
setPictureUrl(undefined);
}, [props.profileImageRemove]);
useDidUpdate(() => {
const {edit, imageUri} = props;
if (edit && imageUri) {
setPictureUrl(imageUri);
}
}, [props.edit, props.imageUri]);
useDidUpdate(() => {
const {profileImageUri} = props;
if (profileImageUri) {
setPictureUrl(profileImageUri);
}
}, [props.profileImageUri]);
useDidUpdate(() => {
const {user} = props;
const url = user ? Client4.getProfilePictureUrl(user.id, user.last_picture_update) : undefined;
if (url !== pictureUrl) {
setPictureUrl(url);
}
}, [props.user]);
let statusIcon;
let statusStyle = props.statusStyle;
let containerStyle: StyleProp<ViewStyle> = {
width: props.size + buffer,
height: props.size + buffer,
};
if (props.edit) {
const iconColor = changeOpacity(props.theme.centerChannelColor, 0.6);
statusStyle = {
width: props.statusSize,
height: props.statusSize,
backgroundColor: props.theme.centerChannelBg,
};
statusIcon = (
<CompassIcon
name='camera-outline'
size={props.statusSize / 1.7}
color={iconColor}
/>
);
} else if (props.status && !props.edit) {
statusIcon = (
<UserStatus
size={props.statusSize}
status={props.status}
/>
);
}
let source = null;
let image;
if (pictureUrl) {
let prefix = '';
if (Platform.OS === 'android' && !pictureUrl.startsWith('content://') &&
!pictureUrl.startsWith('http://') && !pictureUrl.startsWith('https://')) {
prefix = 'file://';
}
source = {
uri: `${prefix}${pictureUrl}`,
};
image = (
<FastImage
key={pictureUrl}
style={{width: props.size, height: props.size, borderRadius: props.size / 2}}
source={source}
/>
);
} else {
containerStyle = {
width: props.size + (buffer - 1),
height: props.size + (buffer - 1),
backgroundColor: changeOpacity(props.theme.centerChannelColor, 0.08),
};
image = (
<CompassIcon
name='account-outline'
size={props.iconSize || props.size}
style={style.icon}
/>
);
}
return (
<View
style={[style.container, containerStyle]}
testID={`${props.testID}.${props.user?.id}`}
>
{image}
{(props.showStatus || props.edit) && (!props.user?.is_bot) &&
<View style={[style.statusWrapper, statusStyle, {borderRadius: props.statusSize / 2}]}>
{statusIcon}
</View>
}
</View>
);
};
ProfilePicture.defaultProps = {
showStatus: true,
size: 128,
statusSize: 14,
edit: false,
};
export default ProfilePicture;

View File

@@ -8,13 +8,13 @@ import {
TouchableWithoutFeedback,
} from 'react-native';
import Emoji from 'app/components/emoji';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {hapticFeedback} from 'app/utils/general';
import Emoji from '@components/emoji';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {hapticFeedback} from '@utils/general';
import {
LARGE_CONTAINER_SIZE,
LARGE_ICON_SIZE,
} from 'app/constants/reaction_picker';
} from '@constants/reaction_picker';
export default class ReactionButton extends PureComponent {
static propTypes = {

View File

@@ -8,9 +8,9 @@ import {
Text,
} from 'react-native';
import Emoji from 'app/components/emoji';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import Emoji from '@components/emoji';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
export default class Reaction extends PureComponent {
static propTypes = {

View File

@@ -37,12 +37,16 @@ exports[`ChannelItem should match snapshot 1`] = `
hasDraft={false}
isActive={false}
isArchived={false}
isBot={false}
isInfo={false}
isUnread={true}
membersCount={1}
size={16}
status="online"
size={24}
statusStyle={
Object {
"backgroundColor": "#145dbf",
"borderColor": "transparent",
}
}
testID="main.sidebar.channels_list.list.channel_item.channel_icon"
theme={
Object {
@@ -87,6 +91,7 @@ exports[`ChannelItem should match snapshot 1`] = `
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
"marginLeft": 13,
"maxWidth": "80%",
"paddingRight": 10,
},
@@ -153,12 +158,16 @@ exports[`ChannelItem should match snapshot for current user i.e currentUser (you
hasDraft={false}
isActive={true}
isArchived={false}
isBot={false}
isInfo={false}
isUnread={true}
membersCount={1}
size={16}
status="online"
size={24}
statusStyle={
Object {
"backgroundColor": "#145dbf",
"borderColor": "transparent",
}
}
testID="main.sidebar.channels_list.list.channel_item.channel_icon"
theme={
Object {
@@ -203,6 +212,7 @@ exports[`ChannelItem should match snapshot for current user i.e currentUser (you
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
"marginLeft": 13,
"maxWidth": "80%",
"paddingRight": 10,
},
@@ -213,7 +223,7 @@ exports[`ChannelItem should match snapshot for current user i.e currentUser (you
}
testID="main.sidebar.channels_list.list.channel_item.display_name"
>
{displayname} (you)
display_name
</Text>
</View>
</View>
@@ -268,12 +278,16 @@ exports[`ChannelItem should match snapshot for current user i.e currentUser (you
hasDraft={false}
isActive={true}
isArchived={false}
isBot={false}
isInfo={false}
isUnread={true}
membersCount={1}
size={16}
status="online"
size={24}
statusStyle={
Object {
"backgroundColor": "#145dbf",
"borderColor": "transparent",
}
}
testID="main.sidebar.channels_list.list.channel_item.channel_icon"
theme={
Object {
@@ -318,6 +332,7 @@ exports[`ChannelItem should match snapshot for current user i.e currentUser (you
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
"marginLeft": 13,
"maxWidth": "80%",
"paddingRight": 10,
},
@@ -383,12 +398,16 @@ exports[`ChannelItem should match snapshot for deactivated user and is currentCh
hasDraft={false}
isActive={true}
isArchived={true}
isBot={false}
isInfo={false}
isUnread={true}
membersCount={1}
size={16}
status="online"
size={24}
statusStyle={
Object {
"backgroundColor": "#145dbf",
"borderColor": "transparent",
}
}
testID="main.sidebar.channels_list.list.channel_item.channel_icon"
theme={
Object {
@@ -433,6 +452,7 @@ exports[`ChannelItem should match snapshot for deactivated user and is currentCh
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
"marginLeft": 13,
"maxWidth": "80%",
"paddingRight": 10,
},
@@ -487,12 +507,16 @@ exports[`ChannelItem should match snapshot for deactivated user and is searchRes
hasDraft={false}
isActive={false}
isArchived={false}
isBot={false}
isInfo={false}
isUnread={true}
membersCount={1}
size={16}
status="online"
size={24}
statusStyle={
Object {
"backgroundColor": "#145dbf",
"borderColor": "transparent",
}
}
testID="main.sidebar.channels_list.list.channel_item.channel_icon"
theme={
Object {
@@ -537,6 +561,7 @@ exports[`ChannelItem should match snapshot for deactivated user and is searchRes
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
"marginLeft": 13,
"maxWidth": "80%",
"paddingRight": 10,
},
@@ -592,12 +617,16 @@ exports[`ChannelItem should match snapshot for deactivated user and not searchRe
hasDraft={false}
isActive={false}
isArchived={false}
isBot={false}
isInfo={false}
isUnread={true}
membersCount={1}
size={16}
status="online"
size={24}
statusStyle={
Object {
"backgroundColor": "#145dbf",
"borderColor": "transparent",
}
}
testID="main.sidebar.channels_list.list.channel_item.channel_icon"
theme={
Object {
@@ -642,6 +671,7 @@ exports[`ChannelItem should match snapshot for deactivated user and not searchRe
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
"marginLeft": 13,
"maxWidth": "80%",
"paddingRight": 10,
},
@@ -697,12 +727,16 @@ exports[`ChannelItem should match snapshot for isManualUnread 1`] = `
hasDraft={false}
isActive={false}
isArchived={false}
isBot={false}
isInfo={false}
isUnread={true}
membersCount={1}
size={16}
status="online"
size={24}
statusStyle={
Object {
"backgroundColor": "#145dbf",
"borderColor": "transparent",
}
}
testID="main.sidebar.channels_list.list.channel_item.channel_icon"
theme={
Object {
@@ -747,6 +781,7 @@ exports[`ChannelItem should match snapshot for isManualUnread 1`] = `
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
"marginLeft": 13,
"maxWidth": "80%",
"paddingRight": 10,
},
@@ -806,12 +841,16 @@ exports[`ChannelItem should match snapshot with draft 1`] = `
hasDraft={true}
isActive={false}
isArchived={false}
isBot={false}
isInfo={false}
isUnread={true}
membersCount={1}
size={16}
status="online"
size={24}
statusStyle={
Object {
"backgroundColor": "#145dbf",
"borderColor": "transparent",
}
}
testID="main.sidebar.channels_list.list.channel_item.channel_icon"
theme={
Object {
@@ -856,6 +895,7 @@ exports[`ChannelItem should match snapshot with draft 1`] = `
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
"marginLeft": 13,
"maxWidth": "80%",
"paddingRight": 10,
},
@@ -913,12 +953,16 @@ exports[`ChannelItem should match snapshot with mentions and muted 1`] = `
hasDraft={false}
isActive={false}
isArchived={false}
isBot={false}
isInfo={false}
isUnread={true}
membersCount={1}
size={16}
status="online"
size={24}
statusStyle={
Object {
"backgroundColor": "#145dbf",
"borderColor": "transparent",
}
}
testID="main.sidebar.channels_list.list.channel_item.channel_icon"
theme={
Object {
@@ -963,6 +1007,7 @@ exports[`ChannelItem should match snapshot with mentions and muted 1`] = `
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
"marginLeft": 13,
"maxWidth": "80%",
"paddingRight": 10,
},

View File

@@ -10,11 +10,11 @@ import {
} from 'react-native';
import {intlShape} from 'react-intl';
import Badge from '@components/badge';
import ChannelIcon from '@components/channel_icon';
import {General} from '@mm-redux/constants';
import Badge from 'app/components/badge';
import ChannelIcon from 'app/components/channel_icon';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
export default class ChannelItem extends PureComponent {
static propTypes = {
@@ -33,10 +33,10 @@ export default class ChannelItem extends PureComponent {
onSelectChannel: PropTypes.func.isRequired,
shouldHideChannel: PropTypes.bool,
showUnreadForMsgs: PropTypes.bool.isRequired,
teammateId: PropTypes.string,
theme: PropTypes.object.isRequired,
unreadMsgs: PropTypes.number.isRequired,
isSearchResult: PropTypes.bool,
isBot: PropTypes.bool.isRequired,
};
static defaultProps = {
@@ -77,7 +77,7 @@ export default class ChannelItem extends PureComponent {
theme,
isSearchResult,
channel,
isBot,
teammateId,
} = this.props;
// Only ever show an archived channel if it's the currently viewed channel.
@@ -103,7 +103,7 @@ export default class ChannelItem extends PureComponent {
if (isSearchResult) {
isCurrenUser = channel.id === currentUserId;
} else {
isCurrenUser = channel.teammate_id === currentUserId;
isCurrenUser = teammateId === currentUserId;
}
}
if (isCurrenUser) {
@@ -161,13 +161,13 @@ export default class ChannelItem extends PureComponent {
isUnread={isUnread}
hasDraft={hasDraft && channelId !== currentChannelId}
membersCount={displayName.split(',').length}
size={16}
status={channel.status}
statusStyle={{backgroundColor: theme.sidebarBg, borderColor: 'transparent'}}
size={24}
theme={theme}
type={channel.type}
isArchived={isArchived}
isBot={isBot}
testID={`${testID}.channel_icon`}
userId={teammateId}
/>
);
@@ -231,6 +231,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
fontSize: 16,
lineHeight: 24,
paddingRight: 10,
marginLeft: 13,
maxWidth: '80%',
flex: 1,
alignSelf: 'center',

View File

@@ -30,16 +30,14 @@ function makeMapStateToProps() {
const channelDraft = getDraftForChannel(state, channel.id);
let displayName = channel.display_name;
let isBot = false;
let isGuest = false;
let isArchived = channel.delete_at > 0;
let teammateId;
if (channel.type === General.DM_CHANNEL) {
const teammateId = getUserIdFromChannelName(currentUserId, channel.name);
teammateId = getUserIdFromChannelName(currentUserId, channel.name);
const teammate = getUser(state, teammateId);
isBot = Boolean(ownProps.isSearchResult ? channel.isBot : teammate?.is_bot); //eslint-disable-line camelcase
if (teammate) {
const teammateNameDisplay = getTeammateNameDisplaySetting(state);
displayName = displayUsername(teammate, teammateNameDisplay, false);
@@ -78,13 +76,13 @@ function makeMapStateToProps() {
displayName,
hasDraft: Boolean(channelDraft.draft.trim() || channelDraft?.files?.length),
isArchived,
isBot,
isChannelMuted: isChannelMuted(member),
isGuest,
isManualUnread: isManuallyUnread(state, ownProps.channelId),
mentions: member ? member.mention_count : 0,
shouldHideChannel,
showUnreadForMsgs,
teammateId,
theme: getTheme(state),
unreadMsgs,
};

View File

@@ -179,7 +179,7 @@ class FilteredList extends Component {
};
buildCurrentDMSForSearch = (props, term) => {
const {channels, teammateNameDisplay, profiles, statuses, pastDirectMessages, groupChannelMemberDetails} = props;
const {channels, currentUserId, teammateNameDisplay, profiles, pastDirectMessages, groupChannelMemberDetails} = props;
const {favoriteChannels} = channels;
const favoriteDms = favoriteChannels.filter((c) => {
@@ -206,7 +206,6 @@ class FilteredList extends Component {
return {
id: u.id,
status: statuses[u.id],
display_name: displayName,
username: u.username,
email: u.email,
@@ -215,10 +214,7 @@ class FilteredList extends Component {
nickname: u.nickname,
fullname: `${u.first_name} ${u.last_name}`,
delete_at: u.delete_at,
isBot: u.is_bot,
// need name key for DM's as we use it for sortChannelsByDisplayName with same display_name
name: displayName,
name: `${currentUserId}__${u.id}`,
};
});
@@ -233,7 +229,7 @@ class FilteredList extends Component {
}
buildMembersForSearch = (props, term) => {
const {channels, currentUserId, teammateNameDisplay, profiles, teamProfiles, statuses, pastDirectMessages, restrictDms} = props;
const {channels, currentUserId, teammateNameDisplay, profiles, teamProfiles, pastDirectMessages, restrictDms} = props;
const {favoriteChannels, unreadChannels} = channels;
const favoriteAndUnreadDms = [...favoriteChannels, ...unreadChannels].filter((c) => {
@@ -251,17 +247,15 @@ class FilteredList extends Component {
return {
id: u.id,
status: statuses[u.id],
display_name: displayName,
username: u.username,
email: u.email,
name: displayName,
name: `${currentUserId}__${u.id}`,
type: General.DM_CHANNEL,
fake: true,
nickname: u.nickname,
fullname: `${u.first_name} ${u.last_name}`,
delete_at: u.delete_at,
isBot: u.is_bot,
};
});

View File

@@ -21,7 +21,7 @@ exports[`ChannelsList List should match snapshot 1`] = `
onEndReachedThreshold={2}
onScrollBeginDrag={[Function]}
onViewableItemsChanged={[Function]}
removeClippedSubviews={true}
removeClippedSubviews={false}
renderItem={[Function]}
renderSectionHeader={[Function]}
scrollEventThrottle={50}

View File

@@ -411,7 +411,7 @@ export default class List extends PureComponent {
ref={this.setListRef}
sections={sections}
contentContainerStyle={{paddingBottom}}
removeClippedSubviews={true}
removeClippedSubviews={Platform.OS === 'android'}
renderItem={this.renderItem}
renderSectionHeader={this.renderSectionHeader}
keyboardShouldPersistTaps={'always'}

View File

@@ -44,7 +44,7 @@ exports[`UserStatus should match snapshot, should default to offline status 1`]
name="circle-outline"
style={
Object {
"color": "rgba(61,60,64,0.3)",
"color": "rgba(184,184,184,0.64)",
"fontSize": 32,
}
}

View File

@@ -1,57 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {General} from '@mm-redux/constants';
import CompassIcon from '@components/compass_icon';
import {changeOpacity} from '@utils/theme';
export default class UserStatus extends PureComponent {
static propTypes = {
isAvatar: PropTypes.bool,
size: PropTypes.number,
status: PropTypes.string,
theme: PropTypes.object.isRequired,
};
static defaultProps = {
size: 6,
status: General.OFFLINE,
};
render() {
const {size, status, theme} = this.props;
let iconName;
let iconColor;
switch (status) {
case General.AWAY:
iconName = 'clock';
iconColor = theme.awayIndicator;
break;
case General.DND:
iconName = 'minus-circle';
iconColor = theme.dndIndicator;
break;
case General.ONLINE:
iconName = 'check-circle';
iconColor = theme.onlineIndicator;
break;
default:
iconName = 'circle-outline';
iconColor = changeOpacity(theme.centerChannelColor, 0.3);
break;
}
return (
<CompassIcon
name={iconName}
style={{fontSize: size, color: iconColor}}
testID={`user_status.icon.${status}`}
/>
);
}
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import CompassIcon from '@components/compass_icon';
import {General} from '@mm-redux/constants';
import {changeOpacity} from '@utils/theme';
import type {Theme} from '@mm-redux/types/preferences';
type UserStatusProps = {
size: number;
status: string;
theme: Theme;
};
const UserStatus = ({size, status, theme}: UserStatusProps) => {
let iconName;
let iconColor;
switch (status) {
case General.AWAY:
iconName = 'clock';
iconColor = theme.awayIndicator;
break;
case General.DND:
iconName = 'minus-circle';
iconColor = theme.dndIndicator;
break;
case General.ONLINE:
iconName = 'check-circle';
iconColor = theme.onlineIndicator;
break;
default:
iconName = 'circle-outline';
iconColor = changeOpacity('#B8B8B8', 0.64);
break;
}
return (
<CompassIcon
name={iconName}
style={{fontSize: size, color: iconColor}}
testID={`user_status.icon.${status}`}
/>
);
};
UserStatus.defaultProps = {
size: 6,
status: General.OFFLINE,
};
export default UserStatus;

View File

@@ -2,4 +2,3 @@
// See LICENSE.txt for license information.
export const ATTACHMENT_DOWNLOAD = 'attachment_download';
export const MAX_ATTACHMENT_FOOTER_LENGTH = 300;

View File

@@ -23,7 +23,7 @@ export default {
DOCUMENTS_PATH: `${RNFetchBlobFS.dirs.CacheDir}/Documents`,
IMAGES_PATH: `${RNFetchBlobFS.dirs.CacheDir}/Images`,
IS_IPHONE_WITH_INSETS: isPhoneWithInsets,
IS_TABLET: DeviceInfo.isTablet(),
IS_TABLET: isTablet,
VIDEOS_PATH: `${RNFetchBlobFS.dirs.CacheDir}/Videos`,
PERMANENT_SIDEBAR_SETTINGS: '@PERMANENT_SIDEBAR_SETTINGS',
TABLET_WIDTH: 250,

18
app/hooks/did_update.ts Normal file
View File

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useRef, useEffect, EffectCallback, DependencyList} from 'react';
function useDidUpdate(callback: EffectCallback, deps?: DependencyList) {
const hasMount = useRef(false);
useEffect(() => {
if (hasMount.current) {
callback();
} else {
hasMount.current = true;
}
}, deps);
}
export default useDidUpdate;

4
app/hooks/index.ts Normal file
View File

@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default as useDidUpdate} from './did_update';

View File

@@ -1,15 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppsTypes} from '@mm-redux/action_types';
import {Client4} from '@client/rest';
import {ActionFunc} from '@mm-redux/types/actions';
import {ActionFunc, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions';
import {getChannel} from '@mm-redux/selectors/entities/channels';
import {bindClientFunc} from './helpers';
export function fetchAppBindings(userID: string, channelID: string): ActionFunc {
return bindClientFunc({
clientFunc: () => Client4.getAppsBindings(userID, channelID),
onSuccess: AppsTypes.RECEIVED_APP_BINDINGS,
});
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const channel = getChannel(getState(), channelID);
const teamID = channel?.team_id || '';
return dispatch(bindClientFunc({
clientFunc: () => Client4.getAppsBindings(userID, channelID, teamID),
onSuccess: AppsTypes.RECEIVED_APP_BINDINGS,
}));
};
}

View File

@@ -242,7 +242,6 @@ export function getProfiles(page = 0, perPage: number = General.PROFILE_CHUNK_SI
try {
profiles = await Client4.getProfiles(page, perPage, options);
removeUserFromList(currentUserId, profiles);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
@@ -251,7 +250,7 @@ export function getProfiles(page = 0, perPage: number = General.PROFILE_CHUNK_SI
dispatch({
type: UserTypes.RECEIVED_PROFILES_LIST,
data: profiles,
data: removeUserFromList(currentUserId, [...profiles]),
});
return {data: profiles};
@@ -307,7 +306,6 @@ export function getProfilesByIds(userIds: Array<string>, options?: any): ActionF
try {
profiles = await Client4.getProfilesByIds(userIds, options);
removeUserFromList(currentUserId, profiles);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
@@ -316,7 +314,7 @@ export function getProfilesByIds(userIds: Array<string>, options?: any): ActionF
dispatch({
type: UserTypes.RECEIVED_PROFILES_LIST,
data: profiles,
data: removeUserFromList(currentUserId, [...profiles]),
});
return {data: profiles};
@@ -330,7 +328,6 @@ export function getProfilesByUsernames(usernames: Array<string>): ActionFunc {
try {
profiles = await Client4.getProfilesByUsernames(usernames);
removeUserFromList(currentUserId, profiles);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
@@ -339,7 +336,7 @@ export function getProfilesByUsernames(usernames: Array<string>): ActionFunc {
dispatch({
type: UserTypes.RECEIVED_PROFILES_LIST,
data: profiles,
data: removeUserFromList(currentUserId, [...profiles]),
});
return {data: profiles};

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PostAction} from './integration_actions';
export type MessageAttachment = {
id: number;
fallback: string;
color: string;
pretext: string;
author_name: string;
author_link: string;
author_icon: string;
title: string;
title_link: string;
text: string;
fields: MessageAttachmentField[];
image_url: string;
thumb_url: string;
footer: string;
footer_icon: string;
timestamp: number | string;
actions?: PostAction[];
};
export type MessageAttachmentField = {
title: string;
value: any;
short: boolean;
}

View File

@@ -18,11 +18,12 @@ import {dismissModal} from 'app/actions/navigation';
import DialogIntroductionText from './dialog_introduction_text';
import {Theme} from '@mm-redux/types/preferences';
import {AppCallRequest, AppCallResponse, AppField, AppForm, AppFormValue, AppFormValues, AppLookupResponse, AppSelectOption, FormResponseData} from '@mm-redux/types/apps';
import {AppCallRequest, AppField, AppForm, AppFormValue, AppFormValues, AppLookupResponse, AppSelectOption, FormResponseData} from '@mm-redux/types/apps';
import {DialogElement} from '@mm-redux/types/integrations';
import {AppCallResponseTypes} from '@mm-redux/constants/apps';
import AppsFormField from './apps_form_field';
import {preventDoubleTap} from '@utils/tap';
import {DoAppCallResult} from 'types/actions/apps';
export type Props = {
call: AppCallRequest;
@@ -32,9 +33,9 @@ export type Props = {
values: {
[name: string]: string;
};
}) => Promise<{data?: AppCallResponse<FormResponseData>, error?: AppCallResponse<FormResponseData>}>;
performLookupCall: (field: AppField, values: AppFormValues, userInput: string) => Promise<{data?: AppCallResponse<AppLookupResponse>, error?: AppCallResponse<AppLookupResponse>}>;
refreshOnSelect: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise<{data?: AppCallResponse<FormResponseData>, error?: AppCallResponse<FormResponseData>}>;
}) => Promise<DoAppCallResult<FormResponseData>>;
performLookupCall: (field: AppField, values: AppFormValues, userInput: string) => Promise<DoAppCallResult<AppLookupResponse>>;
refreshOnSelect: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise<DoAppCallResult<FormResponseData>>;
};
theme: Theme;
componentId: string;

View File

@@ -5,18 +5,18 @@ import React, {PureComponent} from 'react';
import {intlShape} from 'react-intl';
import {Theme} from '@mm-redux/types/preferences';
import {AppCallResponse, AppCallRequest, AppField, AppForm, AppFormValues, FormResponseData, AppCallType, AppLookupResponse} from '@mm-redux/types/apps';
import {AppCallResponse, AppCallRequest, AppField, AppForm, AppFormValues, FormResponseData, AppLookupResponse} from '@mm-redux/types/apps';
import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps';
import AppsFormComponent from './apps_form_component';
import {makeCallErrorResponse} from '@utils/apps';
import {SendEphemeralPost} from 'types/actions/posts';
import {DoAppCall, DoAppCallResult, PostEphemeralCallResponseForContext} from 'types/actions/apps';
export type Props = {
form?: AppForm;
call?: AppCallRequest;
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse<any>, error?: AppCallResponse<any>}>;
sendEphemeralPost: SendEphemeralPost;
doAppCall: DoAppCall<any>;
postEphemeralCallResponseForContext: PostEphemeralCallResponseForContext;
};
theme: Theme;
componentId: string;
@@ -81,7 +81,7 @@ export default class AppsFormContainer extends PureComponent<Props, State> {
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
this.props.actions.sendEphemeralPost(callResp.markdown, call.context.channel_id, call.context.root_id || call.context.post_id, callResp.app_metadata?.bot_user_id);
this.props.actions.postEphemeralCallResponseForContext(callResp, callResp.markdown, call.context);
}
break;
case AppCallResponseTypes.FORM:
@@ -102,7 +102,7 @@ export default class AppsFormContainer extends PureComponent<Props, State> {
return res;
}
refreshOnSelect = async (field: AppField, values: AppFormValues): Promise<{data?: AppCallResponse<FormResponseData>, error?: AppCallResponse<FormResponseData>}> => {
refreshOnSelect = async (field: AppField, values: AppFormValues): Promise<DoAppCallResult<FormResponseData>> => {
const intl = this.context.intl;
const makeErrorMsg = (message: string) => intl.formatMessage(
{
@@ -171,7 +171,7 @@ export default class AppsFormContainer extends PureComponent<Props, State> {
return res;
};
performLookupCall = async (field: AppField, values: AppFormValues, userInput: string): Promise<{data?: AppCallResponse<AppLookupResponse>, error?: AppCallResponse<AppLookupResponse>}> => {
performLookupCall = async (field: AppField, values: AppFormValues, userInput: string): Promise<DoAppCallResult<AppLookupResponse>> => {
const intl = this.context.intl;
const makeErrorMsg = (message: string) => intl.formatMessage(
{

View File

@@ -5,19 +5,17 @@ import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {doAppCall} from '@actions/apps';
import {doAppCall, postEphemeralCallResponseForContext} from '@actions/apps';
import {AppCallResponse, AppCallRequest, AppCallType} from '@mm-redux/types/apps';
import {GlobalState} from '@mm-redux/types/store';
import {ActionFunc, GenericAction} from '@mm-redux/types/actions';
import {SendEphemeralPost} from 'types/actions/posts';
import AppsFormContainer from './apps_form_container';
import {sendEphemeralPost} from '@actions/views/post';
import {DoAppCall, PostEphemeralCallResponseForContext} from 'types/actions/apps';
type Actions = {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: SendEphemeralPost;
doAppCall: DoAppCall;
postEphemeralCallResponseForContext: PostEphemeralCallResponseForContext;
};
function mapStateToProps(state: GlobalState) {
@@ -30,7 +28,7 @@ function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
sendEphemeralPost,
postEphemeralCallResponseForContext,
}, dispatch),
};
}

View File

@@ -33,13 +33,11 @@ exports[`channelInfo should match snapshot 1`] = `
hasGuests={false}
header=""
isArchived={false}
isBot={false}
isGroupConstrained={false}
isTeammateGuest={false}
memberCount={2}
onPermalinkPress={[Function]}
purpose="Purpose"
status="status"
testID="channel_info.header"
theme={
Object {

View File

@@ -27,14 +27,13 @@ exports[`channel_info_header should match snapshot 1`] = `
}
>
<ChannelIcon
hasDraft={false}
isActive={false}
isArchived={false}
isBot={false}
isInfo={true}
isUnread={false}
membersCount={3}
size={24}
status="status"
testID="channel_info.header.channel_icon"
theme={
Object {
@@ -76,6 +75,7 @@ exports[`channel_info_header should match snapshot 1`] = `
"flex": 1,
"fontSize": 15,
"fontWeight": "600",
"marginLeft": 13,
}
}
testID="channel_info.header.display_name"
@@ -334,14 +334,13 @@ exports[`channel_info_header should match snapshot when DM and hasGuests and is
}
>
<ChannelIcon
hasDraft={false}
isActive={false}
isArchived={false}
isBot={false}
isInfo={true}
isUnread={false}
membersCount={3}
size={24}
status="status"
testID="channel_info.header.channel_icon"
theme={
Object {
@@ -383,6 +382,7 @@ exports[`channel_info_header should match snapshot when DM and hasGuests and is
"flex": 1,
"fontSize": 15,
"fontWeight": "600",
"marginLeft": 13,
}
}
testID="channel_info.header.display_name"
@@ -669,14 +669,13 @@ exports[`channel_info_header should match snapshot when DM and hasGuests but its
}
>
<ChannelIcon
hasDraft={false}
isActive={false}
isArchived={false}
isBot={false}
isInfo={true}
isUnread={false}
membersCount={3}
size={24}
status="status"
testID="channel_info.header.channel_icon"
theme={
Object {
@@ -718,6 +717,7 @@ exports[`channel_info_header should match snapshot when DM and hasGuests but its
"flex": 1,
"fontSize": 15,
"fontWeight": "600",
"marginLeft": 13,
}
}
testID="channel_info.header.display_name"
@@ -976,14 +976,13 @@ exports[`channel_info_header should match snapshot when GM and hasGuests 1`] = `
}
>
<ChannelIcon
hasDraft={false}
isActive={false}
isArchived={false}
isBot={false}
isInfo={true}
isUnread={false}
membersCount={3}
size={24}
status="status"
testID="channel_info.header.channel_icon"
theme={
Object {
@@ -1025,6 +1024,7 @@ exports[`channel_info_header should match snapshot when GM and hasGuests 1`] = `
"flex": 1,
"fontSize": 15,
"fontWeight": "600",
"marginLeft": 13,
}
}
testID="channel_info.header.display_name"
@@ -1311,14 +1311,13 @@ exports[`channel_info_header should match snapshot when is group constrained 1`]
}
>
<ChannelIcon
hasDraft={false}
isActive={false}
isArchived={false}
isBot={false}
isInfo={true}
isUnread={false}
membersCount={3}
size={24}
status="status"
testID="channel_info.header.channel_icon"
theme={
Object {
@@ -1360,6 +1359,7 @@ exports[`channel_info_header should match snapshot when is group constrained 1`]
"flex": 1,
"fontSize": 15,
"fontWeight": "600",
"marginLeft": 13,
}
}
testID="channel_info.header.display_name"
@@ -1639,14 +1639,13 @@ exports[`channel_info_header should match snapshot when public channel and hasGu
}
>
<ChannelIcon
hasDraft={false}
isActive={false}
isArchived={false}
isBot={false}
isInfo={true}
isUnread={false}
membersCount={3}
size={24}
status="status"
testID="channel_info.header.channel_icon"
theme={
Object {
@@ -1688,6 +1687,7 @@ exports[`channel_info_header should match snapshot when public channel and hasGu
"flex": 1,
"fontSize": 15,
"fontWeight": "600",
"marginLeft": 13,
}
}
testID="channel_info.header.display_name"

View File

@@ -8,13 +8,13 @@ import {intlShape, injectIntl} from 'react-intl';
import Separator from '@screens/channel_info/separator';
import ChannelInfoRow from '../channel_info_row';
import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {AppBinding} from '@mm-redux/types/apps';
import {Theme} from '@mm-redux/types/preferences';
import {Channel} from '@mm-redux/types/channels';
import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps';
import {dismissModal} from '@actions/navigation';
import {createCallContext, createCallRequest} from '@utils/apps';
import {SendEphemeralPost} from 'types/actions/posts';
import {DoAppCall, PostEphemeralCallResponseForChannel} from 'types/actions/apps';
type Props = {
bindings: AppBinding[];
@@ -24,8 +24,8 @@ type Props = {
intl: typeof intlShape;
currentTeamId: string;
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: SendEphemeralPost;
doAppCall: DoAppCall;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
}
}
@@ -63,8 +63,8 @@ type OptionProps = {
intl: typeof intlShape;
currentTeamId: string;
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: SendEphemeralPost;
doAppCall: DoAppCall;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
},
}
@@ -79,7 +79,7 @@ class Option extends React.PureComponent<OptionProps, OptionState> {
onPress = async () => {
const {binding, currentChannel, currentTeamId, intl} = this.props;
const {doAppCall, sendEphemeralPost} = this.props.actions;
const {doAppCall, postEphemeralCallResponseForChannel} = this.props.actions;
if (this.state.submitting) {
return;
@@ -121,11 +121,10 @@ class Option extends React.PureComponent<OptionProps, OptionState> {
}
const callResp = res.data!;
const ephemeral = (message: string) => sendEphemeralPost(message, currentChannel.id, '', callResp.app_metadata?.bot_user_id);
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
ephemeral(callResp.markdown);
postEphemeralCallResponseForChannel(callResp, callResp.markdown, currentChannel.id);
}
break;
case AppCallResponseTypes.NAVIGATE:

View File

@@ -9,15 +9,13 @@ import {AppBindingLocations} from '@mm-redux/constants/apps';
import {getCurrentChannel} from '@mm-redux/selectors/entities/channels';
import {GlobalState} from '@mm-redux/types/store';
import {GenericAction, ActionFunc} from '@mm-redux/types/actions';
import {AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {DoAppCall, PostEphemeralCallResponseForChannel} from 'types/actions/apps';
import {appsEnabled} from '@utils/apps';
import {doAppCall} from '@actions/apps';
import {doAppCall, postEphemeralCallResponseForChannel} from '@actions/apps';
import Bindings from './bindings';
import {sendEphemeralPost} from '@actions/views/post';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {SendEphemeralPost} from 'types/actions/posts';
function mapStateToProps(state: GlobalState) {
const apps = appsEnabled(state);
@@ -33,15 +31,15 @@ function mapStateToProps(state: GlobalState) {
}
type Actions = {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: SendEphemeralPost;
doAppCall: DoAppCall;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
sendEphemeralPost,
postEphemeralCallResponseForChannel,
}, dispatch),
};
}

View File

@@ -45,11 +45,9 @@ export default class ChannelInfo extends PureComponent {
currentChannelGuestCount: PropTypes.number,
currentChannelMemberCount: PropTypes.number,
currentUserId: PropTypes.string,
isBot: PropTypes.bool.isRequired,
isLandscape: PropTypes.bool.isRequired,
isTeammateGuest: PropTypes.bool.isRequired,
isDirectMessage: PropTypes.bool.isRequired,
status: PropTypes.string,
teammateId: PropTypes.string,
theme: PropTypes.object.isRequired,
};
@@ -172,9 +170,8 @@ export default class ChannelInfo extends PureComponent {
currentChannelCreatorName,
currentChannelMemberCount,
currentChannelGuestCount,
status,
teammateId,
theme,
isBot,
isTeammateGuest,
} = this.props;
@@ -201,11 +198,10 @@ export default class ChannelInfo extends PureComponent {
memberCount={currentChannelMemberCount}
onPermalinkPress={this.handlePermalinkPress}
purpose={currentChannel.purpose}
status={status}
teammateId={teammateId}
theme={theme}
type={currentChannel.type}
isArchived={channelIsArchived}
isBot={isBot}
isTeammateGuest={isTeammateGuest}
hasGuests={currentChannelGuestCount > 0}
isGroupConstrained={currentChannel.group_constrained}

View File

@@ -44,9 +44,7 @@ describe('channelInfo', () => {
currentChannelMemberCount: 2,
currentChannelGuestCount: 0,
currentUserId: '1234',
status: 'status',
theme: Preferences.THEMES.default,
isBot: false,
isTeammateGuest: false,
isDirectMessage: false,
actions: {

View File

@@ -12,18 +12,17 @@ import {
import {intlShape} from 'react-intl';
import Clipboard from '@react-native-community/clipboard';
import {popToRoot} from '@actions/navigation';
import ChannelIcon from '@components/channel_icon';
import FormattedDate from '@components/formatted_date';
import FormattedText from '@components/formatted_text';
import Markdown from '@components/markdown';
import {General} from '@mm-redux/constants';
import ChannelIcon from 'app/components/channel_icon';
import FormattedDate from 'app/components/formatted_date';
import FormattedText from 'app/components/formatted_text';
import Markdown from 'app/components/markdown';
import BottomSheet from '@utils/bottom_sheet';
import {t} from '@utils/i18n';
import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import mattermostManaged from 'app/mattermost_managed';
import BottomSheet from 'app/utils/bottom_sheet';
import {getMarkdownTextStyles, getMarkdownBlockStyles} from 'app/utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {t} from 'app/utils/i18n';
import {popToRoot} from 'app/actions/navigation';
export default class ChannelInfoHeader extends React.PureComponent {
static propTypes = {
@@ -34,11 +33,10 @@ export default class ChannelInfoHeader extends React.PureComponent {
header: PropTypes.string,
onPermalinkPress: PropTypes.func,
purpose: PropTypes.string,
status: PropTypes.string,
teammateId: PropTypes.string,
theme: PropTypes.object.isRequired,
type: PropTypes.string.isRequired,
isArchived: PropTypes.bool.isRequired,
isBot: PropTypes.bool.isRequired,
isTeammateGuest: PropTypes.bool.isRequired,
hasGuests: PropTypes.bool.isRequired,
isGroupConstrained: PropTypes.bool,
@@ -135,11 +133,10 @@ export default class ChannelInfoHeader extends React.PureComponent {
memberCount,
onPermalinkPress,
purpose,
status,
teammateId,
theme,
type,
isArchived,
isBot,
isGroupConstrained,
testID,
timeZone,
@@ -148,9 +145,10 @@ export default class ChannelInfoHeader extends React.PureComponent {
const style = getStyleSheet(theme);
const textStyles = getMarkdownTextStyles(theme);
const blockStyles = getMarkdownBlockStyles(theme);
const baseTextStyle = Platform.OS === 'ios' ?
{...style.detail, lineHeight: 20} :
style.detail;
const baseTextStyle = Platform.select({
ios: {...style.detail, lineHeight: 20},
android: style.detail,
});
return (
<View style={style.container}>
@@ -159,11 +157,10 @@ export default class ChannelInfoHeader extends React.PureComponent {
isInfo={true}
membersCount={memberCount}
size={24}
status={status}
userId={teammateId}
theme={theme}
type={type}
isArchived={isArchived}
isBot={isBot}
testID={`${testID}.channel_icon`}
/>
<Text
@@ -268,6 +265,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
fontSize: 15,
fontWeight: '600',
color: theme.centerChannelColor,
marginLeft: 13,
},
channelNameContainer: {
flexDirection: 'row',

View File

@@ -11,10 +11,9 @@ import {getCustomEmojisInText} from '@mm-redux/actions/emojis';
import {General} from '@mm-redux/constants';
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
import {getCurrentChannel, getCurrentChannelStats} from '@mm-redux/selectors/entities/channels';
import {getCurrentUserId, getUser, getStatusForUserId} from '@mm-redux/selectors/entities/users';
import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users';
import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils';
import {displayUsername} from '@mm-redux/utils/user_utils';
import {isLandscape} from '@selectors/device';
import {isGuest} from '@utils/users';
import ChannelInfo from './channel_info';
@@ -29,17 +28,13 @@ function mapStateToProps(state) {
let currentChannelGuestCount = (currentChannelStats && currentChannelStats.guest_count) || 0;
const currentUserId = getCurrentUserId(state);
let status;
let isBot = false;
let teammateId;
let isTeammateGuest = false;
const isDirectMessage = currentChannel.type === General.DM_CHANNEL;
if (isDirectMessage) {
const teammateId = getUserIdFromChannelName(currentUserId, currentChannel.name);
teammateId = getUserIdFromChannelName(currentUserId, currentChannel.name);
const teammate = getUser(state, teammateId);
status = getStatusForUserId(state, teammateId);
if (teammate && teammate.is_bot) {
isBot = true;
}
if (isGuest(teammate)) {
isTeammateGuest = true;
currentChannelGuestCount = 1;
@@ -56,11 +51,9 @@ function mapStateToProps(state) {
currentChannelGuestCount,
currentChannelMemberCount,
currentUserId,
isBot,
isLandscape: isLandscape(state),
isTeammateGuest,
isDirectMessage,
status,
teammateId,
theme: getTheme(state),
};
}

View File

@@ -250,7 +250,7 @@ export default class EditPost extends PureComponent {
}
}
const height = Platform.OS === 'android' ? (deviceHeight / 2) - 40 : (deviceHeight / 2);
const height = Platform.OS === 'android' ? (deviceHeight / 2) - 40 : (deviceHeight / 2) - 30;
const autocompleteStyles = [
style.autocompleteContainer,
{flex: autocompleteVisible ? 1 : 0},

View File

@@ -95,7 +95,7 @@ const GalleryViewer = (props: GalleryProps) => {
const imgHeight = currentFile.height || height;
const imgWidth = currentFile.width || width;
const calculatedDimensions = calculateDimensions(imgHeight, imgWidth, width, height);
const imgCanvas = vec.create(calculatedDimensions.width, calculatedDimensions.height);
const imgCanvas = vec.create(calculatedDimensions.width || 0, calculatedDimensions.height || 0);
const minImgVec = vec.min(vec.multiply(-0.5, imgCanvas, sub(scale, 1)), 0);
const maxImgVec = vec.max(vec.minus(minImgVec), 0);

View File

@@ -8,13 +8,13 @@ import {intlShape, injectIntl} from 'react-intl';
import {isSystemMessage} from '@mm-redux/utils/post_utils';
import PostOption from '../post_option';
import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {AppBinding, AppCallResponse} from '@mm-redux/types/apps';
import {Theme} from '@mm-redux/types/preferences';
import {Post} from '@mm-redux/types/posts';
import {UserProfile} from '@mm-redux/types/users';
import {AppCallResponseTypes, AppCallTypes, AppExpandLevels} from '@mm-redux/constants/apps';
import {createCallContext, createCallRequest} from '@utils/apps';
import {SendEphemeralPost} from 'types/actions/posts';
import {DoAppCall, PostEphemeralCallResponseForPost} from 'types/actions/apps';
type Props = {
bindings: AppBinding[],
@@ -22,12 +22,12 @@ type Props = {
post: Post,
currentUser: UserProfile,
teamID: string,
closeWithAnimation: () => void,
closeWithAnimation: (cb?: () => void) => void,
appsEnabled: boolean,
intl: typeof intlShape,
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: SendEphemeralPost;
doAppCall: DoAppCall;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
}
}
@@ -69,18 +69,18 @@ type OptionProps = {
post: Post,
currentUser: UserProfile,
teamID: string,
closeWithAnimation: () => void,
closeWithAnimation: (cb?: () => void) => void,
intl: typeof intlShape,
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: SendEphemeralPost;
doAppCall: DoAppCall;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
},
}
class Option extends React.PureComponent<OptionProps> {
onPress = async () => {
const {closeWithAnimation, post, teamID, binding, intl} = this.props;
const {doAppCall, sendEphemeralPost} = this.props.actions;
const {doAppCall, postEphemeralCallResponseForPost} = this.props.actions;
if (!binding.call) {
return;
@@ -101,47 +101,48 @@ class Option extends React.PureComponent<OptionProps> {
},
);
closeWithAnimation();
const res = await doAppCall(call, AppCallTypes.SUBMIT, intl);
if (res.error) {
const errorResponse = res.error;
const title = intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errorMessage = errorResponse.error || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
Alert.alert(title, errorMessage);
return;
}
const callResp = (res as {data: AppCallResponse}).data;
const ephemeral = (message: string) => sendEphemeralPost(message, post.channel_id, post.root_id || post.id, callResp.app_metadata?.bot_user_id);
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
ephemeral(callResp.markdown);
closeWithAnimation(async () => {
const callPromise = doAppCall(call, AppCallTypes.SUBMIT, intl);
const res = await callPromise;
if (res.error) {
const errorResponse = res.error;
const title = intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errorMessage = errorResponse.error || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
Alert.alert(title, errorMessage);
return;
}
break;
case AppCallResponseTypes.NAVIGATE:
case AppCallResponseTypes.FORM:
break;
default: {
const title = intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errMessage = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
Alert.alert(title, errMessage);
}
}
const callResp = (res as {data: AppCallResponse}).data;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
}
break;
case AppCallResponseTypes.NAVIGATE:
case AppCallResponseTypes.FORM:
break;
default: {
const title = intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errMessage = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
Alert.alert(title, errMessage);
}
}
});
};
render() {

Some files were not shown because too many files have changed in this diff Show More