Port WebSocket from mm-redux and batch actions (#4060)

* Port WebSocket from mm-redux and batch actions

* Update mm-redux and fix tests

* Change action name

* Naming batch actions

* Fix unit tests

* Dispatch connection change only if its different

* Remove comment

* Add Lint to TypeScript and fix linting errors

* Add WebSocket Unit Tests

* Revert from unwanted RN 0.62
This commit is contained in:
Elias Nahum 2020-03-26 21:23:50 -03:00 committed by GitHub
parent 7d8a286192
commit 10d433cfab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 6016 additions and 2065 deletions

View file

@ -1,9 +1,13 @@
{
"extends": [
"plugin:mattermost/react"
"plugin:mattermost/react",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": [
"mattermost"
"mattermost",
"@typescript-eslint"
],
"settings": {
"react": {
@ -20,7 +24,16 @@
"rules": {
"global-require": 0,
"react/display-name": [2, { "ignoreTranspilerName": false }],
"react/jsx-filename-extension": [2, {"extensions": [".js"]}]
"react/jsx-filename-extension": [2, {"extensions": [".js"]}],
"no-undefined": 0,
"@typescript-eslint/camelcase": 0,
"@typescript-eslint/no-undefined": 0,
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/no-unused-vars": 0,
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-use-before-define": 0,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/explicit-function-return-type": 0
},
"overrides": [
{

View file

@ -2,16 +2,21 @@
// See LICENSE.txt for license information.
import {networkStatusChangedAction} from 'redux-offline';
import {batchActions} from 'redux-batched-actions';
import {DeviceTypes} from 'app/constants';
export function connection(isOnline) {
return async (dispatch) => {
dispatch(networkStatusChangedAction(isOnline));
dispatch({
type: DeviceTypes.CONNECTION_CHANGED,
data: isOnline,
});
return async (dispatch, getState) => {
const state = getState();
if (isOnline !== undefined && isOnline !== state.device.connection) { //eslint-disable-line no-undefined
dispatch(batchActions([
networkStatusChangedAction(isOnline), {
type: DeviceTypes.CONNECTION_CHANGED,
data: isOnline,
},
], 'BATCH_CONNECTION_CHANGED'));
}
};
}

View file

@ -0,0 +1,355 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ChannelTypes, PreferenceTypes, RoleTypes, UserTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import {General, Preferences} from 'mattermost-redux/constants';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId, getUsers, getUserIdsInChannels} from 'mattermost-redux/selectors/entities/users';
import {getUserIdFromChannelName, isAutoClosed} from 'mattermost-redux/utils/channel_utils';
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import {ActionResult, GenericAction} from 'mattermost-redux/types/actions';
import {Channel, ChannelMembership} from 'mattermost-redux/types/channels';
import {PreferenceType} from 'mattermost-redux/types/preferences';
import {GlobalState} from 'mattermost-redux/types/store';
import {UserProfile} from 'mattermost-redux/types/users';
import {RelationOneToMany} from 'mattermost-redux/types/utilities';
import {isDirectChannelVisible, isGroupChannelVisible} from '@utils/channels';
import {buildPreference} from '@utils/preferences';
export async function loadSidebarDirectMessagesProfiles(state: GlobalState, channels: Array<Channel>, channelMembers: Array<ChannelMembership>) {
const currentUserId = getCurrentUserId(state);
const usersInChannel: RelationOneToMany<Channel, UserProfile> = getUserIdsInChannels(state);
const directChannels = Object.values(channels).filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
const prefs: Array<PreferenceType> = [];
const promises: Array<Promise<ActionResult>> = []; //only fetch profiles that we don't have and the Direct channel should be visible
const actions = [];
// Prepare preferences and start fetching profiles to batch them
directChannels.forEach((c) => {
const profileIds = Array.from(usersInChannel[c.id] || []);
const profilesInChannel: Array<string> = profileIds.filter((u: string) => u !== currentUserId);
switch (c.type) {
case General.DM_CHANNEL: {
const dm = fetchDirectMessageProfileIfNeeded(state, c, channelMembers, profilesInChannel);
if (dm.preferences.length) {
prefs.push(...dm.preferences);
}
if (dm.promise) {
promises.push(dm.promise);
}
break;
}
case General.GM_CHANNEL: {
const gm = fetchGroupMessageProfilesIfNeeded(state, c, channelMembers, profilesInChannel);
if (gm.preferences.length) {
prefs.push(...gm.preferences);
}
if (gm.promise) {
promises.push(gm.promise);
}
break;
}
}
});
// Save preferences if there are any changes
if (prefs.length) {
Client4.savePreferences(currentUserId, prefs);
actions.push({
type: PreferenceTypes.RECEIVED_PREFERENCES,
data: prefs,
});
}
const profilesAction = await getProfilesFromPromises(promises);
if (profilesAction) {
actions.push(profilesAction);
}
return actions;
}
export async function fetchMyChannel(channelId: string) {
try {
const data = await Client4.getChannel(channelId);
return {data};
} catch (error) {
return {error};
}
}
export async function fetchMyChannelMember(channelId: string) {
try {
const data = await Client4.getMyChannelMember(channelId);
return {data};
} catch (error) {
return {error};
}
}
export function markChannelAsUnread(state: GlobalState, teamId: string, channelId: string, mentions: Array<string>): Array<GenericAction> {
const {myMembers} = state.entities.channels;
const {currentUserId} = state.entities.users;
const actions: GenericAction[] = [{
type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT,
data: {
channelId,
amount: 1,
},
}, {
type: ChannelTypes.INCREMENT_UNREAD_MSG_COUNT,
data: {
teamId,
channelId,
amount: 1,
onlyMentions: myMembers[channelId] && myMembers[channelId].notify_props &&
myMembers[channelId].notify_props.mark_unread === General.MENTION,
},
}];
if (mentions && mentions.indexOf(currentUserId) !== -1) {
actions.push({
type: ChannelTypes.INCREMENT_UNREAD_MENTION_COUNT,
data: {
teamId,
channelId,
amount: 1,
},
});
}
return actions;
}
export function makeDirectChannelVisibleIfNecessary(state: GlobalState, otherUserId: string): GenericAction|null {
const myPreferences = getMyPreferences(state);
const currentUserId = getCurrentUserId(state);
let preference = myPreferences[getPreferenceKey(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, otherUserId)];
if (!preference || preference.value === 'false') {
preference = {
user_id: currentUserId,
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
name: otherUserId,
value: 'true',
};
return {
type: PreferenceTypes.RECEIVED_PREFERENCES,
data: [preference],
};
}
return null;
}
export async function makeGroupMessageVisibleIfNecessary(state: GlobalState, channelId: string) {
try {
const myPreferences = getMyPreferences(state);
const currentUserId = getCurrentUserId(state);
let preference = myPreferences[getPreferenceKey(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId)];
if (!preference || preference.value === 'false') {
preference = {
user_id: currentUserId,
category: Preferences.CATEGORY_GROUP_CHANNEL_SHOW,
name: channelId,
value: 'true',
};
const profilesInChannel = await fetchUsersInChannel(state, channelId);
return [{
type: UserTypes.RECEIVED_BATCHED_PROFILES_IN_CHANNEL,
data: [profilesInChannel],
}, {
type: PreferenceTypes.RECEIVED_PREFERENCES,
data: [preference],
}];
}
return null;
} catch {
return null;
}
}
export async function fetchChannelAndMyMember(channelId: string): Promise<Array<GenericAction>> {
const actions: Array<GenericAction> = [];
try {
const [channel, member] = await Promise.all([
Client4.getChannel(channelId),
Client4.getMyChannelMember(channelId),
]);
actions.push({
type: ChannelTypes.RECEIVED_CHANNEL,
data: channel,
},
{
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER,
data: member,
});
const roles = await Client4.getRolesByNames(member.roles.split(' '));
if (roles.length) {
actions.push({
type: RoleTypes.RECEIVED_ROLES,
data: roles,
});
}
} catch {
// do nothing
}
return actions;
}
export async function getAddedDmUsersIfNecessary(state: GlobalState, preferences: PreferenceType[]): Promise<Array<GenericAction>> {
const userIds: string[] = [];
const actions: Array<GenericAction> = [];
for (const preference of preferences) {
if (preference.category === Preferences.CATEGORY_DIRECT_CHANNEL_SHOW && preference.value === 'true') {
userIds.push(preference.name);
}
}
if (userIds.length !== 0) {
const profiles = getUsers(state);
const currentUserId = getCurrentUserId(state);
const needProfiles: string[] = [];
for (const userId of userIds) {
if (!profiles[userId] && userId !== currentUserId) {
needProfiles.push(userId);
}
}
if (needProfiles.length > 0) {
const data = await Client4.getProfilesByIds(userIds);
if (profiles.lenght) {
actions.push({
type: UserTypes.RECEIVED_PROFILES_LIST,
data,
});
}
}
}
return actions;
}
function fetchDirectMessageProfileIfNeeded(state: GlobalState, channel: Channel, channelMembers: Array<ChannelMembership>, profilesInChannel: Array<string>) {
const currentUserId = getCurrentUserId(state);
const myPreferences = getMyPreferences(state);
const users = getUsers(state);
const config = getConfig(state);
const currentChannelId = getCurrentChannelId(state);
const otherUserId = getUserIdFromChannelName(currentUserId, channel.name);
const otherUser = users[otherUserId];
const dmVisible = isDirectChannelVisible(currentUserId, myPreferences, channel);
const dmAutoClosed = isAutoClosed(config, myPreferences, channel, channel.last_post_at, otherUser ? otherUser.delete_at : 0, currentChannelId);
const member = channelMembers.find((cm) => cm.channel_id === channel.id);
const dmIsUnread = member ? member.mention_count > 0 : false;
const dmFetchProfile = dmIsUnread || (dmVisible && !dmAutoClosed);
const preferences = [];
// when then DM is hidden but has new messages
if ((!dmVisible || dmAutoClosed) && dmIsUnread) {
preferences.push(buildPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, currentUserId, otherUserId));
preferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
}
if (dmFetchProfile && !profilesInChannel.includes(otherUserId) && otherUserId !== currentUserId) {
return {
preferences,
promise: fetchUsersInChannel(state, channel.id),
};
}
return {preferences};
}
function fetchGroupMessageProfilesIfNeeded(state: GlobalState, channel: Channel, channelMembers: Array<ChannelMembership>, profilesInChannel: Array<string>) {
const currentUserId = getCurrentUserId(state);
const myPreferences = getMyPreferences(state);
const config = getConfig(state);
const gmVisible = isGroupChannelVisible(myPreferences, channel.id);
const gmAutoClosed = isAutoClosed(config, myPreferences, channel, channel.last_post_at, 0);
const channelMember = channelMembers.find((cm) => cm.channel_id === channel.id);
let hasMentions = false;
let isUnread = false;
if (channelMember) {
hasMentions = channelMember.mention_count > 0;
isUnread = channelMember.msg_count < channel.total_msg_count;
}
const gmIsUnread = hasMentions || isUnread;
const gmFetchProfile = gmIsUnread || (gmVisible && !gmAutoClosed);
const preferences = [];
// when then GM is hidden but has new messages
if ((!gmVisible || gmAutoClosed) && gmIsUnread) {
preferences.push(buildPreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, currentUserId, channel.id));
preferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
}
if (gmFetchProfile && !profilesInChannel.length) {
return {
preferences,
promise: fetchUsersInChannel(state, channel.id),
};
}
return {preferences};
}
async function fetchUsersInChannel(state: GlobalState, channelId: string): Promise<ActionResult> {
try {
const currentUserId = getCurrentUserId(state);
const profiles = await Client4.getProfilesInChannel(channelId);
// When fetching profiles in channels we exclude our own user
const users = profiles.filter((p: UserProfile) => p.id !== currentUserId);
const data = {
channelId,
users,
};
return {data};
} catch (error) {
return {error};
}
}
async function getProfilesFromPromises(promises: Array<Promise<ActionResult>>): Promise<GenericAction | null> {
// Get the profiles returned by the promises
if (!promises.length) {
return null;
}
const result = await Promise.all(promises);
const data = result.filter((p: any) => !p.error);
return {
type: UserTypes.RECEIVED_BATCHED_PROFILES_IN_CHANNEL,
data,
};
}

View file

@ -5,17 +5,14 @@ import {batchActions} from 'redux-batched-actions';
import {ViewTypes} from 'app/constants';
import {UserTypes, ChannelTypes} from 'mattermost-redux/action_types';
import {ChannelTypes, RoleTypes, UserTypes} from 'mattermost-redux/action_types';
import {
fetchMyChannelsAndMembers,
getChannelByNameAndTeamName,
markChannelAsRead,
markChannelAsViewed,
leaveChannel as serviceLeaveChannel,
} from 'mattermost-redux/actions/channels';
import {getFilesForPost} from 'mattermost-redux/actions/files';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
import {getTeamMembersByIds, selectTeam} from 'mattermost-redux/actions/teams';
import {getProfilesInChannel} from 'mattermost-redux/actions/users';
import {Client4} from 'mattermost-redux/client';
@ -27,9 +24,7 @@ import {
getChannelsNameMapInTeam,
isManuallyUnread,
} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId, getUserIdsInChannels, getUsers} from 'mattermost-redux/selectors/entities/users';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getTeamByName} from 'mattermost-redux/selectors/entities/teams';
import {
@ -44,15 +39,13 @@ import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {getLastCreateAt} from 'mattermost-redux/utils/post_utils';
import {getPreferencesByCategory} from 'mattermost-redux/utils/preference_utils';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from 'app/constants/post_textbox';
import {getChannelReachable} from 'app/selectors/channel';
import telemetry from 'app/telemetry';
import {isDirectChannelVisible, isGroupChannelVisible, isDirectMessageVisible, isGroupMessageVisible, isDirectChannelAutoClosed} from 'app/utils/channels';
import {isPendingPost} from 'app/utils/general';
import {buildPreference} from 'app/utils/preferences';
import {getPosts, getPostsBefore, getPostsSince, getPostThread} from './post';
import {forceLogoutIfNecessary} from './user';
import {loadSidebarDirectMessagesProfiles} from '@actions/helpers/channels';
import {getPosts, getPostsBefore, getPostsSince, getPostThread} from '@actions/views/post';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from '@constants/post_textbox';
import {getChannelReachable} from '@selectors/channel';
import telemetry from '@telemetry';
import {isDirectChannelVisible, isGroupChannelVisible} from '@utils/channels';
import {isPendingPost} from '@utils/general';
const MAX_RETRIES = 3;
@ -72,106 +65,6 @@ export function loadChannelsByTeamName(teamName, errorHandler) {
};
}
export function loadProfilesAndTeamMembersForDMSidebar(teamId) {
return async (dispatch, getState) => {
const state = getState();
const {currentUserId, profilesInChannel} = state.entities.users;
const {channels, myMembers} = state.entities.channels;
const {myPreferences} = state.entities.preferences;
const {membersInTeam} = state.entities.teams;
const dmPrefs = getPreferencesByCategory(myPreferences, Preferences.CATEGORY_DIRECT_CHANNEL_SHOW);
const gmPrefs = getPreferencesByCategory(myPreferences, Preferences.CATEGORY_GROUP_CHANNEL_SHOW);
const members = [];
const loadProfilesForChannels = [];
const prefs = [];
function buildPref(name) {
return {
user_id: currentUserId,
category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW,
name,
value: 'true',
};
}
// Find DM's and GM's that need to be shown
const directChannels = Object.values(channels).filter((c) => (isDirectChannel(c) || isGroupChannel(c)));
directChannels.forEach((channel) => {
const member = myMembers[channel.id];
if (isDirectChannel(channel) && !isDirectChannelVisible(currentUserId, myPreferences, channel) && member && member.mention_count > 0) {
const teammateId = getUserIdFromChannelName(currentUserId, channel.name);
let pref = dmPrefs.get(teammateId);
if (pref) {
pref = {...pref, value: 'true'};
} else {
pref = buildPref(teammateId);
}
dmPrefs.set(teammateId, pref);
prefs.push(pref);
} else if (isGroupChannel(channel) && !isGroupChannelVisible(myPreferences, channel) && member && (member.mention_count > 0 || member.msg_count < channel.total_msg_count)) {
const id = channel.id;
let pref = gmPrefs.get(id);
if (pref) {
pref = {...pref, value: 'true'};
} else {
pref = buildPref(id);
}
gmPrefs.set(id, pref);
prefs.push(pref);
}
});
if (prefs.length) {
savePreferences(currentUserId, prefs)(dispatch, getState);
}
for (const [key, pref] of dmPrefs) {
if (pref.value === 'true') {
members.push(key);
}
}
for (const [key, pref] of gmPrefs) {
//only load the profiles in channels if we don't already have them
if (pref.value === 'true' && !profilesInChannel[key]) {
loadProfilesForChannels.push(key);
}
}
if (loadProfilesForChannels.length) {
for (let i = 0; i < loadProfilesForChannels.length; i++) {
const channelId = loadProfilesForChannels[i];
getProfilesInChannel(channelId, 0)(dispatch, getState);
}
}
let membersToLoad = members;
if (membersInTeam[teamId]) {
membersToLoad = members.filter((m) => !membersInTeam[teamId].hasOwnProperty(m));
}
if (membersToLoad.length) {
getTeamMembersByIds(teamId, membersToLoad)(dispatch, getState);
}
const actions = [];
for (let i = 0; i < members.length; i++) {
const channelName = getDirectChannelName(currentUserId, members[i]);
const channel = getChannelByName(channels, channelName);
if (channel) {
actions.push({
type: UserTypes.RECEIVED_PROFILE_IN_CHANNEL,
data: {id: channel.id, user_id: members[i]},
});
}
}
if (actions.length) {
dispatch(batchActions(actions));
}
};
}
export function loadPostsIfNecessaryWithRetry(channelId) {
return async (dispatch, getState) => {
const state = getState();
@ -211,7 +104,8 @@ export function loadPostsIfNecessaryWithRetry(channelId) {
type: ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
channelId,
time,
});
},
setChannelRetryFailed(false));
if (received?.order) {
const count = received.order.length;
@ -220,7 +114,7 @@ export function loadPostsIfNecessaryWithRetry(channelId) {
}
actions.push(setLoadMorePostsVisible(loadMorePostsVisible));
dispatch(batchActions(actions));
dispatch(batchActions(actions, 'BATCH_LOAD_POSTS_IN_CHANNEL'));
};
}
@ -229,7 +123,6 @@ export async function retryGetPostsAction(action, dispatch, getState, maxTries =
const {data} = await dispatch(action); // eslint-disable-line no-await-in-loop
if (data) {
dispatch(setChannelRetryFailed(false));
return data;
}
}
@ -365,7 +258,7 @@ export function handleSelectChannel(channelId) {
teamId: channel.team_id || currentTeamId,
},
});
dispatch(batchActions(actions));
dispatch(batchActions(actions, 'BATCH_SWITCH_CHANNEL'));
}
console.log('channel switch to', channel?.display_name, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line
@ -406,10 +299,16 @@ export function handleSelectChannelByName(channelName, teamName, errorHandler) {
}
export function handlePostDraftChanged(channelId, draft) {
return {
type: ViewTypes.POST_DRAFT_CHANGED,
channelId,
draft,
return (dispatch, getState) => {
const state = getState();
if (state.views.channel.drafts[channelId]?.draft !== draft) {
dispatch({
type: ViewTypes.POST_DRAFT_CHANGED,
channelId,
draft,
});
}
};
}
@ -425,13 +324,15 @@ export function insertToDraft(value) {
}
export function markChannelViewedAndRead(channelId, previousChannelId, markOnServer = true) {
return (dispatch) => {
dispatch(markChannelAsRead(channelId, previousChannelId, markOnServer));
dispatch(markChannelAsViewed(channelId, previousChannelId));
return (dispatch, getState) => {
const state = getState();
const actions = markAsViewedAndReadBatch(state, channelId, previousChannelId, markOnServer);
dispatch(batchActions(actions, 'BATCH_MARK_CHANNEL_VIEWED_AND_READ'));
};
}
function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', markOnServer = true) {
export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', markOnServer = true) {
const actions = [];
const {channels, myMembers} = state.entities.channels;
const channel = channels[channelId];
@ -512,8 +413,7 @@ export function markChannelViewedAndReadOnReconnect(channelId) {
return;
}
dispatch(markChannelAsRead(channelId));
dispatch(markChannelAsViewed(channelId));
dispatch(markChannelViewedAndRead(channelId));
};
}
@ -582,7 +482,13 @@ export function refreshChannelWithRetry(channelId) {
return async (dispatch, getState) => {
dispatch(setChannelRefreshing(true));
const posts = await retryGetPostsAction(getPosts(channelId), dispatch, getState);
dispatch(setChannelRefreshing(false));
const actions = [setChannelRefreshing(false)];
if (posts) {
actions.push(setChannelRetryFailed(false));
}
dispatch(batchActions(actions, 'BATCH_REEFRESH_CHANNEL'));
return posts;
};
}
@ -683,6 +589,10 @@ export function increasePostVisibility(channelId, postId) {
channelId,
}];
if (result) {
actions.push(setChannelRetryFailed(false));
}
let hasMorePost = false;
if (result?.order) {
const count = result.order.length;
@ -691,7 +601,7 @@ export function increasePostVisibility(channelId, postId) {
actions.push(setLoadMorePostsVisible(hasMorePost));
}
dispatch(batchActions(actions));
dispatch(batchActions(actions, 'BATCH_LOAD_MORE_POSTS'));
telemetry.end(['posts:loading']);
telemetry.save();
@ -706,13 +616,14 @@ function setLoadMorePostsVisible(visible) {
};
}
export function loadChannelsForTeam(teamId) {
export function loadChannelsForTeam(teamId, skipDispatch = false) {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = getCurrentUserId(state);
const data = {sync: true, teamId};
const actions = [];
if (currentUserId) {
const data = {sync: true, teamId};
for (let i = 0; i <= MAX_RETRIES; i++) {
try {
console.log('Fetching channels attempt', teamId, (i + 1)); //eslint-disable-line no-console
@ -725,8 +636,7 @@ export function loadChannelsForTeam(teamId) {
data.channelMembers = channelMembers;
break;
} catch (err) {
const result = await dispatch(forceLogoutIfNecessary(err)); //eslint-disable-line no-await-in-loop
if (result || i === MAX_RETRIES) {
if (i === MAX_RETRIES) {
const hasChannelsLoaded = state.entities.channels.channelsInTeam[teamId]?.size > 0;
return {error: hasChannelsLoaded ? null : err};
}
@ -734,175 +644,50 @@ export function loadChannelsForTeam(teamId) {
}
if (data.channels) {
const roles = new Set();
const members = data.channelMembers;
for (const member of members) {
for (const role of member.roles.split(' ')) {
roles.add(role);
}
}
if (roles.size > 0) {
dispatch(loadRolesIfNeeded(roles));
}
// Fetch needed profiles from channel creators and direct channels
dispatch(loadSidebarDirectMessagesProfiles(data));
dispatch({
actions.push({
type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS,
data,
});
}
return {data};
if (!skipDispatch) {
const rolesToLoad = new Set();
const members = data.channelMembers;
for (const member of members) {
for (const role of member.roles.split(' ')) {
rolesToLoad.add(role);
}
}
if (rolesToLoad.size > 0) {
data.roles = await Client4.getRolesByNames(Array.from(rolesToLoad));
if (data.roles.length) {
actions.push({
type: RoleTypes.RECEIVED_ROLES,
data: data.roles,
});
}
}
dispatch(batchActions(actions, 'BATCH_LOAD_CHANNELS_FOR_TEAM'));
}
// Fetch needed profiles from channel creators and direct channels
dispatch(loadSidebar(data));
}
}
return {error: 'Cannot fetch channels without a current user'};
return {data};
};
}
export function loadSidebarDirectMessagesProfiles(data) {
function loadSidebar(data) {
return async (dispatch, getState) => {
const state = getState();
const {channels, channelMembers} = data;
const currentUserId = getCurrentUserId(state);
const usersInChannel = getUserIdsInChannels(state);
const directChannels = Object.values(channels).filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
const prefs = [];
const promises = []; //only fetch profiles that we don't have and the Direct channel should be visible
// Prepare preferences and start fetching profiles to batch them
directChannels.forEach((c) => {
const profilesInChannel = Array.from(usersInChannel[c.id] || []).filter((u) => u.id !== currentUserId);
switch (c.type) {
case General.DM_CHANNEL: {
const dm = fetchDirectMessageProfileIfNeeded(state, c, channelMembers, profilesInChannel, prefs);
if (dm) {
promises.push(dispatch(dm));
}
break;
}
case General.GM_CHANNEL: {
const gm = fetchGroupMessageProfilesIfNeeded(state, c, channelMembers, profilesInChannel, prefs);
if (gm) {
promises.push(dispatch(gm));
}
break;
}
}
});
// Save preferences if there are any changes
if (prefs.length) {
dispatch(savePreferences(currentUserId, prefs));
}
getProfilesFromPromises(dispatch, promises, directChannels);
return {data: true};
};
}
export function getUsersInChannel(channelId) {
return async (dispatch, getState) => {
try {
const state = getState();
const currentUserId = getCurrentUserId(state);
const profiles = await Client4.getProfilesInChannel(channelId);
// When fetching profiles in channels we exclude our own user
const users = profiles.filter((p) => p.id !== currentUserId);
const data = {
channelId,
users,
};
return {data};
} catch (error) {
return {error};
const sidebarActions = await loadSidebarDirectMessagesProfiles(state, channels, channelMembers);
if (sidebarActions.length) {
dispatch(batchActions(sidebarActions, 'BATCH_LOAD_SIDEBAR'));
}
};
}
async function getProfilesFromPromises(dispatch, promiseArray, directChannels) {
// Get the profiles returned by the promises and retry those that failed
let promises = promiseArray;
for (let i = 0; i <= MAX_RETRIES; i++) {
if (!promises.length) {
return;
}
const result = await Promise.all(promises); //eslint-disable-line no-await-in-loop
const failed = [];
result.forEach((p, index) => {
if (p.error) {
failed.push(directChannels[index].id);
}
});
dispatch({
type: UserTypes.RECEIVED_BATCHED_PROFILES_IN_CHANNEL,
data: result,
});
if (failed.length) {
promises = failed.map((id) => dispatch(getUsersInChannel(id))); //eslint-disable-line no-loop-func
continue;
}
return;
}
}
function fetchDirectMessageProfileIfNeeded(state, channel, channelMembers, profilesInChannel, newPreferences) {
const currentUserId = getCurrentUserId(state);
const preferences = getMyPreferences(state);
const users = getUsers(state);
const config = getConfig(state);
const currentChannelId = getCurrentChannelId(state);
const otherUserId = getUserIdFromChannelName(currentUserId, channel.name);
const otherUser = users[otherUserId];
const dmVisible = isDirectMessageVisible(preferences, channel.id);
const dmAutoClosed = isDirectChannelAutoClosed(config, preferences, channel.id, channel.last_post_at, otherUser?.delete_at, currentChannelId); //eslint-disable-line camelcase
const dmIsUnread = channelMembers[channel.id]?.mention_count > 0; //eslint-disable-line camelcase
const dmFetchProfile = dmIsUnread || (dmVisible && !dmAutoClosed);
// when then DM is hidden but has new messages
if ((!dmVisible || dmAutoClosed) && dmIsUnread) {
newPreferences.push(buildPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, currentUserId, otherUserId));
newPreferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
}
if (dmFetchProfile && !profilesInChannel.includes(otherUserId) && otherUserId !== currentUserId) {
return getUsersInChannel(channel.id);
}
return null;
}
function fetchGroupMessageProfilesIfNeeded(state, channel, channelMembers, profilesInChannel, newPreferences) {
const currentUserId = getCurrentUserId(state);
const preferences = getMyPreferences(state);
const config = getConfig(state);
const gmVisible = isGroupMessageVisible(preferences, channel.id);
const gmAutoClosed = isDirectChannelAutoClosed(config, preferences, channel.id, channel.last_post_at);
const channelMember = channelMembers[channel.id];
const gmIsUnread = channelMember?.mention_count > 0 || channelMember?.msg_count < channel.total_msg_count; //eslint-disable-line camelcase
const gmFetchProfile = gmIsUnread || (gmVisible && !gmAutoClosed);
// when then GM is hidden but has new messages
if ((!gmVisible || gmAutoClosed) && gmIsUnread) {
newPreferences.push(buildPreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, currentUserId, channel.id));
newPreferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
}
if (gmFetchProfile && !profilesInChannel.length) {
return getUsersInChannel(channel.id);
}
return null;
}
}

View file

@ -211,7 +211,7 @@ describe('Actions.Views.Channel', () => {
expect(postActions.getPosts).toBeCalled();
const storeActions = store.getActions();
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCHING_REDUCER.BATCH');
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCH_LOAD_POSTS_IN_CHANNEL');
const receivedPosts = storeActions.find(({type}) => type === MOCK_RECEIVED_POSTS);
const receivedPostsAtAction = storeBatchActions[0].payload.some((action) => action.type === 'RECEIVED_POSTS_FOR_CHANNEL_AT_TIME');
@ -297,7 +297,7 @@ describe('Actions.Views.Channel', () => {
await store.dispatch(handleSelectChannel(channelId));
const storeActions = store.getActions();
const storeBatchActions = storeActions.find(({type}) => type === 'BATCHING_REDUCER.BATCH');
const storeBatchActions = storeActions.find(({type}) => type === 'BATCH_SWITCH_CHANNEL');
const selectChannelWithMember = storeBatchActions?.payload.find(({type}) => type === ChannelTypes.SELECT_CHANNEL);
const viewedAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_VIEWED);
const readAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_READ);

View file

@ -79,7 +79,7 @@ export function getEmojisInPosts(posts) {
}
if (actions.length) {
dispatch(batchActions(actions));
dispatch(batchActions(actions, 'BATCH_GET_EMOJIS_FOR_POSTS'));
}
}
};

View file

@ -8,6 +8,7 @@ import {
doPostAction,
getNeededAtMentionedUsernames,
receivedNewPost,
receivedPost,
receivedPosts,
receivedPostsBefore,
receivedPostsInChannel,
@ -16,6 +17,7 @@ import {
} from 'mattermost-redux/actions/posts';
import {Client4} from 'mattermost-redux/client';
import {Posts} from 'mattermost-redux/constants';
import {getPost as selectPost} from 'mattermost-redux/selectors/entities/posts';
import {removeUserFromList} from 'mattermost-redux/utils/user_utils';
import {ViewTypes} from 'app/constants';
@ -91,12 +93,37 @@ export function getPosts(channelId, page = 0, perPage = Posts.POST_CHUNK_SIZE) {
if (posts?.length) {
actions.push(receivedPosts(data));
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.length) {
actions.push(...additional);
if (additional.data.length) {
actions.push(...additional.data);
}
}
dispatch(batchActions(actions));
dispatch(batchActions(actions, 'BATCH_GET_POSTS'));
return {data};
} catch (error) {
return {error};
}
};
}
export function getPost(postId) {
return async (dispatch) => {
try {
const data = await Client4.getPost(postId);
if (data) {
const actions = [
receivedPost(data),
];
const additional = await dispatch(getPostsAdditionalDataBatch([data]));
if (additional.data.length) {
actions.push(...additional.data);
}
dispatch(batchActions(actions, 'BATCH_GET_POST'));
}
return {data};
} catch (error) {
@ -118,11 +145,11 @@ export function getPostsSince(channelId, since) {
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.length) {
actions.push(...additional);
if (additional.data.length) {
actions.push(...additional.data);
}
dispatch(batchActions(actions));
dispatch(batchActions(actions, 'BATCH_GET_POSTS_SINCE'));
}
return {data};
@ -145,11 +172,11 @@ export function getPostsBefore(channelId, postId, page = 0, perPage = Posts.POST
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.length) {
actions.push(...additional);
if (additional.data.length) {
actions.push(...additional.data);
}
dispatch(batchActions(actions));
dispatch(batchActions(actions, 'BATCH_GET_POSTS_BEFORE'));
}
return {data};
@ -159,7 +186,7 @@ export function getPostsBefore(channelId, postId, page = 0, perPage = Posts.POST
};
}
export function getPostThread(rootId) {
export function getPostThread(rootId, skipDispatch = false) {
return async (dispatch) => {
try {
const data = await Client4.getPostThread(rootId);
@ -172,11 +199,15 @@ export function getPostThread(rootId) {
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.length) {
actions.push(...additional);
if (additional.data.length) {
actions.push(...additional.data);
}
dispatch(batchActions(actions));
if (skipDispatch) {
return {data: actions};
}
dispatch(batchActions(actions, 'BATCH_GET_POSTS_THREAD'));
}
return {data};
@ -219,11 +250,11 @@ export function getPostsAround(channelId, postId, perPage = Posts.POST_CHUNK_SIZ
];
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
if (additional.length) {
actions.push(...additional);
if (additional.data.length) {
actions.push(...additional.data);
}
dispatch(batchActions(actions));
dispatch(batchActions(actions, 'BATCH_GET_POSTS_AROUND'));
}
return {data};
@ -233,12 +264,40 @@ export function getPostsAround(channelId, postId, perPage = Posts.POST_CHUNK_SIZ
};
}
function getPostsAdditionalDataBatch(posts = []) {
export function handleNewPostBatch(WebSocketMessage) {
return async (dispatch, getState) => {
const actions = [];
const state = getState();
const post = JSON.parse(WebSocketMessage.data.post);
const actions = [receivedNewPost(post)];
// If we don't have the thread for this post, fetch it from the server
// and include the actions in the batch
if (post.root_id) {
const rootPost = selectPost(state, post.root_id);
if (!rootPost) {
const thread = await dispatch(getPostThread(post.root_id, true));
if (thread.actions?.length) {
actions.push(...thread.actions);
}
}
}
const additional = await dispatch(getPostsAdditionalDataBatch([post]));
if (additional.data.length) {
actions.push(...additional.data);
}
return actions;
};
}
export function getPostsAdditionalDataBatch(posts = []) {
return async (dispatch, getState) => {
const data = [];
if (!posts.length) {
return actions;
return {data};
}
// Custom Emojis used in the posts
@ -249,7 +308,7 @@ function getPostsAdditionalDataBatch(posts = []) {
const state = getState();
const promises = [];
const promiseTrace = [];
const extra = dispatch(profilesStatusesAndToLoadFromPosts(posts));
const extra = userMetadataToLoadFromPosts(state, posts);
if (extra?.userIds.length) {
promises.push(Client4.getProfilesByIds(extra.userIds));
@ -273,7 +332,7 @@ function getPostsAdditionalDataBatch(posts = []) {
const type = promiseTrace[index];
switch (type) {
case 'statuses':
actions.push({
data.push({
type: UserTypes.RECEIVED_STATUSES,
data: p,
});
@ -282,7 +341,7 @@ function getPostsAdditionalDataBatch(posts = []) {
const {currentUserId} = state.entities.users;
removeUserFromList(currentUserId, p);
actions.push({
data.push({
type: UserTypes.RECEIVED_PROFILES_LIST,
data: p,
});
@ -296,42 +355,39 @@ function getPostsAdditionalDataBatch(posts = []) {
// do nothing
}
return actions;
return {data};
};
}
function profilesStatusesAndToLoadFromPosts(posts = []) {
return (dispatch, getState) => {
const state = getState();
const {currentUserId, profiles, statuses} = state.entities.users;
function userMetadataToLoadFromPosts(state, posts = []) {
const {currentUserId, profiles, statuses} = state.entities.users;
// Profiles of users mentioned in the posts
const usernamesToLoad = getNeededAtMentionedUsernames(state, posts);
// Profiles of users mentioned in the posts
const usernamesToLoad = getNeededAtMentionedUsernames(state, posts);
// Statuses and profiles of the users who made the posts
const userIdsToLoad = new Set();
const statusesToLoad = new Set();
// Statuses and profiles of the users who made the posts
const userIdsToLoad = new Set();
const statusesToLoad = new Set();
posts.forEach((post) => {
const userId = post.user_id;
posts.forEach((post) => {
const userId = post.user_id;
if (!statuses[userId]) {
statusesToLoad.add(userId);
}
if (!statuses[userId]) {
statusesToLoad.add(userId);
}
if (userId === currentUserId) {
return;
}
if (userId === currentUserId) {
return;
}
if (!profiles[userId]) {
userIdsToLoad.add(userId);
}
});
if (!profiles[userId]) {
userIdsToLoad.add(userId);
}
});
return {
usernames: Array.from(usernamesToLoad),
userIds: Array.from(userIdsToLoad),
statuses: Array.from(statusesToLoad),
};
return {
usernames: Array.from(usernamesToLoad),
userIds: Array.from(userIdsToLoad),
statuses: Array.from(statusesToLoad),
};
}

View file

@ -116,7 +116,7 @@ export function handleSelectTeamAndChannel(teamId, channelId) {
}
if (actions.length) {
dispatch(batchActions(actions));
dispatch(batchActions(actions, 'BATCH_SELECT_TEAM_AND_CHANNEL'));
}
EphemeralStore.setStartFromNotification(false);

View file

@ -11,7 +11,7 @@ export function handleServerUrlChanged(serverUrl) {
{type: GeneralTypes.CLIENT_CONFIG_RESET},
{type: GeneralTypes.CLIENT_LICENSE_RESET},
{type: ViewTypes.SERVER_URL_CHANGED, serverUrl},
]);
], 'BATCH_SERVER_URL_CHANGED');
}
export function setServerUrl(serverUrl) {

View file

@ -26,7 +26,7 @@ describe('Actions.Views.SelectServer', () => {
{type: GeneralTypes.CLIENT_CONFIG_RESET},
{type: GeneralTypes.CLIENT_LICENSE_RESET},
{type: ViewTypes.SERVER_URL_CHANGED, serverUrl},
]);
], 'BATCH_SERVER_URL_CHANGED');
store.dispatch(handleServerUrlChanged(serverUrl));
expect(store.getActions()).toEqual([actions]);

View file

@ -23,7 +23,7 @@ export function handleTeamChange(teamId) {
dispatch(batchActions([
{type: TeamTypes.SELECT_TEAM, data: teamId},
{type: ChannelTypes.SELECT_CHANNEL, data: '', extra: {}},
]));
], 'BATCH_SWITCH_TEAM'));
};
}

View file

@ -4,10 +4,16 @@
import {ViewTypes} from 'app/constants';
export function handleCommentDraftChanged(rootId, draft) {
return {
type: ViewTypes.COMMENT_DRAFT_CHANGED,
rootId,
draft,
return (dispatch, getState) => {
const state = getState();
if (state.views.thread.drafts[rootId]?.draft !== draft) {
dispatch({
type: ViewTypes.COMMENT_DRAFT_CHANGED,
rootId,
draft,
});
}
};
}

View file

@ -17,7 +17,13 @@ describe('Actions.Views.Thread', () => {
let store;
beforeEach(() => {
store = mockStore({});
store = mockStore({
views: {
thread: {
drafts: {},
},
},
});
});
test('handleCommentDraftChanged', () => {

View file

@ -1,13 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {userTyping as wsUserTyping} from 'mattermost-redux/actions/websocket';
import {userTyping as wsUserTyping} from '@actions/websocket';
export function userTyping(channelId, rootId) {
return async (dispatch, getState) => {
const {websocket} = getState();
const state = getState();
const {websocket} = state;
if (websocket.connected) {
wsUserTyping(channelId, rootId)(dispatch, getState);
wsUserTyping(state, channelId, rootId);
}
};
}

View file

@ -1,20 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GeneralTypes, UserTypes} from 'mattermost-redux/action_types';
import {batchActions} from 'redux-batched-actions';
import {GeneralTypes, RoleTypes, UserTypes} from 'mattermost-redux/action_types';
import {getDataRetentionPolicy} from 'mattermost-redux/actions/general';
import * as HelperActions from 'mattermost-redux/actions/helpers';
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone';
import {Client4} from 'mattermost-redux/client';
import {General} from 'mattermost-redux/constants';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {isTimezoneEnabled} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getCurrentUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
import {setAppCredentials} from 'app/init/credentials';
import {setCSRFFromCookie} from 'app/utils/security';
import {getDeviceTimezoneAsync} from 'app/utils/timezone';
import {setCSRFFromCookie} from '@utils/security';
import {getDeviceTimezoneAsync} from '@utils/timezone';
const HTTP_UNAUTHORIZED = 401;
@ -46,14 +47,42 @@ export function completeLogin(user, deviceToken) {
};
}
export function loadMe(user, deviceToken) {
export function getMe() {
return async (dispatch) => {
try {
const data = {};
data.me = await Client4.getMe();
const actions = [{
type: UserTypes.RECEIVED_ME,
data: data.me,
}];
const roles = data.me.roles.split(' ');
data.roles = await Client4.getRolesByNames(roles);
if (data.roles.length) {
actions.push({
type: RoleTypes.RECEIVED_ROLES,
data: data.roles,
});
}
dispatch(batchActions(actions, 'BATCH_GET_ME'));
return {data};
} catch (error) {
return {error};
}
};
}
export function loadMe(user, deviceToken, skipDispatch = false) {
return async (dispatch, getState) => {
const state = getState();
const data = {user};
const deviceId = state.entities?.general?.deviceToken;
try {
if (deviceId && !deviceToken) {
if (deviceId && !deviceToken && !skipDispatch) {
await Client4.attachDevice(deviceId);
}
@ -75,6 +104,7 @@ export function loadMe(user, deviceToken) {
const teamUnreadRequest = Client4.getMyTeamUnreads();
const preferencesRequest = Client4.getMyPreferences();
const configRequest = Client4.getClientConfigOld();
const actions = [];
const [teams, teamMembers, teamUnreads, preferences, config] = await Promise.all([
teamsRequest,
@ -91,22 +121,33 @@ export function loadMe(user, deviceToken) {
data.config = config;
data.url = Client4.getUrl();
dispatch({
actions.push({
type: UserTypes.LOGIN,
data,
});
const roles = new Set();
const rolesToLoad = new Set();
for (const role of data.user.roles.split(' ')) {
roles.add(role);
rolesToLoad.add(role);
}
for (const teamMember of teamMembers) {
for (const role of teamMember.roles.split(' ')) {
roles.add(role);
rolesToLoad.add(role);
}
}
if (roles.size > 0) {
dispatch(loadRolesIfNeeded(roles));
if (rolesToLoad.size > 0) {
data.roles = await Client4.getRolesByNames(Array.from(rolesToLoad));
if (data.roles.length) {
actions.push({
type: RoleTypes.RECEIVED_ROLES,
data: data.roles,
});
}
}
if (!skipDispatch) {
dispatch(batchActions(actions, 'BATCH_LOAD_ME'));
}
} catch (error) {
console.log('login error', error.stack); // eslint-disable-line no-console
@ -182,15 +223,19 @@ export function forceLogoutIfNecessary(error) {
export function setCurrentUserStatusOffline() {
return (dispatch, getState) => {
const currentUserId = getCurrentUserId(getState());
const state = getState();
const currentUserId = getCurrentUserId(state);
const status = getStatusForUserId(state, currentUserId);
return dispatch({
type: UserTypes.RECEIVED_STATUS,
data: {
user_id: currentUserId,
status: General.OFFLINE,
},
});
if (status !== General.OFFLINE) {
dispatch({
type: UserTypes.RECEIVED_STATUS,
data: {
user_id: currentUserId,
status: General.OFFLINE,
},
});
}
};
}

File diff suppressed because it is too large Load diff

1155
app/actions/websocket.ts Normal file

File diff suppressed because it is too large Load diff

247
app/client/websocket.ts Normal file
View file

@ -0,0 +1,247 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
const MAX_WEBSOCKET_FAILS = 7;
const MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec
const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins
class WebSocketClient {
conn?: WebSocket;
connectionUrl: string;
token: string|null;
sequence: number;
connectFailCount: number;
eventCallback?: Function;
firstConnectCallback?: Function;
reconnectCallback?: Function;
errorCallback?: Function;
closeCallback?: Function;
connectingCallback?: Function;
stop: boolean;
connectionTimeout: any;
constructor() {
this.connectionUrl = '';
this.token = null;
this.sequence = 1;
this.connectFailCount = 0;
this.stop = false;
}
initialize(token: string|null, opts = {}) {
const defaults = {
forceConnection: true,
connectionUrl: this.connectionUrl,
};
const {connectionUrl, forceConnection, ...additionalOptions} = Object.assign({}, defaults, opts);
if (forceConnection) {
this.stop = false;
}
return new Promise((resolve, reject) => {
if (this.conn) {
resolve();
return;
}
if (connectionUrl == null) {
console.log('websocket must have connection url'); //eslint-disable-line no-console
reject(new Error('websocket must have connection url'));
return;
}
if (this.connectFailCount === 0) {
console.log('websocket connecting to ' + connectionUrl); //eslint-disable-line no-console
}
if (this.connectingCallback) {
this.connectingCallback();
}
const regex = /^(?:https?|wss?):(?:\/\/)?[^/]*/;
const captured = (regex).exec(connectionUrl);
let origin;
if (captured) {
origin = captured[0];
if (Platform.OS === 'android') {
// this is done cause for android having the port 80 or 443 will fail the connection
// the websocket will append them
const split = origin.split(':');
const port = split[2];
if (port === '80' || port === '443') {
origin = `${split[0]}:${split[1]}`;
}
}
} else {
// If we're unable to set the origin header, the websocket won't connect, but the URL is likely malformed anyway
const errorMessage = 'websocket failed to parse origin from ' + connectionUrl;
console.warn(errorMessage); // eslint-disable-line no-console
reject(new Error(errorMessage));
return;
}
this.conn = new WebSocket(connectionUrl, [], {headers: {origin}, ...(additionalOptions || {})});
this.connectionUrl = connectionUrl;
this.token = token;
this.conn!.onopen = () => {
if (token) {
// we check for the platform as a workaround until we fix on the server that further authentications
// are ignored
this.sendMessage('authentication_challenge', {token});
}
if (this.connectFailCount > 0) {
console.log('websocket re-established connection'); //eslint-disable-line no-console
if (this.reconnectCallback) {
this.reconnectCallback();
}
} else if (this.firstConnectCallback) {
this.firstConnectCallback();
}
this.connectFailCount = 0;
resolve();
};
this.conn!.onclose = () => {
this.conn = undefined;
this.sequence = 1;
if (this.connectFailCount === 0) {
console.log('websocket closed'); //eslint-disable-line no-console
}
this.connectFailCount++;
if (this.closeCallback) {
this.closeCallback(this.connectFailCount);
}
let retryTime = MIN_WEBSOCKET_RETRY_TIME;
// If we've failed a bunch of connections then start backing off
if (this.connectFailCount > MAX_WEBSOCKET_FAILS) {
retryTime = MIN_WEBSOCKET_RETRY_TIME * this.connectFailCount;
if (retryTime > MAX_WEBSOCKET_RETRY_TIME) {
retryTime = MAX_WEBSOCKET_RETRY_TIME;
}
}
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
}
this.connectionTimeout = setTimeout(
() => {
if (this.stop) {
clearTimeout(this.connectionTimeout);
return;
}
this.initialize(token, opts);
},
retryTime,
);
};
this.conn!.onerror = (evt: any) => {
if (this.connectFailCount <= 1) {
console.log('websocket error'); //eslint-disable-line no-console
console.log(evt); //eslint-disable-line no-console
}
if (this.errorCallback) {
this.errorCallback(evt);
}
};
this.conn!.onmessage = (evt: any) => {
const msg = JSON.parse(evt.data);
if (msg.seq_reply) {
if (msg.error) {
console.warn(msg); //eslint-disable-line no-console
}
} else if (this.eventCallback) {
this.eventCallback(msg);
}
};
});
}
setConnectingCallback(callback: Function) {
this.connectingCallback = callback;
}
setEventCallback(callback: Function) {
this.eventCallback = callback;
}
setFirstConnectCallback(callback: Function) {
this.firstConnectCallback = callback;
}
setReconnectCallback(callback: Function) {
this.reconnectCallback = callback;
}
setErrorCallback(callback: Function) {
this.errorCallback = callback;
}
setCloseCallback(callback: Function) {
this.closeCallback = callback;
}
close(stop = false) {
this.stop = stop;
this.connectFailCount = 0;
this.sequence = 1;
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
this.conn.onclose = () => {}; //eslint-disable-line no-empty-function
this.conn.close();
this.conn = undefined;
console.log('websocket closed'); //eslint-disable-line no-console
}
}
sendMessage(action: string, data: any) {
const msg = {
action,
seq: this.sequence++,
data,
};
if (this.conn && this.conn.readyState === WebSocket.OPEN) {
this.conn.send(JSON.stringify(msg));
} else if (!this.conn || this.conn.readyState === WebSocket.CLOSED) {
this.conn = undefined;
this.initialize(this.token);
}
}
userTyping(channelId: string, parentId: string) {
this.sendMessage('user_typing', {
channel_id: channelId,
parent_id: parentId,
});
}
getStatuses() {
this.sendMessage('get_statuses', null);
}
getStatusesByIds(userIds: string[]) {
this.sendMessage('get_statuses_by_ids', {
user_ids: userIds,
});
}
}
export default new WebSocketClient();

View file

@ -9,7 +9,7 @@ import Preferences from 'mattermost-redux/constants/preferences';
import ChannelLoader from './channel_loader';
jest.mock('rn-placeholder', () => ({
ImageContent: () => {},
ImageContent: () => null,
}));
describe('ChannelLoader', () => {

View file

@ -92,7 +92,7 @@ class FormattedMarkdownText extends React.PureComponent {
}
renderLink = ({children, href}) => {
var url = href[0] === TARGET_BLANK_URL_PREFIX ? href.substring(1, href.length) : href;
const url = href[0] === TARGET_BLANK_URL_PREFIX ? href.substring(1, href.length) : href;
return <MarkdownLink href={url}>{children}</MarkdownLink>;
}

View file

@ -5,7 +5,7 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {stopPeriodicStatusUpdates, startPeriodicStatusUpdates} from 'mattermost-redux/actions/users';
import {init as initWebSocket, close as closeWebSocket} from 'mattermost-redux/actions/websocket';
import {init as initWebSocket, close as closeWebSocket} from '@actions/websocket';
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {connection} from 'app/actions/device';

View file

@ -169,7 +169,6 @@ export default class NetworkIndicator extends PureComponent {
};
connected = () => {
this.props.actions.setChannelRetryFailed(false);
Animated.sequence([
Animated.timing(
this.backgroundColor, {
@ -302,7 +301,7 @@ export default class NetworkIndicator extends PureComponent {
certificate = await mattermostBucket.getPreference('cert');
}
initWebSocket(platform, null, null, null, {certificate, forceConnection: true}).catch(() => {
initWebSocket({certificate, forceConnection: true}).catch(() => {
// we should dispatch a failure and show the app as disconnected
Alert.alert(
formatMessage({id: 'mobile.authentication_error.title', defaultMessage: 'Authentication Error'}),

View file

@ -2,7 +2,6 @@
// Original work: https://github.com/react-native-community/react-native-drawer-layout .
// Modified work: Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
// @flow
import React, { Component } from 'react';
import {
@ -17,6 +16,7 @@ import {
View,
I18nManager,
} from 'react-native';
import PropTypes from 'prop-types';
import telemetry from 'app/telemetry';
@ -31,62 +31,31 @@ const emptyObject = {};
export const DRAWER_INITIAL_OFFSET = 40;
export const TABLET_WIDTH = 250;
export type PropType = {
children: any,
drawerBackgroundColor?: string,
drawerLockMode?: 'unlocked' | 'locked-closed' | 'locked-open',
drawerPosition: 'left' | 'right',
drawerWidth: number,
keyboardDismissMode?: 'none' | 'on-drag',
onDrawerClose?: Function,
onDrawerOpen?: Function,
onDrawerSlide?: Function,
onDrawerStateChanged?: Function,
renderNavigationView: () => any,
statusBarBackgroundColor?: string,
useNativeAnimations?: boolean,
isTablet?: boolean,
};
export type StateType = {
accessibilityViewIsModal: boolean,
deviceWidth: number,
drawerShown: boolean,
openValue: any,
threshold: number,
};
export type EventType = {
stopPropagation: Function,
};
export type PanResponderEventType = {
dx: number,
dy: number,
moveX: number,
moveY: number,
vx: number,
vy: number,
};
export type DrawerMovementOptionType = {
velocity?: number,
};
export default class DrawerLayout extends Component {
props: PropType;
state: StateType;
_lastOpenValue: number;
_panResponder: any;
_isClosing: boolean;
_closingAnchorValue: number;
canClose: boolean;
static propTypes = {
children: PropTypes.any,
drawerBackgroundColor: PropTypes.string,
drawerLockMode: PropTypes.oneOf(['unlocked', 'locked-closed', 'locked-open']),
drawerPosition: PropTypes.oneOf(['left', 'right']),
drawerWidth: PropTypes.number,
keyboardDismissMode: PropTypes.oneOf(['none', 'on-drag']),
onDrawerClose: PropTypes.func,
onDrawerOpen: PropTypes.func,
onDrawerSlide: PropTypes.func,
onDrawerStateChanged: PropTypes.func,
renderNavigationView: PropTypes.func,
statusBarBackgroundColor: PropTypes.string,
useNativeAnimations: PropTypes.bool,
isTablet: PropTypes.bool,
}
static defaultProps = {
drawerWidth: 0,
drawerPosition: 'left',
useNativeAnimations: false,
isTablet: false,
renderNavigationView: () => true,
};
static positions = {
@ -94,7 +63,7 @@ export default class DrawerLayout extends Component {
Right: 'right',
};
constructor(props: PropType, context: any) {
constructor(props, context) {
super(props, context);
this._panResponder = PanResponder.create({
@ -316,20 +285,20 @@ export default class DrawerLayout extends Component {
);
}
_onOverlayClick = (e: EventType) => {
_onOverlayClick = (e) => {
e.stopPropagation();
if (!this._isLockedClosed() && !this._isLockedOpen()) {
this.closeDrawer();
}
};
_emitStateChanged = (newState: string) => {
_emitStateChanged = (newState) => {
if (this.props.onDrawerStateChanged) {
this.props.onDrawerStateChanged(newState);
}
};
openDrawer = (options: DrawerMovementOptionType = {}) => {
openDrawer = (options = {}) => {
if (!this.props.isTablet) {
this._emitStateChanged(SETTLING);
Animated.timing(this.openValue, {
@ -351,7 +320,7 @@ export default class DrawerLayout extends Component {
}
};
closeDrawer = (options: DrawerMovementOptionType = {}) => {
closeDrawer = (options = {}) => {
this._emitStateChanged(SETTLING);
Animated.timing(this.openValue, {
toValue: 0,
@ -383,10 +352,7 @@ export default class DrawerLayout extends Component {
}
};
_shouldSetPanResponder = (
e: EventType,
{ moveX, dx, dy }: PanResponderEventType,
) => {
_shouldSetPanResponder = (e, { moveX, dx, dy }) => {
if (!dx || !dy || Math.abs(dx) < MIN_SWIPE_DISTANCE) {
return false;
}
@ -443,7 +409,7 @@ export default class DrawerLayout extends Component {
this._emitStateChanged(DRAGGING);
};
_panResponderMove = (e: EventType, { moveX, dx }: PanResponderEventType) => {
_panResponderMove = (e, { moveX, dx }) => {
const useDx = this.getDrawerPosition() === 'left' && !this._isClosing;
let openValue = this._getOpenValueForX(useDx ? dx : moveX);
@ -460,10 +426,7 @@ export default class DrawerLayout extends Component {
this.openValue.setValue(openValue);
};
_panResponderRelease = (
e: EventType,
{ moveX, vx }: PanResponderEventType,
) => {
_panResponderRelease = (e, { moveX, vx }) => {
const {threshold} = this.state;
const previouslyOpen = this._isClosing;
const isWithinVelocityThreshold = vx < VX_MAX && vx > -VX_MAX;
@ -521,7 +484,7 @@ export default class DrawerLayout extends Component {
this.state.drawerShown;
};
_getOpenValueForX(x: number): number {
_getOpenValueForX(x) {
const { drawerWidth } = this.props;
const { deviceWidth } = this.state;

View file

@ -31,7 +31,7 @@ describe('ChannelItem', () => {
isUnread: true,
hasDraft: false,
mentions: 0,
onSelectChannel: () => {}, // eslint-disable-line no-empty-function
onSelectChannel: () => true,
shouldHideChannel: false,
showUnreadForMsgs: true,
theme: Preferences.THEMES.default,

View file

@ -10,11 +10,11 @@ import {
View,
} from 'react-native';
import {General, WebsocketEvents} from 'mattermost-redux/constants';
import {General} from 'mattermost-redux/constants';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import SafeAreaView from 'app/components/safe_area_view';
import {NavigationTypes} from 'app/constants';
import {NavigationTypes, WebsocketEvents} from 'app/constants';
import tracker from 'app/utils/time_tracker';
import {t} from 'app/utils/i18n';

View file

@ -79,9 +79,9 @@ export default class TeamsList extends PureComponent {
tracker.teamSwitch = Date.now();
actions.handleTeamChange(teamId);
}
closeMainSidebar();
});
closeMainSidebar();
};
goToSelectTeam = preventDoubleTap(async () => {

View file

@ -7,6 +7,7 @@ import ListTypes from './list';
import NavigationTypes from './navigation';
import Types from './types';
import ViewTypes, {UpgradeTypes} from './view';
import WebsocketEvents from './websocket';
export {
DeepLinkTypes,
@ -16,4 +17,5 @@ export {
UpgradeTypes,
Types,
ViewTypes,
WebsocketEvents,
};

View file

@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const WebsocketEvents = {
POSTED: 'posted',
POST_EDITED: 'post_edited',
POST_DELETED: 'post_deleted',
POST_UNREAD: 'post_unread',
CHANNEL_CONVERTED: 'channel_converted',
CHANNEL_CREATED: 'channel_created',
CHANNEL_DELETED: 'channel_deleted',
CHANNEL_UNARCHIVED: 'channel_restored',
CHANNEL_UPDATED: 'channel_updated',
CHANNEL_VIEWED: 'channel_viewed',
CHANNEL_MEMBER_UPDATED: 'channel_member_updated',
CHANNEL_SCHEME_UPDATED: 'channel_scheme_updated',
DIRECT_ADDED: 'direct_added',
ADDED_TO_TEAM: 'added_to_team',
LEAVE_TEAM: 'leave_team',
UPDATE_TEAM: 'update_team',
USER_ADDED: 'user_added',
USER_REMOVED: 'user_removed',
USER_UPDATED: 'user_updated',
USER_ROLE_UPDATED: 'user_role_updated',
ROLE_ADDED: 'role_added',
ROLE_REMOVED: 'role_removed',
ROLE_UPDATED: 'role_updated',
TYPING: 'typing',
STOP_TYPING: 'stop_typing',
PREFERENCE_CHANGED: 'preference_changed',
PREFERENCES_CHANGED: 'preferences_changed',
PREFERENCES_DELETED: 'preferences_deleted',
EPHEMERAL_MESSAGE: 'ephemeral_message',
STATUS_CHANGED: 'status_change',
HELLO: 'hello',
WEBRTC: 'webrtc',
REACTION_ADDED: 'reaction_added',
REACTION_REMOVED: 'reaction_removed',
EMOJI_ADDED: 'emoji_added',
LICENSE_CHANGED: 'license_changed',
CONFIG_CHANGED: 'config_changed',
PLUGIN_STATUSES_CHANGED: 'plugin_statuses_changed',
OPEN_DIALOG: 'open_dialog',
INCREASE_POST_VISIBILITY_BY_ONE: 'increase_post_visibility_by_one',
};
export default WebsocketEvents;

View file

@ -12,6 +12,10 @@ import {
} from 'app/init/fetch';
describe('Fetch', () => {
beforeAll(() => {
global.fetch = jest.fn(() => Promise.resolve());
});
test('doFetchWithResponse handles empty headers', async () => {
const setToken = jest.spyOn(Client4, 'setToken');
const response = {

View file

@ -11,7 +11,7 @@ import semver from 'semver/preload';
import {setAppState, setServerVersion} from 'mattermost-redux/actions/general';
import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone';
import {close as closeWebSocket} from 'mattermost-redux/actions/websocket';
import {close as closeWebSocket} from '@actions/websocket';
import {GeneralTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import {General} from 'mattermost-redux/constants';
@ -141,7 +141,9 @@ class GlobalEventHandler {
onDeepLink = (event) => {
const {url} = event;
this.store.dispatch(setDeepLinkURL(url));
if (url) {
this.store.dispatch(setDeepLinkURL(url));
}
};
onManagedConfigurationChange = () => {
@ -288,7 +290,7 @@ class GlobalEventHandler {
type: GeneralTypes.RECEIVED_APP_DEVICE_TOKEN,
data: state.entities.general.deviceToken,
},
]));
], 'BATCH_RESET_STATE'));
} catch (e) {
// clear error
}

View file

@ -155,6 +155,11 @@ const state = {
drafts: {},
},
},
websocket: {
connected: false,
lastConnectAt: 0,
lastDisconnectAt: 0,
},
};
export default state;

View file

@ -63,7 +63,9 @@ const launchApp = (credentials) => {
EphemeralStore.appStarted = true;
Linking.getInitialURL().then((url) => {
store.dispatch(setDeepLinkURL(url));
if (url) {
store.dispatch(setDeepLinkURL(url));
}
});
};

View file

@ -18,10 +18,10 @@ describe('ErrorTeamsList', () => {
const baseProps = {
actions: {
loadMe: () => {}, // eslint-disable-line no-empty-function
connection: () => {}, // eslint-disable-line no-empty-function
logout: () => {}, // eslint-disable-line no-empty-function
selectDefaultTeam: () => {}, // eslint-disable-line no-empty-function
loadMe: () => true,
connection: () => true,
logout: () => true,
selectDefaultTeam: () => true,
},
componentId: 'component-id',
theme: Preferences.THEMES.default,

View file

@ -11,7 +11,7 @@ import {shallowWithIntl} from 'test/intl-test-helper';
import FlaggedPosts from './flagged_posts';
jest.mock('rn-placeholder', () => ({
ImageContent: () => {},
ImageContent: () => null,
}));
describe('FlaggedPosts', () => {

View file

@ -10,7 +10,7 @@ import {shallowWithIntl} from 'test/intl-test-helper';
import PinnedPosts from './pinned_posts';
jest.mock('rn-placeholder', () => ({
ImageContent: () => {},
ImageContent: () => null,
}));
describe('PinnedPosts', () => {

View file

@ -11,7 +11,7 @@ import {shallowWithIntl} from 'test/intl-test-helper';
import RecentMentions from './recent_mentions';
jest.mock('rn-placeholder', () => ({
ImageContent: () => {},
ImageContent: () => null,
}));
describe('RecentMentions', () => {

View file

@ -217,7 +217,7 @@ export default class UserProfile extends PureComponent {
const email = this.props.user.email;
return () => {
var hydrated = link.replace(/{email}/, email);
let hydrated = link.replace(/{email}/, email);
hydrated = hydrated.replace(/{username}/, username);
Linking.openURL(hydrated);
};
@ -244,7 +244,7 @@ export default class UserProfile extends PureComponent {
const profileLinks = Config.ExperimentalProfileLinks;
const additionalOptions = profileLinks.map((l) => {
var action;
let action;
if (l.type === 'link') {
action = this.handleLinkPress(l.url);
}

View file

@ -44,6 +44,15 @@ export function messageRetention(store) {
return next(nextAction);
}
/* Uncomment the following lines to log the actions being dispatched */
// if (action.type === 'BATCHING_REDUCER.BATCH') {
// action.payload.forEach((p) => {
// console.log('BATCHED ACTIONS', p.type);
// });
// } else {
// console.log('ACTION', action.type);
// }
return next(action);
};
}

View file

@ -20,17 +20,6 @@ export function isFavoriteChannel(preferences, channelId) {
return fav ? fav.value === 'true' : false;
}
// New to replace the ones above
export function isDirectMessageVisible(preferences, channelId) {
const dm = preferences[`${Preferences.CATEGORY_DIRECT_CHANNEL_SHOW}--${channelId}`];
return dm ? dm.value === 'true' : true;
}
export function isGroupMessageVisible(preferences, channelId) {
const gm = preferences[`${Preferences.CATEGORY_GROUP_CHANNEL_SHOW}--${channelId}`];
return gm ? gm.value === 'true' : true;
}
export function isDirectChannelAutoClosed(config, preferences, channelId, channelActivity, channelArchiveTime = 0, currentChannelId = '') {
// When the config is not set or is a favorite channel
if (config.CloseUnusedDirectMessages !== 'true' || isFavoriteChannel(preferences, channelId)) {

View file

@ -11,7 +11,7 @@ import {
import {Client4} from 'mattermost-redux/client';
import {logError} from 'mattermost-redux/actions/errors';
import {close as closeWebSocket} from 'mattermost-redux/actions/websocket';
import {close as closeWebSocket} from '@actions/websocket';
import {purgeOfflineStore} from 'app/actions/views/root';
import {DEFAULT_LOCALE, getTranslations} from 'app/i18n';

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
// Credit to http://semplicewebsites.com/removing-accents-javascript
var latinMap = {
const latinMap = {
Á: 'A', // LATIN CAPITAL LETTER A WITH ACUTE
Ă: 'A', // LATIN CAPITAL LETTER A WITH BREVE
: 'A', // LATIN CAPITAL LETTER A WITH BREVE AND ACUTE

View file

@ -84,7 +84,7 @@ export function getShortenedURL(url = '', getLength = 27) {
}
export function cleanUpUrlable(input) {
var cleaned = latinise(input);
let cleaned = latinise(input);
cleaned = cleaned.trim().replace(/-/g, ' ').replace(/[^\w\s]/gi, '').toLowerCase().replace(/\s/g, '-');
cleaned = cleaned.replace(/-{2,}/, '-');
cleaned = cleaned.replace(/^-+/, '');

View file

@ -4,8 +4,8 @@
export function isInRole(roles, inRole) {
if (roles) {
var parts = roles.split(' ');
for (var i = 0; i < parts.length; i++) {
const parts = roles.split(' ');
for (let i = 0; i < parts.length; i++) {
if (parts[i] === inRole) {
return true;
}

View file

@ -13,6 +13,12 @@ module.exports = {
root: ['.'],
alias: {
assets: './dist/assets',
'@actions': './app/actions',
'@constants': './app/constants',
'@selectors': './app/selectors',
'@telemetry': './app/telemetry',
'@utils': './app/utils',
'@websocket': './app/client/websocket',
},
}],
],

33
jest.config.js Normal file
View file

@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
module.exports = {
preset: '@testing-library/react-native',
verbose: true,
globals: {
'ts-jest': {
tsConfigFile: 'tsconfig.jest.json',
},
},
clearMocks: true,
setupFilesAfterEnv: [
'<rootDir>/test/setup.js',
'<rootDir>/node_modules/jest-enzyme/lib/index.js',
],
collectCoverageFrom: [
'app/**/*.{js,jsx,ts,tsx}',
],
coverageReporters: [
'lcov',
'text-summary',
],
testPathIgnorePatterns: [
'/node_modules/',
],
moduleNameMapper: {
'assets/images/video_player/(.*).png': '<rootDir>/dist/assets/images/video_player/$1@2x.png',
},
transformIgnorePatterns: [
'node_modules/(?!react-native|jail-monkey|@sentry/react-native|react-navigation|@react-native-community/cameraroll)',
],
};

3755
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -90,6 +90,14 @@
"@babel/preset-env": "7.8.7",
"@babel/register": "7.8.6",
"@testing-library/react-native": "5.0.3",
"@types/enzyme": "3.10.5",
"@types/enzyme-adapter-react-16": "1.0.6",
"@types/jest": "25.1.4",
"@types/react": "16.9.23",
"@types/react-native": "0.61.23",
"@types/react-test-renderer": "16.9.2",
"@typescript-eslint/eslint-plugin": "2.25.0",
"@typescript-eslint/parser": "2.25.0",
"babel-eslint": "10.1.0",
"babel-jest": "25.1.0",
"babel-plugin-module-resolver": "4.0.0",
@ -104,6 +112,7 @@
"eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#070ce792d105482ffb2b27cfc0b7e78b3d20acee",
"eslint-plugin-react": "7.19.0",
"harmony-reflect": "1.6.1",
"isomorphic-fetch": "2.2.1",
"jest": "25.1.0",
"jest-cli": "25.1.0",
"jest-enzyme": "7.1.2",
@ -112,49 +121,31 @@
"metro-react-native-babel-preset": "0.58.0",
"mmjstool": "github:mattermost/mattermost-utilities#086f4ffdca4e31a0be22f6bcdfa093ed83fb29e",
"mock-async-storage": "2.2.0",
"mock-socket": "9.0.3",
"nock": "12.0.3",
"nyc": "15.0.0",
"patch-package": "6.2.1",
"react-dom": "16.13.0",
"react-test-renderer": "16.13.0",
"redux-mock-store": "1.5.4",
"redux-persist-node-storage": "2.0.0",
"remote-redux-devtools": "0.5.16",
"socketcluster": "16.0.1",
"ts-jest": "25.2.1",
"typescript": "3.8.3",
"underscore": "1.9.2",
"util": "0.12.2"
},
"scripts": {
"start": "node ./node_modules/react-native/local-cli/cli.js start",
"check": "eslint --ignore-path .gitignore --ignore-pattern node_modules --quiet .",
"check": "eslint --ignore-path .gitignore --ignore-pattern node_modules --quiet . && npm run tsc",
"fix": "eslint --ignore-path .gitignore --ignore-pattern node_modules --quiet . --fix",
"postinstall": "make post-install",
"tsc": "tsc --noEmit --skipLibCheck",
"test": "jest --forceExit --detectOpenHandles",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"updatesnapshot": "jest --updateSnapshot",
"mmjstool": "mmjstool"
},
"jest": {
"clearMocks": true,
"collectCoverageFrom": [
"app/**/*.{js,jsx}"
],
"coverageReporters": [
"lcov",
"text-summary"
],
"preset": "@testing-library/react-native",
"setupFilesAfterEnv": [
"<rootDir>/test/setup.js",
"<rootDir>/node_modules/jest-enzyme/lib/index.js"
],
"testPathIgnorePatterns": [
"/node_modules/"
],
"moduleNameMapper": {
"assets/images/video_player/(.*).png": "<rootDir>/dist/assets/images/video_player/$1@2x.png"
},
"transformIgnorePatterns": [
"node_modules/(?!react-native|jail-monkey|@sentry/react-native|react-navigation|@react-native-community/cameraroll)"
]
}
}

View file

@ -426,11 +426,9 @@ module.exports = [
'node_modules/mattermost-redux/actions/teams.js',
'node_modules/mattermost-redux/actions/timezone.js',
'node_modules/mattermost-redux/actions/users.js',
'node_modules/mattermost-redux/actions/websocket.js',
'node_modules/mattermost-redux/client/client4.js',
'node_modules/mattermost-redux/client/fetch_etag.js',
'node_modules/mattermost-redux/client/index.js',
'node_modules/mattermost-redux/client/websocket_client.js',
'node_modules/mattermost-redux/constants/alerts.js',
'node_modules/mattermost-redux/constants/emoji.js',
'node_modules/mattermost-redux/constants/files.js',

View file

@ -415,11 +415,9 @@ module.exports = [
'./node_modules/node_modules/mattermost-redux/actions/teams.js',
'./node_modules/node_modules/mattermost-redux/actions/timezone.js',
'./node_modules/node_modules/mattermost-redux/actions/users.js',
'./node_modules/node_modules/mattermost-redux/actions/websocket.js',
'./node_modules/node_modules/mattermost-redux/client/client4.js',
'./node_modules/node_modules/mattermost-redux/client/fetch_etag.js',
'./node_modules/node_modules/mattermost-redux/client/index.js',
'./node_modules/node_modules/mattermost-redux/client/websocket_client.js',
'./node_modules/node_modules/mattermost-redux/constants/alerts.js',
'./node_modules/node_modules/mattermost-redux/constants/emoji.js',
'./node_modules/node_modules/mattermost-redux/constants/files.js',

View file

@ -3,14 +3,14 @@
/* eslint-disable no-console */
var fs = require('fs');
const fs = require('fs');
// Takes the files in rootA/path, overwrites or merges them with the corresponding file in rootB/path, and places the
// resulting file in dest/path. JSON files that exist in both places are shallowly (TODO maybe deeply) merged and all
// other types of files are overwritten.
function leftMergeDirs(rootA, rootB, dest, path) {
var pathA = rootA + path;
var pathB = rootB + path;
const pathA = rootA + path;
const pathB = rootB + path;
try {
fs.mkdirSync(dest + path);
@ -22,11 +22,11 @@ function leftMergeDirs(rootA, rootB, dest, path) {
}
}
for (var file of fs.readdirSync(pathA)) {
var filePathA = pathA + file;
var filePathB = pathB + file;
for (const file of fs.readdirSync(pathA)) {
const filePathA = pathA + file;
const filePathB = pathB + file;
var stat;
let stat;
try {
stat = fs.statSync(filePathA);
} catch (e) {
@ -37,7 +37,7 @@ function leftMergeDirs(rootA, rootB, dest, path) {
if (stat.isDirectory()) {
leftMergeDirs(rootA, rootB, dest, path + file + '/');
} else {
var fileA;
let fileA;
try {
fileA = fs.readFileSync(filePathA);
} catch (e) {
@ -45,8 +45,8 @@ function leftMergeDirs(rootA, rootB, dest, path) {
throw e;
}
var outPath = dest + path + file;
var out;
const outPath = dest + path + file;
let out;
try {
out = fs.createWriteStream(outPath);
} catch (e) {
@ -54,7 +54,7 @@ function leftMergeDirs(rootA, rootB, dest, path) {
throw e;
}
var fileB = null;
let fileB = null;
try {
fileB = fs.readFileSync(filePathB);
} catch (e) {
@ -64,8 +64,8 @@ function leftMergeDirs(rootA, rootB, dest, path) {
if (fileB) {
if (file.endsWith('.json')) {
// We need to merge these files
var objA = JSON.parse(fileA);
var objB = JSON.parse(fileB);
const objA = JSON.parse(fileA);
const objB = JSON.parse(fileB);
console.log('Merging ' + filePathA + ' with ' + filePathB + ' into ' + outPath);
out.write(JSON.stringify(Object.assign({}, objA, objB)));

View file

@ -5,13 +5,12 @@ import {fetchMyChannelsAndMembers} from 'mattermost-redux/actions/channels';
import {getRedirectChannelNameForTeam, getChannelsNameMapInTeam} from 'mattermost-redux/selectors/entities/channels';
import {getChannelByName} from 'mattermost-redux/utils/channel_utils';
import {loadProfilesAndTeamMembersForDMSidebar} from 'app/actions/views/channel';
import {loadChannelsForTeam} from 'app/actions/views/channel';
import {ViewTypes} from 'app/constants';
export function getTeamChannels(teamId) {
return async (dispatch, getState) => {
await dispatch(fetchMyChannelsAndMembers(teamId));
dispatch(loadProfilesAndTeamMembersForDMSidebar(teamId));
await dispatch(loadChannelsForTeam(teamId));
const state = getState();
const channelsInTeam = getChannelsNameMapInTeam(state, teamId);

View file

@ -10,7 +10,7 @@ import ExtensionPost from './extension_post';
jest.spyOn(Alert, 'alert').mockReturnValue(true);
jest.spyOn(PermissionsAndroid, 'check').mockReturnValue(PermissionsAndroid.RESULTS.GRANTED);
jest.mock('react-navigation-stack/lib/module/views/TouchableItem', () => {});
jest.mock('react-navigation-stack/lib/module/views/TouchableItem', () => null);
jest.mock('app/mattermost_managed', () => ({
getConfig: jest.fn().mockReturnValue(false),

View file

@ -5,12 +5,12 @@ import * as ReactNative from 'react-native';
import MockAsyncStorage from 'mock-async-storage';
import {configure} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
configure({adapter: new Adapter()});
const mockImpl = new MockAsyncStorage();
jest.mock('@react-native-community/async-storage', () => mockImpl);
global.window = {};
global.fetch = jest.fn(() => Promise.resolve());
/* eslint-disable no-console */
@ -165,9 +165,9 @@ jest.mock('app/actions/navigation', () => ({
dismissOverlay: jest.fn(() => Promise.resolve()),
}));
let logs;
let warns;
let errors;
let logs = [];
let warns = [];
let errors = [];
beforeAll(() => {
console.originalLog = console.log;
console.log = jest.fn((...params) => {

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import assert from 'assert';
import nock from 'nock';
import Config from 'assets/config.json';
@ -19,11 +20,139 @@ class TestHelper {
this.basicPost = null;
}
activateMocking() {
if (!nock.isActive()) {
nock.activate();
}
}
assertStatusOkay = (data) => {
assert(data);
assert(data.status === 'OK');
};
createClient = () => {
const client = new Client();
client.setUrl(Config.DefaultServerUrl);
return client;
};
fakeChannel = (teamId) => {
const name = this.generateId();
return {
name,
team_id: teamId,
display_name: `Unit Test ${name}`,
type: 'O',
};
};
fakeChannelWithId = (teamId) => {
return {
...this.fakeChannel(teamId),
id: this.generateId(),
create_at: 1507840900004,
update_at: 1507840900004,
delete_at: 0,
};
};
fakeChannelMember = (userId, channelId) => {
return {
user_id: userId,
channel_id: channelId,
notify_props: {},
roles: 'system_user',
};
};
fakeEmail = () => {
return 'success' + this.generateId() + '@simulator.amazonses.com';
};
fakePost = (channelId) => {
const time = Date.now();
return {
id: this.generateId(),
channel_id: channelId,
create_at: time,
update_at: time,
message: `Unit Test ${this.generateId()}`,
};
};
fakePostWithId = (channelId) => {
return {
...this.fakePost(channelId),
id: this.generateId(),
create_at: 1507840900004,
update_at: 1507840900004,
delete_at: 0,
};
};
fakeTeam = () => {
const name = this.generateId();
let inviteId = this.generateId();
if (inviteId.length > 32) {
inviteId = inviteId.substring(0, 32);
}
return {
name,
display_name: `Unit Test ${name}`,
type: 'O',
email: this.fakeEmail(),
allowed_domains: '',
invite_id: inviteId,
};
};
fakeTeamWithId = () => {
return {
...this.fakeTeam(),
id: this.generateId(),
create_at: 1507840900004,
update_at: 1507840900004,
delete_at: 0,
};
};
fakeTeamMember = (userId, teamId) => {
return {
user_id: userId,
team_id: teamId,
roles: 'team_user',
delete_at: 0,
scheme_user: false,
scheme_admin: false,
};
};
fakeUser = () => {
return {
email: this.fakeEmail(),
allow_marketing: true,
password: PASSWORD,
username: this.generateId(),
roles: 'system_user',
};
};
fakeUserWithId = (id = this.generateId()) => {
return {
...this.fakeUser(),
id,
create_at: 1507840900004,
update_at: 1507840900004,
delete_at: 0,
};
};
generateId = () => {
// Implementation taken from http://stackoverflow.com/a/2117523
let id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx';
@ -44,87 +173,91 @@ class TestHelper {
return 'uid' + id;
};
createClient = () => {
const client = new Client();
client.setUrl(Config.DefaultServerUrl);
return client;
};
fakeEmail = () => {
return 'success' + this.generateId() + '@simulator.amazonses.com';
};
fakeUser = () => {
return {
email: this.fakeEmail(),
allow_marketing: true,
password: PASSWORD,
username: this.generateId(),
initMockEntities = () => {
this.basicUser = this.fakeUserWithId();
this.basicUser.roles = 'system_user system_admin';
this.basicTeam = this.fakeTeamWithId();
this.basicTeamMember = this.fakeTeamMember(this.basicUser.id, this.basicTeam.id);
this.basicChannel = this.fakeChannelWithId(this.basicTeam.id);
this.basicChannelMember = this.fakeChannelMember(this.basicUser.id, this.basicChannel.id);
this.basicPost = {...this.fakePostWithId(this.basicChannel.id), create_at: 1507841118796};
this.basicRoles = {
system_admin: {
id: this.generateId(),
name: 'system_admin',
display_name: 'authentication.roles.global_admin.name',
description: 'authentication.roles.global_admin.description',
permissions: [
'system_admin_permission',
],
scheme_managed: true,
built_in: true,
},
system_user: {
id: this.generateId(),
name: 'system_user',
display_name: 'authentication.roles.global_user.name',
description: 'authentication.roles.global_user.description',
permissions: [
'system_user_permission',
],
scheme_managed: true,
built_in: true,
},
team_admin: {
id: this.generateId(),
name: 'team_admin',
display_name: 'authentication.roles.team_admin.name',
description: 'authentication.roles.team_admin.description',
permissions: [
'team_admin_permission',
],
scheme_managed: true,
built_in: true,
},
team_user: {
id: this.generateId(),
name: 'team_user',
display_name: 'authentication.roles.team_user.name',
description: 'authentication.roles.team_user.description',
permissions: [
'team_user_permission',
],
scheme_managed: true,
built_in: true,
},
channel_admin: {
id: this.generateId(),
name: 'channel_admin',
display_name: 'authentication.roles.channel_admin.name',
description: 'authentication.roles.channel_admin.description',
permissions: [
'channel_admin_permission',
],
scheme_managed: true,
built_in: true,
},
channel_user: {
id: this.generateId(),
name: 'channel_user',
display_name: 'authentication.roles.channel_user.name',
description: 'authentication.roles.channel_user.description',
permissions: [
'channel_user_permission',
],
scheme_managed: true,
built_in: true,
},
};
};
fakeTeam = () => {
const name = this.generateId();
let inviteId = this.generateId();
if (inviteId.length > 32) {
inviteId = inviteId.substring(0, 32);
}
return {
name,
display_name: `Unit Test ${name}`,
type: 'O',
email: this.fakeEmail(),
allowed_domains: '',
invite_id: inviteId,
};
};
fakeChannel = (teamId) => {
const name = this.generateId();
return {
name,
team_id: teamId,
display_name: `Unit Test ${name}`,
type: 'O',
};
};
fakeChannelMember = (userId, channelId) => {
return {
user_id: userId,
channel_id: channelId,
notify_props: {},
roles: 'system_user',
};
};
fakePost = (channelId) => {
const time = Date.now();
return {
id: this.generateId(),
channel_id: channelId,
create_at: time,
update_at: time,
message: `Unit Test ${this.generateId()}`,
};
};
this.basicScheme = this.mockSchemeWithId();
}
initBasic = async (client = this.createClient()) => {
client.setUrl(Config.TestServerUrl || Config.DefaultServerUrl);
this.basicClient = client;
this.basicUser = await client.createUser(this.fakeUser());
await client.login(this.basicUser.email, PASSWORD);
this.basicTeam = await client.createTeam(this.fakeTeam());
this.basicChannel = await client.createChannel(this.fakeChannel(this.basicTeam.id));
this.basicPost = await client.createPost(this.basicTeam.id, this.fakePost(this.basicChannel.id));
this.initMockEntities();
this.activateMocking();
return {
client: this.basicClient,
@ -135,11 +268,39 @@ class TestHelper {
};
};
wait = () => {
return new Promise((resolve) => {
setTimeout(() => resolve(), 1000);
});
mockScheme = () => {
return {
name: this.generateId(),
description: this.generateId(),
scope: 'channel',
defaultchanneladminrole: false,
defaultchanneluserrole: false,
};
};
mockSchemeWithId = () => {
return {
...this.mockScheme(),
id: this.generateId(),
create_at: 1507840900004,
update_at: 1507840900004,
delete_at: 0,
};
};
tearDown = async () => {
nock.restore();
this.basicClient4 = null;
this.basicUser = null;
this.basicTeam = null;
this.basicTeamMember = null;
this.basicChannel = null;
this.basicChannelMember = null;
this.basicPost = null;
}
wait = (time) => new Promise((resolve) => setTimeout(resolve, time))
}
export default new TestHelper();

55
test/test_store.js Normal file
View file

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AsyncNodeStorage} from 'redux-persist-node-storage';
import {createTransform, persistStore} from 'redux-persist';
import configureStore from 'mattermost-redux/store';
export default async function testConfigureStore(preloadedState) {
const storageTransform = createTransform(
() => ({}),
() => ({}),
);
const offlineConfig = {
detectNetwork: (callback) => callback(true),
persist: (store, options) => {
return persistStore(store, {storage: new AsyncNodeStorage('./.tmp'), ...options});
},
persistOptions: {
debounce: 1000,
transforms: [
storageTransform,
],
whitelist: [],
},
retry: (action, retries) => 200 * (retries + 1),
discard: (error, action, retries) => {
if (action.meta && action.meta.offline.hasOwnProperty('maxRetry')) {
return retries >= action.meta.offline.maxRetry;
}
return retries >= 1;
},
};
const store = configureStore(preloadedState, {}, offlineConfig, () => ({}), {enableBuffer: false});
const wait = () => new Promise((resolve) => setTimeout(resolve), 300); //eslint-disable-line
await wait();
return store;
}
// This should probably be replaced by redux-mock-store like the web app
export function mockDispatch(dispatch) {
const mocked = (action) => {
dispatch(action);
mocked.actions.push(action);
};
mocked.actions = [];
return mocked;
}

50
tsconfig.json Normal file
View file

@ -0,0 +1,50 @@
{
"compilerOptions": {
"target": "esnext",
"lib": [
"es6"
],
"allowJs": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"isolatedModules": true,
"jsx": "react",
"moduleResolution": "node",
"noEmit": false,
"strict": false,
"importHelpers": true,
"declaration": true,
"sourceMap": false,
"rootDir": "./",
"outDir": "./",
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": false,
"strictPropertyInitialization": true,
"noImplicitThis": false,
"alwaysStrict": true,
"noUnusedLocals": false,
"downlevelIteration": true,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@actions/*": ["app/actions/*"],
"@constants/*": ["app/constants/*"],
"@constants": ["app/constants/index"],
"@selectors/*": ["app/selectors/*"],
"@telemetry/*": ["/app/telemetry/*"],
"@utils/*": ["app/utils/*"],
"@websocket": ["app/client/websocket"],
"*": ["./*", "node_modules/*"]
}
},
"exclude": [
"node_modules",
"build",
"babel.config.js",
"metro.config.js",
"jest.config.js",
]
}

7
tsconfig.test.json Normal file
View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"jsx": "react",
"module": "commonjs"
}
}