This commit is contained in:
Julian Mondragon 2023-01-03 09:24:09 -05:00
commit 5bf0bbbea0
256 changed files with 3885 additions and 2393 deletions

2
.gitignore vendored
View file

@ -102,7 +102,7 @@ coverage
mattermost-license.txt
*.mattermost-license
detox/artifacts
detox/detox_pixel_4_xl_api_30
detox/detox_pixel_*
# Bundle artifact
*.jsbundle

View file

@ -54,13 +54,6 @@
"error": "visit rvm install https://rvm.io/rvm/install",
"platform": "darwin"
},
{
"rule": "cli",
"binary": "bundler",
"semver": "2.1.4",
"error": "install watchman `gem install bundler --version 2.1.4`",
"platform": "darwin"
},
{
"rule": "cli",
"binary": "pod",

View file

@ -200,6 +200,41 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @mattermost/react-native-turbo-mailer
This product contains '@mattermost/react-native-turbo-mailer' by Avinash Lingaloo.
An adaptation of react-native-mail that supports Turbo Module
* HOMEPAGE:
* https://github.com/mattermost/react-native-turbo-mailer#readme
* LICENSE: MIT
MIT License
Copyright (c) 2022 Mattermost
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## @msgpack/msgpack
@ -493,6 +528,21 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---
## @react-navigation/stack
This product contains '@react-navigation/stack'.
Stack navigator component for iOS and Android with animated transitions and gestures
* HOMEPAGE:
* https://reactnavigation.org/docs/stack-navigator/
* LICENSE: MIT
---
## @rudderstack/rudder-sdk-react-native
@ -1109,6 +1159,40 @@ Lightweight fuzzy-search
limitations under the License.
---
## html-entities
This product contains 'html-entities' by Marat Dulin.
Fastest HTML entities encode/decode library.
* HOMEPAGE:
* https://github.com/mdevils/html-entities#readme
* LICENSE: MIT
Copyright (c) 2021 Dulin Marat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
## jail-monkey

View file

@ -145,7 +145,7 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 443
versionCode 446
versionName "2.0.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

View file

@ -74,7 +74,10 @@ public class CustomPushNotificationHelper {
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
sender.setIcon(IconCompat.createWithBitmap(Objects.requireNonNull(userAvatar(context, serverUrl, senderId))));
Bitmap avatar = userAvatar(context, serverUrl, senderId, null);
if (avatar != null) {
sender.setIcon(IconCompat.createWithBitmap(avatar));
}
} catch (IOException e) {
e.printStackTrace();
}
@ -270,7 +273,10 @@ public class CustomPushNotificationHelper {
if (serverUrl != null && !type.equals(CustomPushNotificationHelper.PUSH_TYPE_SESSION)) {
try {
sender.setIcon(IconCompat.createWithBitmap(Objects.requireNonNull(userAvatar(context, serverUrl, "me"))));
Bitmap avatar = userAvatar(context, serverUrl, "me", null);
if (avatar != null) {
sender.setIcon(IconCompat.createWithBitmap(avatar));
}
} catch (IOException e) {
e.printStackTrace();
}
@ -397,6 +403,7 @@ public class CustomPushNotificationHelper {
String channelName = getConversationTitle(bundle);
String senderName = bundle.getString("sender_name");
String serverUrl = bundle.getString("server_url");
String urlOverride = bundle.getString("override_icon_url");
int smallIconResId = getSmallIconResourceId(context, smallIcon);
notification.setSmallIcon(smallIconResId);
@ -404,33 +411,46 @@ public class CustomPushNotificationHelper {
if (serverUrl != null && channelName.equals(senderName)) {
try {
String senderId = bundle.getString("sender_id");
notification.setLargeIcon(userAvatar(context, serverUrl, senderId));
Bitmap avatar = userAvatar(context, serverUrl, senderId, urlOverride);
if (avatar != null) {
notification.setLargeIcon(avatar);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
private static Bitmap userAvatar(Context context, final String serverUrl, final String userId) throws IOException {
final ReactApplicationContext reactApplicationContext = new ReactApplicationContext(context);
final String token = Credentials.getCredentialsForServerSync(reactApplicationContext, serverUrl);
private static Bitmap userAvatar(Context context, final String serverUrl, final String userId, final String urlOverride) throws IOException {
try {
final OkHttpClient client = new OkHttpClient();
Request request;
String url;
if (urlOverride != null) {
request = new Request.Builder().url(urlOverride).build();
Log.i("ReactNative", String.format("Fetch override profile image %s", urlOverride));
} else {
final ReactApplicationContext reactApplicationContext = new ReactApplicationContext(context);
final String token = Credentials.getCredentialsForServerSync(reactApplicationContext, serverUrl);
url = String.format("%s/api/v4/users/%s/image", serverUrl, userId);
Log.i("ReactNative", String.format("Fetch profile image %s", url));
request = new Request.Builder()
.header("Authorization", String.format("Bearer %s", token))
.url(url)
.build();
}
Response response = client.newCall(request).execute();
if (response.code() == 200) {
assert response.body() != null;
byte[] bytes = Objects.requireNonNull(response.body()).bytes();
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
return getCircleBitmap(bitmap);
}
final OkHttpClient client = new OkHttpClient();
final String url = String.format("%s/api/v4/users/%s/image", serverUrl, userId);
Request request = new Request.Builder()
.header("Authorization", String.format("Bearer %s", token))
.url(url)
.build();
Response response = client.newCall(request).execute();
if (response.code() == 200) {
assert response.body() != null;
byte[] bytes = Objects.requireNonNull(response.body()).bytes();
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
Log.i("ReactNative", String.format("Fetch profile %s", url));
return getCircleBitmap(bitmap);
return null;
} catch (Exception e) {
e.printStackTrace();
return null;
}
return null;
}
}

View file

@ -41,7 +41,7 @@ public class MainActivity extends NavigationActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
super.onCreate(null);
setContentView(R.layout.launch_screen);
setHWKeyboardConnected();
}

View file

@ -10,7 +10,7 @@ import {addChannelToDefaultCategory, storeCategories} from '@actions/local/categ
import {removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
import {switchToGlobalThreads} from '@actions/local/thread';
import {loadCallForChannel} from '@calls/actions/calls';
import {Events, General, Preferences, Screens} from '@constants';
import {DeepLink, Events, General, Preferences, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {privateChannelJoinPrompt} from '@helpers/api/channel';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
@ -24,11 +24,11 @@ import {getNthLastChannelFromTeam, getMyTeamById, getTeamByName, queryMyTeams, r
import {getCurrentUser} from '@queries/servers/user';
import {dismissAllModals, popToRoot} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {setTeamLoading} from '@store/team_load_store';
import {generateChannelNameFromDisplayName, getDirectChannelName, isDMorGM} from '@utils/channel';
import {isTablet} from '@utils/helpers';
import {logDebug, logError, logInfo} from '@utils/log';
import {showMuteChannelSnackbar} from '@utils/snack_bar';
import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url';
import {displayGroupMessageName, displayUsername} from '@utils/user';
import {fetchGroupsForChannelIfConstrained} from './groups';
@ -360,6 +360,9 @@ export async function fetchMyChannelsForTeam(serverUrl: string, teamId: string,
}
try {
if (!fetchOnly) {
setTeamLoading(serverUrl, true);
}
const [allChannels, channelMemberships, categoriesWithOrder] = await Promise.all([
client.getMyChannels(teamId, includeDeleted, since),
client.getMyChannelMembers(teamId),
@ -388,10 +391,14 @@ export async function fetchMyChannelsForTeam(serverUrl: string, teamId: string,
if (models.length) {
await operator.batchRecords(models);
}
setTeamLoading(serverUrl, false);
}
return {channels, memberships, categories};
} catch (error) {
if (!fetchOnly) {
setTeamLoading(serverUrl, false);
}
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
@ -655,7 +662,7 @@ export async function switchToChannelByName(serverUrl: string, channelName: stri
let joinedTeam = false;
let teamId = '';
try {
if (teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) {
if (teamName === DeepLink.Redirect) {
teamId = await getCurrentTeamId(database);
} else {
const team = await getTeamByName(database, teamName);
@ -1257,7 +1264,7 @@ export const unarchiveChannel = async (serverUrl: string, channelId: string) =>
try {
EphemeralStore.addArchivingChannel(channelId);
await client.unarchiveChannel(channelId);
await setChannelDeleteAt(serverUrl, channelId, Date.now());
await setChannelDeleteAt(serverUrl, channelId, 0);
return {error: undefined};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);

View file

@ -5,26 +5,18 @@ import {IntlShape} from 'react-intl';
import {Alert} from 'react-native';
import {doAppSubmit, postEphemeralCallResponseForCommandArgs} from '@actions/remote/apps';
import {showPermalink} from '@actions/remote/permalink';
import {Client} from '@client/rest';
import {AppCommandParser} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser';
import {AppCallResponseTypes} from '@constants/apps';
import DeepLinkType from '@constants/deep_linking';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
import IntegrationsManager from '@managers/integrations_manager';
import NetworkManager from '@managers/network_manager';
import {getChannelById} from '@queries/servers/channel';
import {getConfig, getCurrentTeamId} from '@queries/servers/system';
import {getTeammateNameDisplay, queryUsersByUsername} from '@queries/servers/user';
import {showAppForm, showModal} from '@screens/navigation';
import * as DraftUtils from '@utils/draft';
import {matchDeepLink, tryOpenURL} from '@utils/url';
import {displayUsername} from '@utils/user';
import {makeDirectChannel, switchToChannelById, switchToChannelByName} from './channel';
import type {DeepLinkChannel, DeepLinkPermalink, DeepLinkDM, DeepLinkGM, DeepLinkPlugin} from '@typings/launch';
import {showAppForm} from '@screens/navigation';
import {handleDeepLink, matchDeepLink} from '@utils/deep_link';
import {tryOpenURL} from '@utils/url';
export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: string): Promise<{data?: CommandResponse; error?: string | {message: string}}> => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
@ -144,60 +136,9 @@ export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, loc
const config = await getConfig(database);
const match = matchDeepLink(location, serverUrl, config?.SiteURL);
let linkServerUrl: string | undefined;
if (match?.data?.serverUrl) {
linkServerUrl = DatabaseManager.searchUrl(match.data.serverUrl);
}
if (match && linkServerUrl) {
switch (match.type) {
case DeepLinkType.Channel: {
const data = match.data as DeepLinkChannel;
switchToChannelByName(linkServerUrl, data.channelName, data.teamName, DraftUtils.errorBadChannel, intl);
break;
}
case DeepLinkType.Permalink: {
const data = match.data as DeepLinkPermalink;
showPermalink(linkServerUrl, data.teamName, data.postId, intl);
break;
}
case DeepLinkType.DirectMessage: {
const data = match.data as DeepLinkDM;
if (!data.userName) {
DraftUtils.errorUnkownUser(intl);
return {data: false};
}
if (data.serverUrl !== serverUrl) {
if (!database) {
return {error: `${serverUrl} database not found`};
}
}
const user = (await queryUsersByUsername(database, [data.userName]).fetch())[0];
if (!user) {
DraftUtils.errorUnkownUser(intl);
return {data: false};
}
makeDirectChannel(linkServerUrl, user.id, displayUsername(user, intl.locale, await getTeammateNameDisplay(database)), true);
break;
}
case DeepLinkType.GroupMessage: {
const data = match.data as DeepLinkGM;
if (!data.channelId) {
DraftUtils.errorBadChannel(intl);
return {data: false};
}
switchToChannelById(linkServerUrl, data.channelId);
break;
}
case DeepLinkType.Plugin: {
const data = match.data as DeepLinkPlugin;
showModal('PluginInternal', data.id, {link: location});
break;
}
}
if (match) {
handleDeepLink(match, intl, location);
} else {
const {formatMessage} = intl;
const onError = () => Alert.alert(

View file

@ -6,6 +6,7 @@ import {fetchConfigAndLicense} from '@actions/remote/systems';
import DatabaseManager from '@database/manager';
import {prepareCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected, getCurrentChannelId, getConfig, getLicense} from '@queries/servers/system';
import {getCurrentUser} from '@queries/servers/user';
import {setTeamLoading} from '@store/team_load_store';
import {deleteV1Data} from '@utils/file';
import {logInfo} from '@utils/log';
@ -37,8 +38,10 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false)
const currentChannelId = await getCurrentChannelId(database);
const lastDisconnectedAt = (await getWebSocketLastDisconnected(database)) || since;
setTeamLoading(serverUrl, true);
const entryData = await entry(serverUrl, currentTeamId, currentChannelId, since);
if ('error' in entryData) {
setTeamLoading(serverUrl, false);
return {error: entryData.error};
}
@ -55,6 +58,7 @@ export async function appEntry(serverUrl: string, since = 0, isUpgrade = false)
const dt = Date.now();
await operator.batchRecords(models);
logInfo('ENTRY MODELS BATCHING TOOK', `${Date.now() - dt}ms`);
setTeamLoading(serverUrl, false);
const {id: currentUserId, locale: currentUserLocale} = meData?.user || (await getCurrentUser(database))!;
const config = await getConfig(database);

View file

@ -2,7 +2,6 @@
// See LICENSE.txt for license information.
import {Database, Model} from '@nozbe/watermelondb';
import {DeviceEventEmitter} from 'react-native';
import {fetchMissingDirectChannelsInfo, fetchMyChannelsForTeam, handleKickFromChannel, MyChannelsRequest} from '@actions/remote/channel';
import {fetchGroupsForMember} from '@actions/remote/groups';
@ -10,11 +9,11 @@ import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
import {fetchRoles} from '@actions/remote/role';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import {fetchAllTeams, fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, MyTeamsRequest} from '@actions/remote/team';
import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {syncTeamThreads} from '@actions/remote/thread';
import {autoUpdateTimezone, fetchMe, MyUserRequest, updateAllUsersSince} from '@actions/remote/user';
import {gqlAllChannels} from '@client/graphQL/entry';
import {Events, General, Preferences, Screens} from '@constants';
import {General, Preferences, Screens} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {PUSH_PROXY_RESPONSE_NOT_AVAILABLE, PUSH_PROXY_RESPONSE_UNKNOWN, PUSH_PROXY_STATUS_NOT_AVAILABLE, PUSH_PROXY_STATUS_UNKNOWN, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
import DatabaseManager from '@database/manager';
@ -320,9 +319,7 @@ export async function restDeferredAppEntryActions(
channelsToFetchProfiles = new Set<Channel>(directChannels);
// defer fetching posts for unread channels on initial team
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId, true);
} else {
DeviceEventEmitter.emit(Events.FETCHING_POSTS, false);
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
}
}, FETCH_UNREADS_TIMEOUT);
@ -346,7 +343,7 @@ export async function restDeferredAppEntryActions(
}
}
await fetchAllTeams(serverUrl);
updateCanJoinTeams(serverUrl);
await updateAllUsersSince(serverUrl, since);
// Fetch groups for current user

View file

@ -2,17 +2,16 @@
// See LICENSE.txt for license information.
import {Database} from '@nozbe/watermelondb';
import {DeviceEventEmitter} from 'react-native';
import {storeConfigAndLicense} from '@actions/local/systems';
import {MyChannelsRequest} from '@actions/remote/channel';
import {fetchGroupsForMember} from '@actions/remote/groups';
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {MyTeamsRequest} from '@actions/remote/team';
import {MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {syncTeamThreads} from '@actions/remote/thread';
import {autoUpdateTimezone, updateAllUsersSince} from '@actions/remote/user';
import {gqlEntry, gqlEntryChannels, gqlOtherChannels} from '@client/graphQL/entry';
import {Events, Preferences} from '@constants';
import {Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreferenceValue} from '@helpers/api/preference';
import {selectDefaultTeam} from '@helpers/api/team';
@ -49,9 +48,7 @@ export async function deferredAppEntryGraphQLActions(
setTimeout(() => {
if (chData?.channels?.length && chData.memberships?.length) {
// defer fetching posts for unread channels on initial team
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId, true);
} else {
DeviceEventEmitter.emit(Events.FETCHING_POSTS, false);
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
}
}, FETCH_UNREADS_TIMEOUT);
@ -98,6 +95,7 @@ export async function deferredAppEntryGraphQLActions(
// Fetch groups for current user
fetchGroupsForMember(serverUrl, currentUserId);
updateCanJoinTeams(serverUrl);
updateAllUsersSince(serverUrl, since);
return {error: undefined};
@ -183,9 +181,22 @@ export const entryGQL = async (serverUrl: string, currentTeamId?: string, curren
user: gqlToClientUser(fetchedData.user!),
};
const allTeams = getMemberTeamsFromGQLQuery(fetchedData);
const allTeamMemberships = fetchedData.teamMembers.map((m) => gqlToClientTeamMembership(m, meData.user.id));
const [nonArchivedTeams, archivedTeamIds] = allTeams.reduce((acc, t) => {
if (t.delete_at) {
acc[1].add(t.id);
return acc;
}
return [[...acc[0], t], acc[1]];
}, [[], new Set<string>()]);
const nonArchivedTeamMemberships = allTeamMemberships.filter((m) => !archivedTeamIds.has(m.team_id));
const teamData = {
teams: getMemberTeamsFromGQLQuery(fetchedData),
memberships: fetchedData.teamMembers.map((m) => gqlToClientTeamMembership(m, meData.user.id)),
teams: nonArchivedTeams,
memberships: nonArchivedTeamMemberships,
};
const prefData = {
@ -205,7 +216,7 @@ export const entryGQL = async (serverUrl: string, currentTeamId?: string, curren
let initialTeamId = currentTeamId;
if (!teamData.teams.length) {
initialTeamId = '';
} else if (!initialTeamId || !teamData.teams.find((t) => t.id === currentTeamId)) {
} else if (!initialTeamId || !teamData.teams.find((t) => t.id === currentTeamId && t.delete_at === 0)) {
const teamOrderPreference = getPreferenceValue(prefData.preferences || [], Preferences.TEAMS_ORDER, '', '') as string;
initialTeamId = selectDefaultTeam(teamData.teams, meData.user.locale, teamOrderPreference, config.ExperimentalPrimaryTeam)?.id || '';
}

View file

@ -6,6 +6,7 @@ import {fetchConfigAndLicense} from '@actions/remote/systems';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {setCurrentTeamAndChannelId} from '@queries/servers/system';
import {setTeamLoading} from '@store/team_load_store';
import {isTablet} from '@utils/helpers';
import {deferredAppEntryActions, entry} from './gql_common';
@ -47,9 +48,11 @@ export async function loginEntry({serverUrl, user, deviceToken}: AfterLoginArgs)
return {error: clData.error};
}
setTeamLoading(serverUrl, true);
const entryData = await entry(serverUrl, '', '');
if ('error' in entryData) {
setTeamLoading(serverUrl, false);
return {error: entryData.error};
}
@ -66,6 +69,7 @@ export async function loginEntry({serverUrl, user, deviceToken}: AfterLoginArgs)
}
await operator.batchRecords(models);
setTeamLoading(serverUrl, false);
const config = clData.config || {} as ClientConfig;
const license = clData.license || {} as ClientLicense;

View file

@ -14,6 +14,7 @@ import {getIsCRTEnabled} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {setTeamLoading} from '@store/team_load_store';
import {isTablet} from '@utils/helpers';
import {emitNotificationError} from '@utils/notification';
import {setThemeDefaults, updateThemeIfNeeded} from '@utils/theme';
@ -81,8 +82,10 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not
switchedToScreen = true;
}
setTeamLoading(serverUrl, true);
const entryData = await entry(serverUrl, teamId, channelId);
if ('error' in entryData) {
setTeamLoading(serverUrl, false);
return {error: entryData.error};
}
const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData;
@ -134,6 +137,7 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not
}
await operator.batchRecords(models);
setTeamLoading(serverUrl, false);
const {id: currentUserId, locale: currentUserLocale} = (await getCurrentUser(operator.database))!;
const config = await getConfig(database);

View file

@ -14,6 +14,7 @@ import {getMyChannel, getChannelById} from '@queries/servers/channel';
import {getCurrentTeamId, getWebSocketLastDisconnected} from '@queries/servers/system';
import {getMyTeamById} from '@queries/servers/team';
import {getIsCRTEnabled} from '@queries/servers/thread';
import EphemeralStore from '@store/ephemeral_store';
import {emitNotificationError} from '@utils/notification';
const fetchNotificationData = async (serverUrl: string, notification: NotificationWithData, skipEvents = false) => {
@ -122,6 +123,7 @@ export const openNotification = async (serverUrl: string, notification: Notifica
}
try {
EphemeralStore.setNotificationTapped(true);
const {database} = operator;
const channelId = notification.payload!.channel_id!;
const rootId = notification.payload!.root_id!;

View file

@ -1,15 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DeepLink} from '@constants';
import DatabaseManager from '@database/manager';
import {getCurrentTeam} from '@queries/servers/team';
import {displayPermalink} from '@utils/permalink';
import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url';
import type TeamModel from '@typings/database/models/servers/team';
import type {IntlShape} from 'react-intl';
export const showPermalink = async (serverUrl: string, teamName: string, postId: string, intl: IntlShape, openAsPermalink = true) => {
export const showPermalink = async (serverUrl: string, teamName: string, postId: string, openAsPermalink = true) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
@ -18,7 +17,7 @@ export const showPermalink = async (serverUrl: string, teamName: string, postId:
try {
let name = teamName;
let team: TeamModel | undefined;
if (!name || name === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) {
if (!name || name === DeepLink.Redirect) {
team = await getCurrentTeam(database);
if (team) {
name = team.name;

View file

@ -23,7 +23,9 @@ import {getPostById, getRecentPostsInChannel} from '@queries/servers/post';
import {getCurrentUserId, getCurrentChannelId} from '@queries/servers/system';
import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
import {queryAllUsers} from '@queries/servers/user';
import {setFetchingThreadState} from '@store/fetching_thread_store';
import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers';
import {isServerError} from '@utils/errors';
import {logError} from '@utils/log';
import {processPostsFetched} from '@utils/post';
import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list';
@ -133,7 +135,7 @@ export async function createPost(serverUrl: string, post: Partial<Post>, files:
let created;
try {
created = await client.createPost(newPost);
} catch (error: any) {
} catch (error) {
const errorPost = {
...newPost,
id: pendingPostId,
@ -146,10 +148,11 @@ export async function createPost(serverUrl: string, post: Partial<Post>, files:
// If the failure was because: the root post was deleted or
// TownSquareIsReadOnly=true then remove the post
if (error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR ||
if (isServerError(error) && (
error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR ||
error.server_error_id === ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR ||
error.server_error_id === ServerErrors.PLUGIN_DISMISSED_POST_ERROR
) {
)) {
await removePost(serverUrl, databasePost);
} else {
const models = await operator.handlePosts({
@ -251,11 +254,12 @@ export const retryFailedPost = async (serverUrl: string, post: PostModel) => {
}
}
await operator.batchRecords(models);
} catch (error: any) {
if (error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR ||
} catch (error) {
if (isServerError(error) && (
error.server_error_id === ServerErrors.DELETED_ROOT_POST_ERROR ||
error.server_error_id === ServerErrors.TOWN_SQUARE_READ_ONLY_ERROR ||
error.server_error_id === ServerErrors.PLUGIN_DISMISSED_POST_ERROR
) {
)) {
await removePost(serverUrl, post);
} else {
post.prepareUpdate((p) => {
@ -325,12 +329,9 @@ export async function fetchPostsForChannel(serverUrl: string, channelId: string,
}
}
export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: Channel[], memberships: ChannelMembership[], excludeChannelId?: string, emitEvent = false) => {
export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: Channel[], memberships: ChannelMembership[], excludeChannelId?: string) => {
try {
const promises = [];
if (emitEvent) {
DeviceEventEmitter.emit(Events.FETCHING_POSTS, true);
}
for (const member of memberships) {
const channel = channels.find((c) => c.id === member.channel_id);
if (channel && (channel.total_msg_count - member.msg_count) > 0 && channel.id !== excludeChannelId) {
@ -338,13 +339,7 @@ export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: C
}
}
await Promise.all(promises);
if (emitEvent) {
DeviceEventEmitter.emit(Events.FETCHING_POSTS, false);
}
} catch (error) {
if (emitEvent) {
DeviceEventEmitter.emit(Events.FETCHING_POSTS, false);
}
return {error};
}
@ -583,6 +578,8 @@ export async function fetchPostThread(serverUrl: string, postId: string, options
return {error};
}
setFetchingThreadState(postId, true);
try {
const isCRTEnabled = await getIsCRTEnabled(operator.database);
@ -620,9 +617,11 @@ export async function fetchPostThread(serverUrl: string, postId: string, options
}
await operator.batchRecords(models);
}
setFetchingThreadState(postId, false);
return {posts: extractRecordsForTable<PostModel>(posts, MM_TABLES.SERVER.POST)};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
setFetchingThreadState(postId, false);
return {error};
}
}

View file

@ -5,6 +5,8 @@ import {Model} from '@nozbe/watermelondb';
import {DeviceEventEmitter} from 'react-native';
import {removeUserFromTeam as localRemoveUserFromTeam} from '@actions/local/team';
import {Client} from '@client/rest';
import {PER_PAGE_DEFAULT} from '@client/rest/constants';
import {Events} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
@ -12,9 +14,10 @@ import {getActiveServerUrl} from '@queries/app/servers';
import {prepareCategoriesAndCategoriesChannels} from '@queries/servers/categories';
import {prepareMyChannelsForTeam, getDefaultChannelForTeam} from '@queries/servers/channel';
import {prepareCommonSystemValues, getCurrentTeamId, getCurrentUserId} from '@queries/servers/system';
import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, getNthLastChannelFromTeam, queryTeamsById, syncTeamTable, getLastTeam, getTeamById, removeTeamFromTeamHistory} from '@queries/servers/team';
import {dismissAllModals, popToRoot, resetToTeams} from '@screens/navigation';
import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, getNthLastChannelFromTeam, queryTeamsById, getLastTeam, getTeamById, removeTeamFromTeamHistory, queryMyTeams} from '@queries/servers/team';
import {dismissAllModals, popToRoot} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {setTeamLoading} from '@store/team_load_store';
import {isTablet} from '@utils/helpers';
import {logDebug} from '@utils/log';
@ -56,12 +59,16 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s
return {error};
}
let loadEventSent = false;
try {
EphemeralStore.startAddingToTeam(teamId);
const team = await client.getTeam(teamId);
const member = await client.addToTeam(teamId, userId);
if (!fetchOnly) {
setTeamLoading(serverUrl, true);
loadEventSent = true;
fetchRolesIfNeeded(serverUrl, member.roles.split(' '));
const {channels, memberships: channelMembers, categories} = await fetchMyChannelsForTeam(serverUrl, teamId, false, 0, true);
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
@ -80,6 +87,8 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s
])).flat();
await operator.batchRecords(models);
setTeamLoading(serverUrl, false);
loadEventSent = false;
if (await isTablet()) {
const channel = await getDefaultChannelForTeam(operator.database, teamId);
@ -87,11 +96,18 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s
fetchPostsForChannel(serverUrl, channel.id);
}
}
} else {
setTeamLoading(serverUrl, false);
loadEventSent = false;
}
}
EphemeralStore.finishAddingToTeam(teamId);
updateCanJoinTeams(serverUrl);
return {member};
} catch (error) {
if (loadEventSent) {
setTeamLoading(serverUrl, false);
}
EphemeralStore.finishAddingToTeam(teamId);
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
@ -222,23 +238,10 @@ export async function fetchMyTeam(serverUrl: string, teamId: string, fetchOnly =
}
}
export const fetchAllTeams = async (serverUrl: string, fetchOnly = false): Promise<MyTeamsRequest> => {
let client;
export const fetchAllTeams = async (serverUrl: string, page = 0, perPage = PER_PAGE_DEFAULT): Promise<{teams?: Team[]; error?: any}> => {
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const teams = await client.getTeams();
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
syncTeamTable(operator, teams);
}
}
const client = NetworkManager.getClient(serverUrl);
const teams = await client.getTeams(page, perPage);
return {teams};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
@ -246,6 +249,76 @@ export const fetchAllTeams = async (serverUrl: string, fetchOnly = false): Promi
}
};
const recCanJoinTeams = async (client: Client, myTeamsIds: Set<string>, page: number): Promise<boolean> => {
const fetchedTeams = await client.getTeams(page, PER_PAGE_DEFAULT);
if (fetchedTeams.find((t) => !myTeamsIds.has(t.id) && t.delete_at === 0)) {
return true;
}
if (fetchedTeams.length === PER_PAGE_DEFAULT) {
return recCanJoinTeams(client, myTeamsIds, page + 1);
}
return false;
};
const LOAD_MORE_THRESHOLD = 10;
export async function fetchTeamsForComponent(
serverUrl: string,
page: number,
joinedIds?: Set<string>,
alreadyLoaded: Team[] = [],
): Promise<{teams: Team[]; hasMore: boolean; page: number}> {
let hasMore = true;
const {teams, error} = await fetchAllTeams(serverUrl, page, PER_PAGE_DEFAULT);
if (error || !teams || teams.length < PER_PAGE_DEFAULT) {
hasMore = false;
}
if (error) {
return {teams: alreadyLoaded, hasMore, page};
}
if (teams?.length) {
const notJoinedTeams = joinedIds ? teams.filter((t) => !joinedIds.has(t.id)) : teams;
alreadyLoaded.push(...notJoinedTeams);
if (teams.length < PER_PAGE_DEFAULT) {
hasMore = false;
}
if (
hasMore &&
(alreadyLoaded.length > LOAD_MORE_THRESHOLD)
) {
return fetchTeamsForComponent(serverUrl, page + 1, joinedIds, alreadyLoaded);
}
return {teams: alreadyLoaded, hasMore, page: page + 1};
}
return {teams: alreadyLoaded, hasMore: false, page};
}
export const updateCanJoinTeams = async (serverUrl: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const myTeams = await queryMyTeams(database).fetch();
const myTeamsIds = new Set(myTeams.map((m) => m.id));
const canJoin = await recCanJoinTeams(client, myTeamsIds, 0);
EphemeralStore.setCanJoinOtherTeams(serverUrl, canJoin);
return {};
} catch (error) {
EphemeralStore.setCanJoinOtherTeams(serverUrl, false);
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}
};
export const fetchTeamsChannelsAndUnreadPosts = async (serverUrl: string, since: number, teams: Team[], memberships: TeamMembership[], excludeTeamId?: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
@ -315,7 +388,7 @@ export const removeUserFromTeam = async (serverUrl: string, teamId: string, user
if (!fetchOnly) {
localRemoveUserFromTeam(serverUrl, teamId);
fetchAllTeams(serverUrl);
updateCanJoinTeams(serverUrl);
}
return {error: undefined};
@ -388,9 +461,9 @@ export async function handleKickFromTeam(serverUrl: string, teamId: string) {
const teamToJumpTo = await getLastTeam(database, teamId);
if (teamToJumpTo) {
await handleTeamChange(serverUrl, teamToJumpTo);
} else if (currentServer === serverUrl) {
await resetToTeams();
}
// Resetting to team select handled by the home screen
} catch (error) {
logDebug('Failed to kick user from team', error);
}

View file

@ -1,7 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {getCurrentUser} from '@queries/servers/user';
import {forceLogoutIfNecessary} from './session';
@ -25,15 +27,24 @@ export async function fetchTermsOfService(serverUrl: string): Promise<{terms?: T
}
export async function updateTermsOfServiceStatus(serverUrl: string, id: string, status: boolean): Promise<{resp?: {status: string}; error?: any}> {
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const client = NetworkManager.getClient(serverUrl);
const resp = await client.updateMyTermsOfServiceStatus(id, status);
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentUser = await getCurrentUser(database);
if (currentUser) {
currentUser.prepareUpdate((u) => {
if (status) {
u.termsOfServiceCreateAt = Date.now();
u.termsOfServiceId = id;
} else {
u.termsOfServiceCreateAt = 0;
u.termsOfServiceId = '';
}
});
operator.batchRecords([currentUser]);
}
return {resp};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);

View file

@ -1,8 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DeviceEventEmitter} from 'react-native';
import {markChannelAsViewed} from '@actions/local/channel';
import {markChannelAsRead} from '@actions/remote/channel';
import {handleEntryAfterLoadNavigation} from '@actions/remote/entry/common';
@ -30,11 +28,10 @@ import {
handleCallUserVoiceOn,
} from '@calls/connection/websocket_event_handlers';
import {isSupportedServerCalls} from '@calls/utils';
import {Events, Screens, WebsocketEvents} from '@constants';
import {Screens, WebsocketEvents} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
import {getActiveServerUrl} from '@queries/app/servers';
import {getCurrentChannel} from '@queries/servers/channel';
import {getLastPostInThread} from '@queries/servers/post';
import {
@ -50,6 +47,7 @@ import {getIsCRTEnabled} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {setTeamLoading} from '@store/team_load_store';
import {isTablet} from '@utils/helpers';
import {logDebug, logInfo} from '@utils/log';
@ -70,7 +68,7 @@ import {handlePreferenceChangedEvent, handlePreferencesChangedEvent, handlePrefe
import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions';
import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles';
import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system';
import {handleLeaveTeamEvent, handleUserAddedToTeamEvent, handleUpdateTeamEvent} from './teams';
import {handleLeaveTeamEvent, handleUserAddedToTeamEvent, handleUpdateTeamEvent, handleTeamArchived, handleTeamRestored} from './teams';
import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads';
import {handleUserUpdatedEvent, handleUserTypingEvent} from './users';
@ -88,7 +86,7 @@ export async function handleFirstConnect(serverUrl: string) {
// ESR: 5.37
if (lastDisconnect && config?.EnableReliableWebSockets !== 'true' && alreadyConnected.has(serverUrl)) {
handleReconnect(serverUrl);
await handleReconnect(serverUrl);
return;
}
@ -102,8 +100,8 @@ export async function handleFirstConnect(serverUrl: string) {
}
}
export function handleReconnect(serverUrl: string) {
doReconnect(serverUrl);
export async function handleReconnect(serverUrl: string) {
await doReconnect(serverUrl);
}
export async function handleClose(serverUrl: string, lastDisconnect: number) {
@ -141,15 +139,10 @@ async function doReconnect(serverUrl: string) {
const currentTeam = await getCurrentTeam(database);
const currentChannel = await getCurrentChannel(database);
const currentActiveServerUrl = await getActiveServerUrl();
if (serverUrl === currentActiveServerUrl) {
DeviceEventEmitter.emit(Events.FETCHING_POSTS, true);
}
setTeamLoading(serverUrl, true);
const entryData = await entry(serverUrl, currentTeam?.id, currentChannel?.id, lastDisconnectedAt);
if ('error' in entryData) {
if (serverUrl === currentActiveServerUrl) {
DeviceEventEmitter.emit(Events.FETCHING_POSTS, false);
}
setTeamLoading(serverUrl, false);
return;
}
const {models, initialTeamId, initialChannelId, prefData, teamData, chData} = entryData;
@ -159,6 +152,7 @@ async function doReconnect(serverUrl: string) {
const dt = Date.now();
await operator.batchRecords(models);
logInfo('WEBSOCKET RECONNECT MODELS BATCHING TOOK', `${Date.now() - dt}ms`);
setTeamLoading(serverUrl, false);
await fetchPostDataIfNeeded(serverUrl);
@ -318,6 +312,14 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
handleOpenDialogEvent(serverUrl, msg);
break;
case WebsocketEvents.DELETE_TEAM:
handleTeamArchived(serverUrl, msg);
break;
case WebsocketEvents.RESTORE_TEAM:
handleTeamRestored(serverUrl, msg);
break;
case WebsocketEvents.THREAD_UPDATED:
handleThreadUpdatedEvent(serverUrl, msg);
break;
@ -441,7 +443,10 @@ async function fetchPostDataIfNeeded(serverUrl: string) {
if (currentChannelId && (isChannelScreenMounted || tabletDevice)) {
await fetchPostsForChannel(serverUrl, currentChannelId);
markChannelAsRead(serverUrl, currentChannelId);
markChannelAsViewed(serverUrl, currentChannelId);
if (!EphemeralStore.wasNotificationTapped()) {
markChannelAsViewed(serverUrl, currentChannelId);
}
EphemeralStore.setNotificationTapped(false);
}
} catch (error) {
logDebug('could not fetch needed post after WS reconnect', error);

View file

@ -6,16 +6,77 @@ import {Model} from '@nozbe/watermelondb';
import {removeUserFromTeam} from '@actions/local/team';
import {fetchMyChannelsForTeam} from '@actions/remote/channel';
import {fetchRoles} from '@actions/remote/role';
import {fetchAllTeams, fetchMyTeam, handleKickFromTeam} from '@actions/remote/team';
import {fetchMyTeam, handleKickFromTeam, updateCanJoinTeams} from '@actions/remote/team';
import {updateUsersNoLongerVisible} from '@actions/remote/user';
import DatabaseManager from '@database/manager';
import ServerDataOperator from '@database/operator/server_data_operator';
import NetworkManager from '@managers/network_manager';
import {prepareCategoriesAndCategoriesChannels} from '@queries/servers/categories';
import {prepareMyChannelsForTeam} from '@queries/servers/channel';
import {getCurrentTeam, prepareMyTeams} from '@queries/servers/team';
import {getCurrentTeam, prepareMyTeams, queryMyTeamsByIds} from '@queries/servers/team';
import {getCurrentUser} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import {setTeamLoading} from '@store/team_load_store';
import {logDebug} from '@utils/log';
export async function handleTeamArchived(serverUrl: string, msg: WebSocketMessage) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const team: Team = JSON.parse(msg.data.team);
const membership = (await queryMyTeamsByIds(database, [team.id]).fetch())[0];
if (membership) {
const currentTeam = await getCurrentTeam(database);
if (currentTeam?.id === team.id) {
await handleKickFromTeam(serverUrl, team.id);
}
await removeUserFromTeam(serverUrl, team.id);
const user = await getCurrentUser(database);
if (user?.isGuest) {
updateUsersNoLongerVisible(serverUrl);
}
}
updateCanJoinTeams(serverUrl);
} catch (error) {
logDebug('cannot handle archive team websocket event', error);
}
}
export async function handleTeamRestored(serverUrl: string, msg: WebSocketMessage) {
let markedAsLoading = false;
try {
const client = NetworkManager.getClient(serverUrl);
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const team: Team = JSON.parse(msg.data.team);
const teamMembership = await client.getTeamMember(team.id, 'me');
if (teamMembership && teamMembership.delete_at === 0) {
// Ignore duplicated team join events sent by the server
if (EphemeralStore.isAddingToTeam(team.id)) {
return;
}
EphemeralStore.startAddingToTeam(team.id);
setTeamLoading(serverUrl, true);
markedAsLoading = true;
await fetchAndStoreJoinedTeamInfo(serverUrl, operator, team.id, [team], [teamMembership]);
setTeamLoading(serverUrl, false);
markedAsLoading = false;
EphemeralStore.finishAddingToTeam(team.id);
}
updateCanJoinTeams(serverUrl);
} catch (error) {
if (markedAsLoading) {
setTeamLoading(serverUrl, false);
}
logDebug('cannot handle restore team websocket event', error);
}
}
export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMessage) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
@ -33,7 +94,7 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess
}
await removeUserFromTeam(serverUrl, teamId);
fetchAllTeams(serverUrl);
updateCanJoinTeams(serverUrl);
if (user.isGuest) {
updateUsersNoLongerVisible(serverUrl);
@ -62,10 +123,6 @@ export async function handleUpdateTeamEvent(serverUrl: string, msg: WebSocketMes
}
export async function handleUserAddedToTeamEvent(serverUrl: string, msg: WebSocketMessage) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return;
}
const {team_id: teamId} = msg.data;
// Ignore duplicated team join events sent by the server
@ -74,8 +131,20 @@ export async function handleUserAddedToTeamEvent(serverUrl: string, msg: WebSock
}
EphemeralStore.startAddingToTeam(teamId);
const {teams, memberships: teamMemberships} = await fetchMyTeam(serverUrl, teamId, true);
try {
setTeamLoading(serverUrl, true);
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const {teams, memberships: teamMemberships} = await fetchMyTeam(serverUrl, teamId, true);
await fetchAndStoreJoinedTeamInfo(serverUrl, operator, teamId, teams, teamMemberships);
} catch (error) {
logDebug('could not handle user added to team websocket event');
}
setTeamLoading(serverUrl, false);
EphemeralStore.finishAddingToTeam(teamId);
}
const fetchAndStoreJoinedTeamInfo = async (serverUrl: string, operator: ServerDataOperator, teamId: string, teams?: Team[], teamMemberships?: TeamMembership[]) => {
const modelPromises: Array<Promise<Model[]>> = [];
if (teams?.length && teamMemberships?.length) {
const {channels, memberships, categories} = await fetchMyChannelsForTeam(serverUrl, teamId, false, 0, true);
@ -83,7 +152,9 @@ export async function handleUserAddedToTeamEvent(serverUrl: string, msg: WebSock
modelPromises.push(...await prepareMyChannelsForTeam(operator, teamId, channels || [], memberships || []));
const {roles} = await fetchRoles(serverUrl, teamMemberships, memberships, undefined, true);
modelPromises.push(operator.handleRole({roles, prepareRecordsOnly: true}));
if (roles?.length) {
modelPromises.push(operator.handleRole({roles, prepareRecordsOnly: true}));
}
}
if (teams && teamMemberships) {
@ -92,6 +163,4 @@ export async function handleUserAddedToTeamEvent(serverUrl: string, msg: WebSock
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
EphemeralStore.finishAddingToTeam(teamId);
}
};

View file

@ -5,7 +5,6 @@ import React, {useCallback} from 'react';
import {StyleSheet, View} from 'react-native';
import ChannelInfoStartButton from '@calls/components/channel_info_start';
import AddPeopleBox from '@components/channel_actions/add_people_box';
import CopyChannelLinkBox from '@components/channel_actions/copy_channel_link_box';
import FavoriteBox from '@components/channel_actions/favorite_box';
import MutedBox from '@components/channel_actions/mute_box';
@ -14,6 +13,8 @@ import {useServerUrl} from '@context/server';
import {dismissBottomSheet} from '@screens/navigation';
import {isTypeDMorGM} from '@utils/channel';
// import AddPeopleBox from '@components/channel_actions/add_people_box';
type Props = {
channelId: string;
channelType?: ChannelType;
@ -69,6 +70,7 @@ const ChannelActions = ({channelId, channelType, inModal = false, dismissChannel
testID={`${testID}.set_header.action`}
/>
}
{/* Add back in after MM-47655 is resolved. https://mattermost.atlassian.net/browse/MM-47655
{!isDM &&
<AddPeopleBox
channelId={channelId}
@ -76,6 +78,7 @@ const ChannelActions = ({channelId, channelType, inModal = false, dismissChannel
testID={`${testID}.add_people.action`}
/>
}
*/}
{!isDM && !callsEnabled &&
<>
<View style={styles.separator}/>

View file

@ -15,19 +15,20 @@ import {showSnackBar} from '@utils/snack_bar';
import type PostModel from '@typings/database/models/servers/post';
type Props = {
bottomSheetId: typeof Screens[keyof typeof Screens];
sourceScreen: typeof Screens[keyof typeof Screens];
post: PostModel;
teamName: string;
}
const CopyPermalinkOption = ({teamName, post, sourceScreen}: Props) => {
const CopyPermalinkOption = ({bottomSheetId, teamName, post, sourceScreen}: Props) => {
const serverUrl = useServerUrl();
const handleCopyLink = useCallback(async () => {
const permalink = `${serverUrl}/${teamName}/pl/${post.id}`;
Clipboard.setString(permalink);
await dismissBottomSheet(Screens.POST_OPTIONS);
await dismissBottomSheet(bottomSheetId);
showSnackBar({barType: SNACK_BAR_TYPE.LINK_COPIED, sourceScreen});
}, [teamName, post.id]);
}, [teamName, post.id, bottomSheetId]);
return (
<BaseOption

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useCallback} from 'react';
import {updateThreadFollowing} from '@actions/remote/thread';
import {BaseOption} from '@components/common_post_options';
@ -13,11 +13,12 @@ import {dismissBottomSheet} from '@screens/navigation';
import type ThreadModel from '@typings/database/models/servers/thread';
type FollowThreadOptionProps = {
bottomSheetId: typeof Screens[keyof typeof Screens];
thread: ThreadModel;
teamId?: string;
};
const FollowThreadOption = ({thread, teamId}: FollowThreadOptionProps) => {
const FollowThreadOption = ({bottomSheetId, thread, teamId}: FollowThreadOptionProps) => {
let id: string;
let defaultMessage: string;
let icon: string;
@ -44,13 +45,13 @@ const FollowThreadOption = ({thread, teamId}: FollowThreadOptionProps) => {
const serverUrl = useServerUrl();
const handleToggleFollow = async () => {
const handleToggleFollow = useCallback(async () => {
if (teamId == null) {
return;
}
await dismissBottomSheet(Screens.POST_OPTIONS);
await dismissBottomSheet(bottomSheetId);
updateThreadFollowing(serverUrl, teamId, thread.id, !thread.isFollowing);
};
}, [bottomSheetId, teamId, thread]);
const followThreadOptionTestId = thread.isFollowing ? 'post_options.following_thread.option' : 'post_options.follow_thread.option';

View file

@ -14,16 +14,16 @@ import type PostModel from '@typings/database/models/servers/post';
type Props = {
post: PostModel;
location?: typeof Screens[keyof typeof Screens];
bottomSheetId: typeof Screens[keyof typeof Screens];
}
const ReplyOption = ({post, location}: Props) => {
const ReplyOption = ({post, bottomSheetId}: Props) => {
const serverUrl = useServerUrl();
const handleReply = useCallback(async () => {
const rootId = post.rootId || post.id;
await dismissBottomSheet(location || Screens.POST_OPTIONS);
await dismissBottomSheet(bottomSheetId);
fetchAndSwitchToThread(serverUrl, rootId);
}, [post, serverUrl]);
}, [bottomSheetId, post, serverUrl]);
return (
<BaseOption

View file

@ -11,18 +11,19 @@ import {t} from '@i18n';
import {dismissBottomSheet} from '@screens/navigation';
type CopyTextProps = {
bottomSheetId: typeof Screens[keyof typeof Screens];
isSaved: boolean;
postId: string;
}
const SaveOption = ({isSaved, postId}: CopyTextProps) => {
const SaveOption = ({bottomSheetId, isSaved, postId}: CopyTextProps) => {
const serverUrl = useServerUrl();
const onHandlePress = useCallback(async () => {
const remoteAction = isSaved ? deleteSavedPost : savePostPreference;
await dismissBottomSheet(Screens.POST_OPTIONS);
await dismissBottomSheet(bottomSheetId);
remoteAction(serverUrl, postId);
}, [postId, serverUrl]);
}, [bottomSheetId, postId, serverUrl]);
const id = isSaved ? t('mobile.post_info.unsave') : t('mobile.post_info.save');
const defaultMessage = isSaved ? 'Unsave' : 'Save';

View file

@ -20,7 +20,7 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
isConnected: boolean;
websocketState: WebsocketConnectedState;
}
const getStyle = makeStyleSheetFromTheme((theme: Theme) => {
@ -74,7 +74,7 @@ const TIME_TO_OPEN = toMilliseconds({seconds: 3});
const TIME_TO_CLOSE = toMilliseconds({seconds: 1});
const ConnectionBanner = ({
isConnected,
websocketState,
}: Props) => {
const intl = useIntl();
const closeTimeout = useRef<NodeJS.Timeout | null>();
@ -86,6 +86,8 @@ const ConnectionBanner = ({
const appState = useAppState();
const netInfo = useNetInfo();
const isConnected = websocketState === 'connected';
const openCallback = useCallback(() => {
setVisible(true);
clearTimeoutRef(openTimeout);
@ -97,7 +99,9 @@ const ConnectionBanner = ({
}, []);
useEffect(() => {
if (!isConnected) {
if (websocketState === 'connecting') {
openCallback();
} else if (!isConnected) {
openTimeout.current = setTimeout(openCallback, TIME_TO_OPEN);
}
return () => {
@ -158,6 +162,8 @@ const ConnectionBanner = ({
let text;
if (isConnected) {
text = intl.formatMessage({id: 'connection_banner.connected', defaultMessage: 'Connection restored'});
} else if (websocketState === 'connecting') {
text = intl.formatMessage({id: 'connection_banner.connecting', defaultMessage: 'Connecting...'});
} else if (netInfo.isInternetReachable) {
text = intl.formatMessage({id: 'connection_banner.not_reachable', defaultMessage: 'The server is not reachable'});
} else {

View file

@ -9,7 +9,7 @@ import websocket_manager from '@managers/websocket_manager';
import ConnectionBanner from './connection_banner';
const enhanced = withObservables(['serverUrl'], ({serverUrl}: {serverUrl: string}) => ({
isConnected: websocket_manager.observeConnected(serverUrl),
websocketState: websocket_manager.observeWebsocketState(serverUrl),
}));
export default withServerUrl(enhanced(ConnectionBanner));

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import React, {useEffect, useMemo, useState} from 'react';
import {DeviceEventEmitter, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
import Animated, {useDerivedValue} from 'react-native-reanimated';
@ -69,10 +69,9 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l
filesForGallery.value[idx] = file;
};
const isSingleImage = () => (filesInfo.length === 1 && (isImage(filesInfo[0]) || isVideo(filesInfo[0])));
const isSingleImage = useMemo(() => filesInfo.filter((f) => isImage(f) || isVideo(f)).length === 1, [filesInfo]);
const renderItems = (items: FileInfo[], moreImagesCount = 0, includeGutter = false) => {
const singleImage = isSingleImage();
let nonVisibleImagesCount: number;
let container: StyleProp<ViewStyle> = items.length > 1 ? styles.container : undefined;
const containerWithGutter = [container, styles.gutter];
@ -97,7 +96,7 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l
file={file}
index={attachmentIndex(file.id!)}
onPress={handlePreviewPress}
isSingleImage={singleImage}
isSingleImage={isSingleImage}
nonVisibleImagesCount={nonVisibleImagesCount}
publicLinkEnabled={publicLinkEnabled}
updateFileForGallery={updateFileForGallery}

View file

@ -76,9 +76,8 @@ const VideoFile = ({
const viewPortHeight = Math.max(dimensions.height, dimensions.width) * 0.45;
return calculateDimensions(video.height || wrapperWidth, video.width || wrapperWidth, wrapperWidth, viewPortHeight);
}
return undefined;
}, [dimensions.height, dimensions.width, video.height, video.width, wrapperWidth]);
}, [dimensions.height, dimensions.width, video.height, video.width, wrapperWidth, isSingleImage]);
const getThumbnail = async () => {
const data = {...file};
@ -161,7 +160,12 @@ const VideoFile = ({
if (failed) {
thumbnail = (
<View style={[isSingleImage ? null : style.imagePreview, style.failed, imageDimensions]}>
<View
// @ts-expect-error ref of type unknown
ref={forwardRef}
style={[isSingleImage ? null : {height: '100%'}, style.failed, imageDimensions]}
>
<FileIcon
failed={failed}
file={file}
@ -174,7 +178,7 @@ const VideoFile = ({
<View
style={style.fileImageWrapper}
>
{!isSingleImage && <View style={style.boxPlaceholder}/>}
{!isSingleImage && !failed && <View style={style.boxPlaceholder}/>}
{thumbnail}
<View style={style.playContainer}>
<View style={style.play}>

View file

@ -5,12 +5,14 @@
import {debounce} from 'lodash';
import React, {useState, useEffect, useRef, useImperativeHandle, forwardRef, useMemo, useCallback} from 'react';
import {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, Platform, StyleProp, TargetedEvent, Text, TextInput, TextInputFocusEventData, TextInputProps, TextStyle, TouchableWithoutFeedback, View, ViewStyle} from 'react-native';
import {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TargetedEvent, Text, TextInput, TextInputFocusEventData, TextInputProps, TextStyle, TouchableWithoutFeedback, View, ViewStyle} from 'react-native';
import Animated, {useAnimatedStyle, withTiming, Easing} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {getLabelPositions, onExecution} from './utils';
const DEFAULT_INPUT_HEIGHT = 48;
const BORDER_DEFAULT_WIDTH = 1;
const BORDER_FOCUSED_WIDTH = 2;
@ -36,6 +38,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
lineHeight: 16,
paddingVertical: 5,
},
input: {
backgroundColor: 'transparent',
borderWidth: 0,
flex: 1,
paddingHorizontal: 0,
paddingTop: 0,
paddingBottom: 0,
},
label: {
position: 'absolute',
color: changeOpacity(theme.centerChannelColor, 0.64),
@ -52,6 +62,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
fontSize: 10,
},
textInput: {
flexDirection: 'row',
fontFamily: 'OpenSans',
fontSize: 16,
paddingTop: 12,
@ -65,29 +76,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
const onExecution = (
e: NativeSyntheticEvent<TextInputFocusEventData>,
innerFunc?: () => void,
outerFunc?: ((event: NativeSyntheticEvent<TargetedEvent>) => void),
) => {
innerFunc?.();
outerFunc?.(e);
};
const getLabelPositions = (style: TextStyle, labelStyle: TextStyle, smallLabelStyle: TextStyle) => {
const top: number = style.paddingTop as number || 0;
const bottom: number = style.paddingBottom as number || 0;
const height: number = (style.height as number || (top + bottom) || style.padding as number) || 0;
const textInputFontSize = style.fontSize || 13;
const labelFontSize = labelStyle.fontSize || 16;
const smallLabelFontSize = smallLabelStyle.fontSize || 10;
const fontSizeDiff = textInputFontSize - labelFontSize;
const unfocused = (height * 0.5) + (fontSizeDiff * (Platform.OS === 'android' ? 0.5 : 0.6));
const focused = -(labelFontSize + smallLabelFontSize) * 0.25;
return [unfocused, focused];
};
export type FloatingTextInputRef = {
blur: () => void;
focus: () => void;
@ -97,10 +85,11 @@ export type FloatingTextInputRef = {
type FloatingTextInputProps = TextInputProps & {
containerStyle?: ViewStyle;
editable?: boolean;
endAdornment?: React.ReactNode;
error?: string;
errorIcon?: string;
isKeyboardInput?: boolean;
label?: string;
label: string;
labelTextStyle?: TextStyle;
multiline?: boolean;
onBlur?: (event: NativeSyntheticEvent<TargetedEvent>) => void;
@ -120,6 +109,7 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
editable = true,
error,
errorIcon = 'alert-outline',
endAdornment,
isKeyboardInput = true,
label = '',
labelTextStyle,
@ -192,7 +182,7 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
return res;
}, [styles, containerStyle, multiline]);
const combinedTextInputStyle = useMemo(() => {
const combinedTextInputContainerStyle = useMemo(() => {
const res: StyleProp<TextStyle> = [styles.textInput];
if (!editable) {
res.push(styles.readOnly);
@ -217,6 +207,16 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
return res;
}, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline, editable]);
const combinedTextInputStyle = useMemo(() => {
const res: StyleProp<TextStyle> = [styles.textInput, styles.input, textInputStyle];
if (multiline) {
res.push({height: 80, textAlignVertical: 'top'});
}
return res;
}, [styles, theme, shouldShowError, focused, textInputStyle, focusedLabel, multiline, editable]);
const textAnimatedTextStyle = useAnimatedStyle(() => {
const inputText = placeholder || value || props.defaultValue;
const index = inputText || focusedLabel ? 1 : 0;
@ -245,31 +245,32 @@ const FloatingTextInput = forwardRef<FloatingTextInputRef, FloatingTextInputProp
onLayout={onLayout}
>
<View style={combinedContainerStyle}>
{label && (
<Animated.Text
onPress={onAnimatedTextPress}
style={[styles.label, labelTextStyle, textAnimatedTextStyle]}
suppressHighlighting={true}
numberOfLines={1}
>
{label}
</Animated.Text>
)}
<TextInput
{...props}
editable={isKeyboardInput && editable}
style={combinedTextInputStyle}
placeholder={placeholder}
placeholderTextColor={styles.label.color}
multiline={multiline}
value={value}
pointerEvents={isKeyboardInput ? 'auto' : 'none'}
onFocus={onTextInputFocus}
onBlur={onTextInputBlur}
ref={inputRef}
underlineColorAndroid='transparent'
testID={testID}
/>
<Animated.Text
onPress={onAnimatedTextPress}
style={[styles.label, labelTextStyle, textAnimatedTextStyle]}
suppressHighlighting={true}
numberOfLines={1}
>
{label}
</Animated.Text>
<View style={combinedTextInputContainerStyle}>
<TextInput
{...props}
editable={isKeyboardInput && editable}
style={combinedTextInputStyle}
placeholder={placeholder}
placeholderTextColor={styles.label.color}
multiline={multiline}
value={value}
pointerEvents={isKeyboardInput ? 'auto' : 'none'}
onFocus={onTextInputFocus}
onBlur={onTextInputBlur}
ref={inputRef}
underlineColorAndroid='transparent'
testID={testID}
/>
{endAdornment}
</View>
{Boolean(error) && (
<View style={styles.errorContainer}>
{showErrorIcon && errorIcon &&

View file

@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {NativeSyntheticEvent, Platform, TargetedEvent, TextInputFocusEventData, TextStyle} from 'react-native';
export const onExecution = (
e: NativeSyntheticEvent<TextInputFocusEventData>,
innerFunc?: () => void,
outerFunc?: ((event: NativeSyntheticEvent<TargetedEvent>) => void),
) => {
innerFunc?.();
outerFunc?.(e);
};
export const getLabelPositions = (style: TextStyle, labelStyle: TextStyle, smallLabelStyle: TextStyle) => {
const top: number = style.paddingTop as number || 0;
const bottom: number = style.paddingBottom as number || 0;
const height: number = (style.height as number || (top + bottom) || style.padding as number) || 0;
const textInputFontSize = style.fontSize || 13;
const labelFontSize = labelStyle.fontSize || 16;
const smallLabelFontSize = smallLabelStyle.fontSize || 10;
const fontSizeDiff = textInputFontSize - labelFontSize;
const unfocused = (height * 0.5) + (fontSizeDiff * (Platform.OS === 'android' ? 0.5 : 0.6));
const focused = -(labelFontSize + smallLabelFontSize) * 0.25;
return [unfocused, focused];
};

View file

@ -2,8 +2,9 @@
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, Text, TextStyle} from 'react-native';
import {DeviceEventEmitter, StyleProp, Text, TextStyle} from 'react-native';
import {Navigation, Screens} from '@constants';
import {popToRoot, dismissAllModals} from '@screens/navigation';
type HashtagProps = {
@ -17,7 +18,12 @@ const Hashtag = ({hashtag, linkStyle}: HashtagProps) => {
await dismissAllModals();
await popToRoot();
// showSearchModal('#' + hashtag);
DeviceEventEmitter.emit(Navigation.NAVIGATE_TO_TAB, {
screen: Screens.SEARCH,
params: {
searchTerm: hashtag,
},
});
};
return (

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useManagedConfig} from '@mattermost/react-native-emm';
import {Parser, Node} from 'commonmark';
import Renderer from 'commonmark-react-renderer';
import React, {ReactElement, useMemo, useRef} from 'react';
@ -131,6 +132,7 @@ const Markdown = ({
textStyles = {}, theme, value = '', baseParagraphStyle,
}: MarkdownProps) => {
const style = getStyleSheet(theme);
const managedConfig = useManagedConfig<ManagedConfig>();
const urlFilter = (url: string) => {
const scheme = getScheme(url);
@ -471,10 +473,14 @@ const Markdown = ({
};
const renderText = ({context, literal}: MarkdownBaseRenderer) => {
const selectable = (managedConfig.copyAndPasteProtection !== 'true') && context.includes('table_cell');
if (context.indexOf('image') !== -1) {
// If this text is displayed, it will be styled by the image component
return (
<Text testID='markdown_text'>
<Text
testID='markdown_text'
selectable={selectable}
>
{literal}
</Text>
);
@ -496,6 +502,7 @@ const Markdown = ({
<Text
testID='markdown_text'
style={styles}
selectable={selectable}
>
{literal}
</Text>

View file

@ -59,7 +59,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
height: 24,
},
container: {
marginBottom: 5,
marginVertical: 5,
top: 5,
},
svg: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.06),

View file

@ -9,19 +9,14 @@ import {Alert, StyleSheet, Text, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import urlParse from 'url-parse';
import {switchToChannelByName} from '@actions/remote/channel';
import {showPermalink} from '@actions/remote/permalink';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import DeepLinkType from '@constants/deep_linking';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
import {errorBadChannel} from '@utils/draft';
import {handleDeepLink, matchDeepLink} from '@utils/deep_link';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {preventDoubleTap} from '@utils/tap';
import {matchDeepLink, normalizeProtocol, tryOpenURL} from '@utils/url';
import type {DeepLinkChannel, DeepLinkPermalink, DeepLinkWithData} from '@typings/launch';
import {normalizeProtocol, tryOpenURL} from '@utils/url';
type MarkdownLinkProps = {
children: ReactElement;
@ -65,28 +60,27 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU
return;
}
const match: DeepLinkWithData | null = matchDeepLink(url, serverUrl, siteURL);
const onError = () => {
Alert.alert(
formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
formatMessage({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
};
if (match && match.data?.teamName) {
if (match.type === DeepLinkType.Channel) {
await switchToChannelByName(serverUrl, (match?.data as DeepLinkChannel).channelName, match.data?.teamName, errorBadChannel, intl);
} else if (match.type === DeepLinkType.Permalink) {
showPermalink(serverUrl, match.data.teamName, (match.data as DeepLinkPermalink).postId, intl);
const match = matchDeepLink(url, serverUrl, siteURL);
if (match) {
const {error} = await handleDeepLink(match, intl);
if (error) {
tryOpenURL(match, onError);
}
} else {
const onError = () => {
Alert.alert(
formatMessage({
id: 'mobile.link.error.title',
defaultMessage: 'Error',
}),
formatMessage({
id: 'mobile.link.error.text',
defaultMessage: 'Unable to open the link.',
}),
);
};
tryOpenURL(url, onError);
}
}), [href, intl.locale, serverUrl, siteURL]);

View file

@ -24,6 +24,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
justifyContent: 'flex-start',
padding: 8,
},
textContainer: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
},
cellRightBorder: {
borderRightWidth: 1,
},
@ -57,7 +62,9 @@ const MarkdownTableCell = ({isLastCell, align, children}: MarkdownTableCellProps
style={[cellStyle, textStyle]}
testID='markdown_table_cell'
>
{children}
<View style={style.textContainer}>
{children}
</View>
</View>
);
};

View file

@ -283,7 +283,6 @@ const OptionItem = ({
}
</View>
);
if (Object.values(TouchableOptionTypes).includes(type)) {
return (
<TouchableOpacity onPress={onPress}>

View file

@ -24,8 +24,8 @@ type Props = {
canShowPostPriority?: boolean;
// Post Props
postProps: Post['props'];
updatePostProps: (postProps: Post['props']) => void;
postPriority: PostPriorityData;
updatePostPriority: (postPriority: PostPriorityData) => void;
// Cursor Position Handler
updateCursorPosition: React.Dispatch<React.SetStateAction<number>>;
@ -110,8 +110,8 @@ export default function DraftInput({
updateCursorPosition,
cursorPosition,
updatePostInputTop,
postProps,
updatePostProps,
postPriority,
updatePostPriority,
setIsFocused,
}: Props) {
const theme = useTheme();
@ -155,9 +155,9 @@ export default function DraftInput({
overScrollMode={'never'}
disableScrollViewPanResponder={true}
>
{Boolean(postProps.priority) && (
{Boolean(postPriority?.priority) && (
<View style={style.postPriorityLabel}>
<PostPriorityLabel label={postProps.priority}/>
<PostPriorityLabel label={postPriority!.priority}/>
</View>
)}
<PostInput
@ -188,8 +188,8 @@ export default function DraftInput({
addFiles={addFiles}
updateValue={updateValue}
value={value}
postProps={postProps}
updatePostProps={updatePostProps}
postPriority={postPriority}
updatePostPriority={updatePostPriority}
canShowPostPriority={canShowPostPriority}
focus={focus}
/>

View file

@ -2,60 +2,36 @@
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import {CameraOptions} from 'react-native-image-picker';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import SlideUpPanelItem from '@components/slide_up_panel_item';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {dismissBottomSheet} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
onPress: (options: CameraOptions) => void;
}
const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
center: {
alignItems: 'center',
},
container: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 200,
paddingVertical: 10,
},
flex: {
flex: 1,
},
options: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
justifyContent: 'space-evenly',
width: '100%',
marginBottom: 50,
},
optionContainer: {
alignItems: 'flex-start',
},
title: {
color: theme.centerChannelColor,
fontSize: 18,
fontWeight: 'bold',
},
text: {
color: theme.centerChannelColor,
fontSize: 15,
...typography('Heading', 600, 'SemiBold'),
marginBottom: 8,
},
}));
const CameraType = ({onPress}: Props) => {
const theme = useTheme();
const isTablet = useIsTablet();
const style = getStyle(theme);
const intl = useIntl();
const onPhoto = async () => {
const options: CameraOptions = {
@ -80,54 +56,26 @@ const CameraType = ({onPress}: Props) => {
};
return (
<View style={style.container}>
<View>
{!isTablet &&
<FormattedText
id='camera_type.title'
defaultMessage='Choose an action'
id='mobile.camera_type.title'
defaultMessage='Camera options'
style={style.title}
/>
}
<View style={style.options}>
<View style={style.flex}>
<TouchableWithFeedback
onPress={onPhoto}
testID='camera_type.photo'
>
<View style={style.center}>
<CompassIcon
color={theme.centerChannelColor}
name='camera-outline'
size={38}
/>
<FormattedText
id='camera_type.photo.option'
defaultMessage='Capture Photo'
style={style.text}
/>
</View>
</TouchableWithFeedback>
</View>
<View style={style.flex}>
<TouchableWithFeedback
onPress={onVideo}
testID='camera_type.video'
>
<View style={style.center}>
<CompassIcon
color={theme.centerChannelColor}
name='video-outline'
size={38}
/>
<FormattedText
id='camera_type.video.option'
defaultMessage='Record Video'
style={style.text}
/>
</View>
</TouchableWithFeedback>
</View>
</View>
<SlideUpPanelItem
icon='camera-outline'
onPress={onPhoto}
testID='camera_type.photo'
text={intl.formatMessage({id: 'camera_type.photo.option', defaultMessage: 'Capture Photo'})}
/>
<SlideUpPanelItem
icon='video-outline'
onPress={onVideo}
testID='camera_type.video'
text={intl.formatMessage({id: 'camera_type.video.option', defaultMessage: 'Record Video'})}
/>
</View>
);
};

View file

@ -5,14 +5,18 @@ import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Alert, StyleSheet} from 'react-native';
import {CameraOptions} from 'react-native-image-picker';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
import {TITLE_HEIGHT} from '@screens/bottom_sheet/content';
import {bottomSheet} from '@screens/navigation';
import {fileMaxWarning} from '@utils/file';
import PickerUtil from '@utils/file/file_picker';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {changeOpacity} from '@utils/theme';
import CameraType from './camera_type';
@ -36,6 +40,7 @@ export default function CameraQuickAction({
}: QuickActionAttachmentProps) {
const intl = useIntl();
const theme = useTheme();
const {bottom} = useSafeAreaInsets();
const handleButtonPress = useCallback((options: CameraOptions) => {
const picker = new PickerUtil(intl,
@ -64,14 +69,15 @@ export default function CameraQuickAction({
return;
}
const snap = bottomSheetSnapPoint(2, ITEM_HEIGHT, bottom);
bottomSheet({
title: intl.formatMessage({id: 'camera_type.title', defaultMessage: 'Choose an action'}),
title: intl.formatMessage({id: 'mobile.camera_type.title', defaultMessage: 'Camera options'}),
renderContent,
snapPoints: [200, 10],
snapPoints: [TITLE_HEIGHT + snap, 10],
theme,
closeButtonId: 'camera-close-id',
});
}, [intl, theme, renderContent, maxFilesReached, maxFileCount]);
}, [intl, theme, renderContent, maxFilesReached, maxFileCount, bottom]);
const actionTestID = disabled ? `${testID}.disabled` : testID;
const color = disabled ? changeOpacity(theme.centerChannelColor, 0.16) : changeOpacity(theme.centerChannelColor, 0.64);

View file

@ -6,7 +6,7 @@ import {useIntl} from 'react-intl';
import {StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import PostPriorityPicker, {PostPriorityData} from '@components/post_priority/post_priority_picker';
import PostPriorityPicker from '@components/post_priority/post_priority_picker';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {ICON_SIZE} from '@constants/post_draft';
import {useTheme} from '@context/theme';
@ -15,8 +15,8 @@ import {changeOpacity} from '@utils/theme';
type Props = {
testID?: string;
postProps: Post['props'];
updatePostProps: (postProps: Post['props']) => void;
postPriority: PostPriorityData;
updatePostPriority: (postPriority: PostPriorityData) => void;
}
const style = StyleSheet.create({
@ -29,30 +29,27 @@ const style = StyleSheet.create({
export default function PostPriorityAction({
testID,
postProps,
updatePostProps,
postPriority,
updatePostPriority,
}: Props) {
const intl = useIntl();
const theme = useTheme();
const handlePostPriorityPicker = useCallback((postPriorityData: PostPriorityData) => {
updatePostProps((oldPostProps: Post['props']) => ({
...oldPostProps,
...postPriorityData,
}));
updatePostPriority(postPriorityData);
dismissBottomSheet();
}, [updatePostProps]);
}, [updatePostPriority]);
const renderContent = useCallback(() => {
return (
<PostPriorityPicker
data={{
priority: postProps?.priority || '',
priority: postPriority?.priority || '',
}}
onSubmit={handlePostPriorityPicker}
/>
);
}, [handlePostPriorityPicker, postProps]);
}, [handlePostPriorityPicker, postPriority]);
const onPress = useCallback(() => {
bottomSheet({

View file

@ -22,8 +22,8 @@ type Props = {
value: string;
updateValue: (value: string) => void;
addFiles: (file: FileInfo[]) => void;
postProps: Post['props'];
updatePostProps: (postProps: Post['props']) => void;
postPriority: PostPriorityData;
updatePostPriority: (postPriority: PostPriorityData) => void;
focus: () => void;
}
@ -45,8 +45,8 @@ export default function QuickActions({
maxFileCount,
updateValue,
addFiles,
postProps,
updatePostProps,
postPriority,
updatePostPriority,
focus,
}: Props) {
const atDisabled = value[value.length - 1] === '@';
@ -101,8 +101,8 @@ export default function QuickActions({
{isPostPriorityEnabled && canShowPostPriority && (
<PostPriorityAction
testID={postPriorityActionTestID}
postProps={postProps}
updatePostProps={updatePostProps}
postPriority={postPriority}
updatePostPriority={updatePostPriority}
/>
)}
</View>

View file

@ -13,6 +13,7 @@ import {setStatus} from '@actions/remote/user';
import {canEndCall, endCall, getEndCallMessage} from '@calls/actions/calls';
import ClientError from '@client/rest/error';
import {Events, Screens} from '@constants';
import {PostPriorityType} from '@constants/post';
import {NOTIFY_ALL_MEMBERS} from '@constants/post_draft';
import {useServerUrl} from '@context/server';
import DraftUploadManager from '@managers/draft_upload_manager';
@ -54,6 +55,10 @@ type Props = {
uploadFileError: React.ReactNode;
}
const INITIAL_PRIORITY = {
priority: PostPriorityType.STANDARD,
};
export default function SendHandler({
testID,
channelId,
@ -83,8 +88,7 @@ export default function SendHandler({
const [channelTimezoneCount, setChannelTimezoneCount] = useState(0);
const [sendingMessage, setSendingMessage] = useState(false);
const [postProps, setPostProps] = useState<Post['props']>({});
const [postPriority, setPostPriority] = useState<PostPriorityData>(INITIAL_PRIORITY);
const canSend = useCallback(() => {
if (sendingMessage) {
@ -120,17 +124,19 @@ export default function SendHandler({
message: value,
} as Post;
if (Object.keys(postProps).length) {
post.props = postProps;
if (Object.keys(postPriority).length) {
post.metadata = {
priority: postPriority,
};
}
createPost(serverUrl, post, postFiles);
clearDraft();
setSendingMessage(false);
setPostProps({});
setPostPriority(INITIAL_PRIORITY);
DeviceEventEmitter.emit(Events.POST_LIST_SCROLL_TO_BOTTOM, rootId ? Screens.THREAD : Screens.CHANNEL);
}, [files, currentUserId, channelId, rootId, value, clearDraft, postProps]);
}, [files, currentUserId, channelId, rootId, value, clearDraft, postPriority]);
const showSendToAllOrChannelOrHereAlert = useCallback((calculatedMembersCount: number, atHere: boolean) => {
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(intl, calculatedMembersCount, Boolean(isTimezoneEnabled), channelTimezoneCount, atHere);
@ -297,8 +303,8 @@ export default function SendHandler({
canSend={canSend()}
maxMessageLength={maxMessageLength}
updatePostInputTop={updatePostInputTop}
postProps={postProps}
updatePostProps={setPostProps}
postPriority={postPriority}
updatePostPriority={setPostPriority}
setIsFocused={setIsFocused}
/>
);

View file

@ -44,6 +44,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
message: {
color: theme.centerChannelColor,
...typography('Body', 200),
lineHeight: undefined, // remove line height, not needed and causes problems with md images
},
pendingPost: {
opacity: 0.5,

View file

@ -132,10 +132,10 @@ const Header = (props: HeaderProps) => {
style={style.time}
testID='post_header.date_time'
/>
{showPostPriority && (
{showPostPriority && post.metadata?.priority?.priority && (
<View style={style.postPriority}>
<PostPriorityLabel
label={post.props?.priority}
label={post.metadata.priority.priority}
/>
</View>
)}

View file

@ -139,7 +139,7 @@ const Post = ({
const handlePostPress = () => {
if ([Screens.SAVED_MESSAGES, Screens.MENTIONS, Screens.SEARCH, Screens.PINNED_MESSAGES].includes(location)) {
showPermalink(serverUrl, '', post.id, intl);
showPermalink(serverUrl, '', post.id);
return;
}
@ -226,7 +226,7 @@ const Post = ({
// If the post is a priority post:
// 1. Show the priority label in channel screen
// 2. Show the priority label in thread screen for the root post
const showPostPriority = Boolean(isPostPriorityEnabled && post.props?.priority) && (location !== Screens.THREAD || !post.rootId);
const showPostPriority = Boolean(isPostPriorityEnabled && post.metadata?.priority?.priority) && (location !== Screens.THREAD || !post.rootId);
const sameSequence = hasReplies ? (hasReplies && post.rootId) : !post.rootId;
if (!showPostPriority && hasSameRoot && isConsecutivePost && sameSequence) {

View file

@ -44,6 +44,7 @@ exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] =
</Text>
</Text>
<Text
selectable={false}
style={
{
"color": "rgba(63,67,80,0.6)",
@ -106,6 +107,7 @@ exports[`renderSystemMessage uses renderer for Channel Header update 1`] = `
</Text>
</Text>
<Text
selectable={false}
style={
{
"color": "rgba(63,67,80,0.6)",
@ -184,6 +186,7 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 1
</Text>
</Text>
<Text
selectable={false}
style={
{
"color": "rgba(63,67,80,0.6)",
@ -246,6 +249,7 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 2
</Text>
</Text>
<Text
selectable={false}
style={
{
"color": "rgba(63,67,80,0.6)",
@ -308,6 +312,7 @@ exports[`renderSystemMessage uses renderer for OLD archived channel without a us
>
<Text>
<Text
selectable={false}
style={
{
"color": "rgba(63,67,80,0.6)",
@ -370,6 +375,7 @@ exports[`renderSystemMessage uses renderer for archived channel 1`] = `
</Text>
</Text>
<Text
selectable={false}
style={
{
"color": "rgba(63,67,80,0.6)",
@ -432,6 +438,7 @@ exports[`renderSystemMessage uses renderer for unarchived channel 1`] = `
</Text>
</Text>
<Text
selectable={false}
style={
{
"color": "rgba(63,67,80,0.6)",

View file

@ -30,6 +30,7 @@ type Props = {
currentTimezone: string | null;
currentUserId: string;
currentUsername: string;
disablePullToRefresh?: boolean;
highlightedId?: PostModel['id'];
highlightPinnedOrSaved?: boolean;
isCRTEnabled?: boolean;
@ -45,6 +46,7 @@ type Props = {
showMoreMessages?: boolean;
showNewMessageLine?: boolean;
footer?: ReactElement;
header?: ReactElement;
testID: string;
currentCallBarVisible?: boolean;
joinCallBannerVisible?: boolean;
@ -84,7 +86,9 @@ const PostList = ({
currentTimezone,
currentUserId,
currentUsername,
disablePullToRefresh,
footer,
header,
highlightedId,
highlightPinnedOrSaved = true,
isCRTEnabled,
@ -365,7 +369,7 @@ const PostList = ({
return (
<>
<PostListRefreshControl
enabled={enableRefreshControl}
enabled={!disablePullToRefresh && enableRefreshControl}
refreshing={refreshing}
onRefresh={onRefresh}
style={styles.container}
@ -377,6 +381,7 @@ const PostList = ({
keyboardShouldPersistTaps='handled'
keyExtractor={keyExtractor}
initialNumToRender={INITIAL_BATCH_TO_RENDER + 5}
ListHeaderComponent={header}
ListFooterComponent={footer}
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
maxToRenderPerBatch={10}

View file

@ -14,6 +14,7 @@ describe('ThreadOverview', () => {
const props = {
isSaved: true,
repliesCount: 0,
rootId: '',
rootPost: {} as PostModel,
testID: 'thread-overview',
};
@ -26,6 +27,7 @@ describe('ThreadOverview', () => {
const props = {
isSaved: false,
repliesCount: 2,
rootId: '',
rootPost: {} as PostModel,
testID: 'thread-overview',
};

View file

@ -13,6 +13,7 @@ import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useFetchingThreadState} from '@hooks/fetching_thread';
import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -23,6 +24,7 @@ import type PostModel from '@typings/database/models/servers/post';
type Props = {
isSaved: boolean;
repliesCount: number;
rootId: string;
rootPost?: PostModel;
testID: string;
style?: StyleProp<ViewStyle>;
@ -56,13 +58,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const ThreadOverview = ({isSaved, repliesCount, rootPost, style, testID}: Props) => {
const ThreadOverview = ({isSaved, repliesCount, rootId, rootPost, style, testID}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const intl = useIntl();
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
const isFetchingThread = useFetchingThreadState(rootId);
const onHandleSavePress = useCallback(preventDoubleTap(() => {
if (rootPost?.id) {
@ -98,30 +101,44 @@ const ThreadOverview = ({isSaved, repliesCount, rootPost, style, testID}: Props)
const saveButtonTestId = isSaved ? `${testID}.unsave.button` : `${testID}.save.button`;
let repliesCountElement;
if (repliesCount > 0) {
repliesCountElement = (
<FormattedText
style={styles.repliesCount}
id='thread.repliesCount'
defaultMessage='{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}'
testID={`${testID}.replies_count`}
values={{repliesCount}}
/>
);
} else if (isFetchingThread) {
repliesCountElement = (
<FormattedText
style={styles.repliesCount}
id='thread.loadingReplies'
defaultMessage='Loading replies...'
testID={`${testID}.loading_replies`}
/>
);
} else {
repliesCountElement = (
<FormattedText
style={styles.repliesCount}
id='thread.noReplies'
defaultMessage='No replies yet'
testID={`${testID}.no_replies`}
/>
);
}
return (
<View
style={containerStyle}
testID={testID}
>
<View style={styles.repliesCountContainer}>
{
repliesCount > 0 ? (
<FormattedText
style={styles.repliesCount}
id='thread.repliesCount'
defaultMessage='{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}'
testID={`${testID}.replies_count`}
values={{repliesCount}}
/>
) : (
<FormattedText
style={styles.repliesCount}
id='thread.noReplies'
defaultMessage='No replies yet'
testID={`${testID}.no_replies`}
/>
)
}
{repliesCountElement}
</View>
<View style={styles.optionsContainer}>
<TouchableOpacity

View file

@ -35,7 +35,7 @@ const style = StyleSheet.create({
});
type Props = {
label: PostPriorityType;
label: PostPriorityData['priority'];
};
const PostPriorityLabel = ({label}: Props) => {
@ -48,7 +48,7 @@ const PostPriorityLabel = ({label}: Props) => {
containerStyle.push(style.urgent);
iconName = 'alert-outline';
labelText = intl.formatMessage({id: 'post_priority.label.urgent', defaultMessage: 'URGENT'});
} else {
} else if (label === PostPriorityType.IMPORTANT) {
containerStyle.push(style.important);
iconName = 'alert-circle-outline';
labelText = intl.formatMessage({id: 'post_priority.label.important', defaultMessage: 'IMPORTANT'});

View file

@ -14,10 +14,6 @@ import {typography} from '@utils/typography';
import PostPriorityPickerItem from './post_priority_picker_item';
export type PostPriorityData = {
priority: PostPriorityType;
};
type Props = {
data: PostPriorityData;
onSubmit: (data: PostPriorityData) => void;
@ -34,7 +30,7 @@ const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({
},
title: {
color: theme.centerChannelColor,
...typography('Body', 600, 'SemiBold'),
...typography('Heading', 600, 'SemiBold'),
},
betaContainer: {
backgroundColor: PostPriorityColors.IMPORTANT,
@ -61,8 +57,8 @@ const PostPriorityPicker = ({data, onSubmit}: Props) => {
// For now, we just have one option but the spec suggest we have more in the next phase
// const [data, setData] = React.useState<PostPriorityData>(defaultData);
const handleUpdatePriority = React.useCallback((priority: PostPriorityType) => {
onSubmit({priority});
const handleUpdatePriority = React.useCallback((priority: PostPriorityData['priority']) => {
onSubmit({priority: priority || ''});
}, [onSubmit]);
return (

View file

@ -67,6 +67,11 @@ type Props = {
*/
teammateNameDisplay: string;
/**
* test ID
*/
testID?: string;
/**
* toast Icon
*/
@ -130,7 +135,7 @@ export default function SelectedUsers({
buttonIcon, buttonText, containerHeight = 0,
modalPosition = 0, onPress, onRemove,
selectedIds, setShowToast, showToast = false,
teammateNameDisplay, toastIcon, toastMessage,
teammateNameDisplay, testID, toastIcon, toastMessage,
}: Props) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
@ -157,7 +162,7 @@ export default function SelectedUsers({
user={selectedIds[id]}
teammateNameDisplay={teammateNameDisplay}
onRemove={onRemove}
testID='create_direct_message.selected_user'
testID={`${testID}.selected_user`}
/>,
);
}
@ -276,6 +281,7 @@ export default function SelectedUsers({
icon={buttonIcon}
text={buttonText}
disabled={numberSelectedIds > General.MAX_USERS_IN_GM}
testID={`${testID}.start.button`}
/>
</Animated.View>
</Animated.View>

View file

@ -4,7 +4,7 @@
import React, {useCallback, useMemo} from 'react';
import {StyleSheet, View} from 'react-native';
import SyntaxHighlighter from 'react-syntax-highlighter';
import {github, monokai, solarizedDark, solarizedLight} from 'react-syntax-highlighter/dist/cjs/styles/hljs';
import {githubGist as github, monokai, solarizedDark, solarizedLight} from 'react-syntax-highlighter/dist/cjs/styles/hljs';
import {useTheme} from '@context/theme';

View file

@ -74,7 +74,7 @@ function createNativeElement({node, stylesheet, key, defaultColor, fontFamily, f
<Text
key={key}
style={Object.assign({color: defaultColor}, startingStyle)}
selectable={true}
selectable={selectable}
>
{value}
</Text>

View file

@ -5,6 +5,8 @@ import React, {useCallback} from 'react';
import {ListRenderItemInfo, StyleSheet, View} from 'react-native';
import {FlatList} from 'react-native-gesture-handler'; // Keep the FlatList from gesture handler so it works well with bottom sheet
import Loading from '@components/loading';
import TeamListItem from './team_list_item';
import type TeamModel from '@typings/database/models/servers/team';
@ -17,6 +19,8 @@ type Props = {
onPress: (id: string) => void;
testID?: string;
selectedTeamId?: string;
onEndReached?: () => void;
loading?: boolean;
}
const styles = StyleSheet.create({
@ -30,7 +34,7 @@ const styles = StyleSheet.create({
const keyExtractor = (item: TeamModel) => item.id;
export default function TeamList({teams, textColor, iconTextColor, iconBackgroundColor, onPress, testID, selectedTeamId}: Props) {
export default function TeamList({teams, textColor, iconTextColor, iconBackgroundColor, onPress, testID, selectedTeamId, onEndReached, loading = false}: Props) {
const renderTeam = useCallback(({item: t}: ListRenderItemInfo<Team|TeamModel>) => {
return (
<TeamListItem
@ -44,6 +48,11 @@ export default function TeamList({teams, textColor, iconTextColor, iconBackgroun
);
}, [textColor, iconTextColor, iconBackgroundColor, onPress, selectedTeamId]);
let footer;
if (loading) {
footer = (<Loading/>);
}
return (
<View style={styles.container}>
<FlatList
@ -52,6 +61,8 @@ export default function TeamList({teams, textColor, iconTextColor, iconBackgroun
keyExtractor={keyExtractor}
contentContainerStyle={styles.contentContainer}
testID={`${testID}.flat_list`}
onEndReached={onEndReached}
ListFooterComponent={footer}
/>
</View>
);

View file

@ -1,105 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import {addCurrentUserToTeam, handleTeamChange} from '@actions/remote/team';
import FormattedText from '@components/formatted_text';
import Empty from '@components/illustrations/no_team';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import BottomSheetContent from '@screens/bottom_sheet/content';
import {dismissBottomSheet} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import TeamList from './team_list';
import type TeamModel from '@typings/database/models/servers/team';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
color: theme.centerChannelColor,
lineHeight: 28,
marginTop: 16,
...typography('Heading', 400, 'Regular'),
},
description: {
color: theme.centerChannelColor,
marginTop: 8,
maxWidth: 334,
...typography('Body', 200, 'Regular'),
},
}));
type Props = {
otherTeams: TeamModel[];
title: string;
showTitle?: boolean;
}
export default function AddTeamSlideUp({otherTeams, title, showTitle = true}: Props) {
const intl = useIntl();
const serverUrl = useServerUrl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const onPressCreate = useCallback(() => {
//TODO Create team screen https://mattermost.atlassian.net/browse/MM-43622
dismissBottomSheet();
}, []);
const onPress = useCallback(async (teamId: string) => {
const {error} = await addCurrentUserToTeam(serverUrl, teamId);
if (!error) {
await dismissBottomSheet();
handleTeamChange(serverUrl, teamId);
}
}, [serverUrl]);
const hasOtherTeams = Boolean(otherTeams.length);
return (
<BottomSheetContent
buttonIcon='plus'
buttonText={intl.formatMessage({id: 'mobile.add_team.create_team', defaultMessage: 'Create a new team'})}
onPress={onPressCreate}
showButton={false}
showTitle={showTitle}
testID='team_sidebar.add_team_slide_up'
title={title}
>
{hasOtherTeams &&
<TeamList
teams={otherTeams}
onPress={onPress}
testID='team_sidebar.add_team_slide_up.team_list'
/>
}
{!hasOtherTeams &&
<View style={styles.empty}>
<Empty theme={theme}/>
<FormattedText
id='team_list.no_other_teams.title'
defaultMessage='No additional teams to join'
style={styles.title}
testID='team_sidebar.add_team_slide_up.no_other_teams.title'
/>
<FormattedText
id='team_list.no_other_teams.description'
defaultMessage='To join another team, ask a Team Admin for an invitation, or create your own team.'
style={styles.description}
testID='team_sidebar.add_team_slide_up.no_other_teams.description'
/>
</View>
}
</BottomSheetContent>
);
}

View file

@ -3,26 +3,16 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {useWindowDimensions, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {bottomSheet} from '@screens/navigation';
import {showModal} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {getTeamsSnapHeight} from '@utils/team_list';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import AddTeamSlideUp from './add_team_slide_up';
import type TeamModel from '@typings/database/models/servers/team';
type Props = {
otherTeams: TeamModel[];
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
@ -45,35 +35,26 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
export default function AddTeam({otherTeams}: Props) {
export default function AddTeam() {
const theme = useTheme();
const styles = getStyleSheet(theme);
const dimensions = useWindowDimensions();
const intl = useIntl();
const insets = useSafeAreaInsets();
const isTablet = useIsTablet();
const onPress = useCallback(preventDoubleTap(() => {
const title = intl.formatMessage({id: 'mobile.add_team.join_team', defaultMessage: 'Join Another Team'});
const renderContent = () => {
return (
<AddTeamSlideUp
otherTeams={otherTeams}
showTitle={!isTablet && Boolean(otherTeams.length)}
title={title}
/>
);
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const closeButtonId = 'close-join-team';
const options = {
topBar: {
leftButtons: [{
id: closeButtonId,
icon: closeButton,
testID: 'close.join_team.button',
}],
},
};
const height = getTeamsSnapHeight({dimensions, teams: otherTeams, insets});
bottomSheet({
closeButtonId: 'close-team_list',
renderContent,
snapPoints: [height, 10],
theme,
title,
});
}), [otherTeams, intl, isTablet, dimensions, theme]);
showModal(Screens.JOIN_TEAM, title, {closeButtonId}, options);
}), [intl]);
return (
<View style={styles.container}>

View file

@ -1,17 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {switchMap} from 'rxjs/operators';
import {queryMyTeams, queryOtherTeams} from '@queries/servers/team';
import {withServerUrl} from '@context/server';
import EphemeralStore from '@store/ephemeral_store';
import TeamSidebar from './team_sidebar';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const enhanced = withObservables([], ({serverUrl}: {serverUrl: string}) => {
// TODO https://mattermost.atlassian.net/browse/MM-43622
// const canCreateTeams = observeCurrentUser(database).pipe(
// switchMap((u) => (u ? of$(u.roles.split(' ')) : of$([]))),
@ -19,17 +16,9 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
// switchMap((r) => of$(hasPermission(r, Permissions.CREATE_TEAM, false))),
// );
const otherTeams = queryMyTeams(database).observe().pipe(
switchMap((mm) => {
// eslint-disable-next-line max-nested-callbacks
const ids = mm.map((m) => m.id);
return queryOtherTeams(database, ids).observe();
}),
);
return {
otherTeams,
canJoinOtherTeams: EphemeralStore.observeCanJoinOtherTeams(serverUrl),
};
});
export default withDatabase(enhanced(TeamSidebar));
export default withServerUrl(enhanced(TeamSidebar));

View file

@ -11,11 +11,9 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
import AddTeam from './add_team';
import TeamList from './team_list';
import type TeamModel from '@typings/database/models/servers/team';
type Props = {
iconPad?: boolean;
otherTeams: TeamModel[];
canJoinOtherTeams: boolean;
teamsCount: number;
}
@ -38,8 +36,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
export default function TeamSidebar({iconPad, otherTeams, teamsCount}: Props) {
const showAddTeam = otherTeams.length > 0;
export default function TeamSidebar({iconPad, canJoinOtherTeams, teamsCount}: Props) {
const initialWidth = teamsCount > 1 ? TEAM_SIDEBAR_WIDTH : 0;
const width = useSharedValue(initialWidth);
const marginTop = useSharedValue(iconPad ? 44 : 0);
@ -68,10 +65,8 @@ export default function TeamSidebar({iconPad, otherTeams, teamsCount}: Props) {
<Animated.View style={[styles.container, transform]}>
<Animated.View style={[styles.listContainer, serverStyle]}>
<TeamList testID='team_sidebar.team_list'/>
{showAddTeam && (
<AddTeam
otherTeams={otherTeams}
/>
{canJoinOtherTeams && (
<AddTeam/>
)}
</Animated.View>
</Animated.View>

View file

@ -48,6 +48,7 @@ const TutorialSwipeLeft = ({containerStyle, message, style, textStyles}: Props)
<View
pointerEvents='none'
style={[styles.container, containerStyle]}
testID='tutorial_swipe_left'
>
<View style={[styles.view, style]}>
<LongPressIllustration/>

View file

@ -48,6 +48,7 @@ const TutorialSwipeLeft = ({containerStyle, message, style, textStyles}: Props)
<View
pointerEvents='none'
style={[styles.container, containerStyle]}
testID='tutorial_swipe_left'
>
<View style={[styles.view, style]}>
<HandSwipeLeft fillColor={theme.centerChannelColor}/>

View file

@ -4,11 +4,14 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {StyleProp, Text, TouchableOpacity, View, ViewStyle} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {TITLE_HEIGHT} from '@screens/bottom_sheet/content';
import {bottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -19,6 +22,7 @@ import UsersList from './users_list';
import type UserModel from '@typings/database/models/servers/user';
const OVERFLOW_DISPLAY_LIMIT = 99;
const USER_ROW_HEIGHT = 40;
type Props = {
channelId: string;
@ -88,9 +92,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
marginBottom: 12,
},
listHeaderText: {
color: changeOpacity(theme.centerChannelColor, 0.56),
...typography('Body', 75, 'SemiBold'),
textTransform: 'uppercase',
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
},
};
});
@ -99,6 +102,7 @@ const UserAvatarsStack = ({breakAt = 3, channelId, location, style: baseContaine
const theme = useTheme();
const intl = useIntl();
const isTablet = useIsTablet();
const {bottom} = useSafeAreaInsets();
const showParticipantsList = useCallback(preventDoubleTap(() => {
const renderContent = () => (
@ -119,15 +123,16 @@ const UserAvatarsStack = ({breakAt = 3, channelId, location, style: baseContaine
/>
</>
);
const snap = bottomSheetSnapPoint(Math.min(users.length, 5), USER_ROW_HEIGHT, bottom);
bottomSheet({
closeButtonId: 'close-set-user-status',
renderContent,
initialSnapIndex: 1,
snapPoints: ['90%', '50%', 10],
snapPoints: ['90%', TITLE_HEIGHT + snap, 10],
title: intl.formatMessage({id: 'mobile.participants.header', defaultMessage: 'Thread Participants'}),
theme,
});
}), [isTablet, theme, users, channelId, location]);
}), [isTablet, theme, users, channelId, location, bottom]);
const displayUsers = users.slice(0, breakAt);
const overflowUsersCount = Math.min(users.length - displayUsers.length, OVERFLOW_DISPLAY_LIMIT);

View file

@ -25,6 +25,7 @@ type AtMentionItemProps = {
showFullName: boolean;
testID?: string;
isCustomStatusEnabled: boolean;
pictureContainerStyle?: StyleProp<ViewStyle>;
}
const getName = (user: UserProfile | UserModel | undefined, showFullName: boolean, isCurrentUser: boolean, intl: IntlShape) => {
@ -94,6 +95,7 @@ const UserItem = ({
showFullName,
testID,
isCustomStatusEnabled,
pictureContainerStyle,
}: AtMentionItemProps) => {
const theme = useTheme();
const style = getStyleFromTheme(theme);
@ -124,7 +126,7 @@ const UserItem = ({
style={[style.row, containerStyle]}
testID={userItemTestId}
>
<View style={style.rowPicture}>
<View style={[style.rowPicture, pictureContainerStyle]}>
<ProfilePicture
author={user}
size={24}

View file

@ -8,6 +8,7 @@ const DeepLinkType = {
Invalid: 'invalid',
Permalink: 'permalink',
Plugin: 'plugin',
Redirect: '_redirect',
} as const;
export default DeepLinkType;

View file

@ -9,7 +9,6 @@ export default keyMirror({
CHANNEL_SWITCH: null,
CLOSE_BOTTOM_SHEET: null,
CONFIG_CHANGED: null,
FETCHING_POSTS: null,
FREEZE_SCREEN: null,
GALLERY_ACTIONS: null,
LEAVE_CHANNEL: null,

View file

@ -3,6 +3,7 @@
const LaunchType = {
AddServer: 'add-server',
AddServerFromDeepLink: 'add-server-deeplink',
Normal: 'normal',
DeepLink: 'deeplink',
Notification: 'notification',

View file

@ -11,7 +11,7 @@ export const DEFAULT_EMOJIS = [
];
export const LARGE_ICON_SIZE = 32;
export const LARGE_CONTAINER_SIZE = 48;
export const REACTION_PICKER_HEIGHT = 72;
export const REACTION_PICKER_HEIGHT = 48;
export const SMALL_CONTAINER_SIZE = 44;
export const SMALL_ICON_BREAKPOINT = 410;
export const SMALL_ICON_SIZE = 28;

View file

@ -30,6 +30,7 @@ export const INTEGRATION_SELECTOR = 'IntegrationSelector';
export const INTERACTIVE_DIALOG = 'InteractiveDialog';
export const INVITE = 'Invite';
export const IN_APP_NOTIFICATION = 'InAppNotification';
export const JOIN_TEAM = 'JoinTeam';
export const LATEX = 'Latex';
export const LOGIN = 'Login';
export const MENTIONS = 'Mentions';
@ -96,6 +97,7 @@ export default {
INTERACTIVE_DIALOG,
INVITE,
IN_APP_NOTIFICATION,
JOIN_TEAM,
LATEX,
LOGIN,
MENTIONS,

View file

@ -6,4 +6,5 @@ export default {
TOWN_SQUARE_READ_ONLY_ERROR: 'api.post.create_post.town_square_read_only',
PLUGIN_DISMISSED_POST_ERROR: 'plugin.message_will_be_posted.dismiss_post',
SEND_EMAIL_WITH_DEFAULTS_ERROR: 'api.team.invite_members.unable_to_send_email_with_defaults.app_error',
TEAM_MEMBERSHIP_DENIAL_ERROR_ID: 'api.team.add_members.user_denied',
};

View file

@ -51,6 +51,8 @@ const WebsocketEvents = {
THREAD_UPDATED: 'thread_updated',
THREAD_FOLLOW_CHANGED: 'thread_follow_changed',
THREAD_READ_CHANGED: 'thread_read_changed',
DELETE_TEAM: 'delete_team',
RESTORE_TEAM: 'restore_team',
APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings',
CALLS_CHANNEL_ENABLED: `custom_${Calls.PluginId}_channel_enable_voice`,
CALLS_CHANNEL_DISABLED: `custom_${Calls.PluginId}_channel_disable_voice`,

View file

@ -104,11 +104,18 @@ export default class CategoryModel extends Model implements CategoryInterface {
Q.sortBy('last_post_at', Q.desc),
);
/** hasChannels : Returns a boolean indicating if the category has channels */
@lazy hasChannels = this.categoryChannels.observeCount().pipe(
map((c) => c > 0),
distinctUntilChanged(),
);
observeHasChannels = (canViewArchived: boolean, channelId: string) => {
return this.channels.observeWithColumns(['delete_at']).pipe(
map((channels) => {
if (canViewArchived) {
return channels.filter((c) => c.deleteAt === 0 || c.id === channelId).length > 0;
}
return channels.filter((c) => c.deleteAt === 0).length > 0;
}),
distinctUntilChanged(),
);
};
toCategoryWithChannels = async (): Promise<CategoryWithChannels> => {
const categoryChannels = await this.categoryChannels.fetch();

View file

@ -42,8 +42,8 @@ export const transformUserRecord = ({action, database, value}: TransformerArgs):
user.timezone = raw.timezone || null;
user.isBot = raw.is_bot ?? false;
user.remoteId = raw?.remote_id ?? null;
user.termsOfServiceId = raw?.terms_of_service_id ?? '';
user.termsOfServiceCreateAt = raw?.terms_of_service_create_at ?? 0;
user.termsOfServiceId = raw?.terms_of_service_id ?? (record?.termsOfServiceId || '');
user.termsOfServiceCreateAt = raw?.terms_of_service_create_at ?? (record?.termsOfServiceCreateAt || 0);
if (raw.status) {
user.status = raw.status;
}

View file

@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {subject} from '@store/fetching_thread_store';
export const useFetchingThreadState = (rootId: string) => {
const [isFetching, setIsFetching] = useState(false);
useEffect(() => {
const sub = subject.pipe(
switchMap((s) => of$(s[rootId] || false)),
distinctUntilChanged(),
).subscribe(setIsFetching);
return () => sub.unsubscribe();
}, []);
return isFetching;
};

View file

@ -24,10 +24,13 @@ export const useKeyboardTrackingPaused = (keyboardTrackingRef: RefObject<Keyboar
};
const commandListener = Navigation.events().registerCommandListener(() => {
if (!isPostDraftPaused.current) {
isPostDraftPaused.current = true;
keyboardTrackingRef.current?.pauseTracking(trackerId);
}
setTimeout(() => {
const visibleScreen = NavigationStore.getVisibleScreen();
if (!isPostDraftPaused.current && !screens.includes(visibleScreen)) {
isPostDraftPaused.current = true;
keyboardTrackingRef.current?.pauseTracking(trackerId);
}
});
});
const commandCompletedListener = Navigation.events().registerCommandCompletedListener(() => {

View file

@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {of as of$} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {getLoadingTeamChannelsSubject} from '@store/team_load_store';
export const useTeamsLoading = (serverUrl: string) => {
// const subject = getLoadingTeamChannelsSubject(serverUrl);
// const [loading, setLoading] = useState(subject.getValue() !== 0);
const [loading, setLoading] = useState(false);
useEffect(() => {
const sub = getLoadingTeamChannelsSubject(serverUrl).pipe(
switchMap((v) => of$(v !== 0)),
distinctUntilChanged(),
).subscribe(setLoading);
return () => sub.unsubscribe();
}, []);
return loading;
};

View file

@ -7,20 +7,20 @@ import {Notifications} from 'react-native-notifications';
import {appEntry, pushNotificationEntry, upgradeEntry} from '@actions/remote/entry';
import LocalConfig from '@assets/config.json';
import {Screens, DeepLink, Events, Launch, PushNotification} from '@constants';
import {DeepLink, Events, Launch, PushNotification} from '@constants';
import DatabaseManager from '@database/manager';
import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials';
import {getOnboardingViewed} from '@queries/app/global';
import {getThemeForCurrentTeam} from '@queries/servers/preference';
import {getCurrentUserId} from '@queries/servers/system';
import {queryMyTeams} from '@queries/servers/team';
import {goToScreen, resetToHome, resetToSelectServer, resetToTeams, resetToOnboarding} from '@screens/navigation';
import {resetToHome, resetToSelectServer, resetToTeams, resetToOnboarding} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {getLaunchPropsFromDeepLink} from '@utils/deep_link';
import {logInfo} from '@utils/log';
import {convertToNotificationData} from '@utils/notification';
import {parseDeepLink} from '@utils/url';
import type {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkWithData, LaunchProps} from '@typings/launch';
import type {DeepLinkWithData, LaunchProps} from '@typings/launch';
const initialNotificationTypes = [PushNotification.NOTIFICATION_TYPE.MESSAGE, PushNotification.NOTIFICATION_TYPE.SESSION];
@ -67,13 +67,18 @@ const launchAppFromNotification = async (notification: NotificationWithData, col
* @returns a redirection to a screen, either onboarding, add_server, login or home depending on the scenario
*/
const launchApp = async (props: LaunchProps, resetNavigation = true) => {
const launchApp = async (props: LaunchProps) => {
let serverUrl: string | undefined;
switch (props?.launchType) {
case Launch.DeepLink:
if (props.extra?.type !== DeepLink.Invalid) {
const extra = props.extra as DeepLinkWithData;
serverUrl = extra.data?.serverUrl;
const existingServer = DatabaseManager.searchUrl(extra.data!.serverUrl);
serverUrl = existingServer;
props.serverUrl = serverUrl || extra.data?.serverUrl;
if (!serverUrl) {
props.launchError = true;
}
}
break;
case Launch.Notification: {
@ -142,17 +147,17 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => {
return resetToOnboarding(props);
}
return launchToServer(props, resetNavigation);
return resetToSelectServer(props);
};
const launchToHome = async (props: LaunchProps) => {
let openPushNotification = false;
switch (props.launchType) {
case Launch.DeepLink:
// TODO:
// deepLinkEntry({props.serverUrl, props.extra});
case Launch.DeepLink: {
appEntry(props.serverUrl!);
break;
}
case Launch.Notification: {
const extra = props.extra as NotificationWithData;
openPushNotification = Boolean(props.serverUrl && !props.launchError && extra.userInteraction && extra.payload?.channel_id && !extra.payload?.userInfo?.local);
@ -185,55 +190,8 @@ const launchToHome = async (props: LaunchProps) => {
return resetToTeams();
};
const launchToServer = (props: LaunchProps, resetNavigation: Boolean) => {
if (resetNavigation) {
return resetToSelectServer(props);
}
// This is being called for Deeplinks, but needs to be revisited when
// the implementation of deep links is complete
const title = '';
return goToScreen(Screens.SERVER, title, {...props});
};
export const relaunchApp = (props: LaunchProps, resetNavigation = false) => {
return launchApp(props, resetNavigation);
};
export const getLaunchPropsFromDeepLink = (deepLinkUrl: string, coldStart = false): LaunchProps => {
const parsed = parseDeepLink(deepLinkUrl);
const launchProps: LaunchProps = {
launchType: Launch.DeepLink,
coldStart,
};
switch (parsed.type) {
case DeepLink.Invalid:
launchProps.launchError = true;
break;
case DeepLink.Channel: {
const parsedData = parsed.data as DeepLinkChannel;
(launchProps.extra as DeepLinkWithData).data = parsedData;
break;
}
case DeepLink.DirectMessage: {
const parsedData = parsed.data as DeepLinkDM;
(launchProps.extra as DeepLinkWithData).data = parsedData;
break;
}
case DeepLink.GroupMessage: {
const parsedData = parsed.data as DeepLinkGM;
(launchProps.extra as DeepLinkWithData).data = parsedData;
break;
}
case DeepLink.Permalink: {
const parsedData = parsed.data as DeepLinkPermalink;
(launchProps.extra as DeepLinkWithData).data = parsedData;
break;
}
}
return launchProps;
export const relaunchApp = (props: LaunchProps) => {
return launchApp(props);
};
export const getLaunchPropsFromNotification = async (notification: NotificationWithData, coldStart = false): Promise<LaunchProps> => {

View file

@ -2,7 +2,6 @@
// See LICENSE.txt for license information.
import {AppState, DeviceEventEmitter, Platform} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import {
Notification,
NotificationAction,
@ -29,6 +28,7 @@ import {getIsCRTEnabled, getThreadById} from '@queries/servers/thread';
import {dismissOverlay, showOverlay} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {isBetaApp} from '@utils/general';
import {isMainActivity, isTablet} from '@utils/helpers';
import {logInfo} from '@utils/log';
import {convertToNotificationData} from '@utils/notification';
@ -248,7 +248,7 @@ class PushNotifications {
if (Platform.OS === 'ios') {
prefix = Device.PUSH_NOTIFY_APPLE_REACT_NATIVE;
if (DeviceInfo.getBundleId().includes('rnbeta')) {
if (isBetaApp) {
prefix = `${prefix}beta`;
}
} else {

View file

@ -11,9 +11,9 @@ import {Events, Sso} from '@constants';
import {MIN_REQUIRED_VERSION} from '@constants/supported_server';
import {DEFAULT_LOCALE, getTranslations, t} from '@i18n';
import {getServerCredentials} from '@init/credentials';
import {getLaunchPropsFromDeepLink, relaunchApp} from '@init/launch';
import * as analytics from '@managers/analytics';
import {getAllServers} from '@queries/app/servers';
import {handleDeepLink} from '@utils/deep_link';
import {logError} from '@utils/log';
import type {jsAndNativeErrorHandler} from '@typings/global/error_handling';
@ -64,8 +64,7 @@ class GlobalEventHandler {
}
if (event.url) {
const props = getLaunchPropsFromDeepLink(event.url);
relaunchApp(props);
handleDeepLink(event.url);
}
};

View file

@ -185,7 +185,7 @@ class SessionManager {
await storeOnboardingViewedValue(false);
}
relaunchApp({launchType, serverUrl, displayName}, true);
relaunchApp({launchType, serverUrl, displayName});
}
};
@ -197,7 +197,7 @@ class SessionManager {
const activeServerUrl = await DatabaseManager.getActiveServerUrl();
const serverDisplayName = await getServerDisplayName(serverUrl);
await relaunchApp({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName}, true);
await relaunchApp({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName});
if (activeServerUrl) {
addNewServer(getThemeFromState(), serverUrl, serverDisplayName);
} else {

View file

@ -21,10 +21,10 @@ import {isMainActivity} from '@utils/helpers';
import {logError} from '@utils/log';
const WAIT_TO_CLOSE = toMilliseconds({seconds: 15});
const WAIT_UNTIL_NEXT = toMilliseconds({seconds: 20});
const WAIT_UNTIL_NEXT = toMilliseconds({seconds: 5});
class WebsocketManager {
private connectedSubjects: {[serverUrl: string]: BehaviorSubject<boolean>} = {};
private connectedSubjects: {[serverUrl: string]: BehaviorSubject<WebsocketConnectedState>} = {};
private clients: Record<string, WebSocketClient> = {};
private connectionTimerIDs: Record<string, DebouncedFunc<() => void>> = {};
@ -69,7 +69,7 @@ class WebsocketManager {
}
delete this.clients[serverUrl];
this.getConnectedSubject(serverUrl).next(false);
this.getConnectedSubject(serverUrl).next('not_connected');
delete this.connectedSubjects[serverUrl];
};
@ -96,7 +96,7 @@ class WebsocketManager {
const client = this.clients[url];
if (client.isConnected()) {
client.close(true);
this.getConnectedSubject(url).next(false);
this.getConnectedSubject(url).next('not_connected');
}
}
};
@ -107,6 +107,7 @@ class WebsocketManager {
if (clientUrl === activeServerUrl) {
this.initializeClient(clientUrl);
} else {
this.getConnectedSubject(clientUrl).next('connecting');
const bounce = debounce(this.initializeClient.bind(this, clientUrl), WAIT_UNTIL_NEXT);
this.connectionTimerIDs[clientUrl] = bounce;
bounce();
@ -118,7 +119,7 @@ class WebsocketManager {
return this.clients[serverUrl]?.isConnected();
};
public observeConnected = (serverUrl: string) => {
public observeWebsocketState = (serverUrl: string) => {
return this.getConnectedSubject(serverUrl).asObservable().pipe(
distinctUntilChanged(),
);
@ -126,7 +127,7 @@ class WebsocketManager {
private getConnectedSubject = (serverUrl: string) => {
if (!this.connectedSubjects[serverUrl]) {
this.connectedSubjects[serverUrl] = new BehaviorSubject(this.isConnected(serverUrl));
this.connectedSubjects[serverUrl] = new BehaviorSubject(this.isConnected(serverUrl) ? 'connected' : 'not_connected');
}
return this.connectedSubjects[serverUrl];
@ -153,13 +154,13 @@ class WebsocketManager {
private onFirstConnect = (serverUrl: string) => {
this.startPeriodicStatusUpdates(serverUrl);
handleFirstConnect(serverUrl);
this.getConnectedSubject(serverUrl).next(true);
this.getConnectedSubject(serverUrl).next('connected');
};
private onReconnect = (serverUrl: string) => {
private onReconnect = async (serverUrl: string) => {
this.startPeriodicStatusUpdates(serverUrl);
handleReconnect(serverUrl);
this.getConnectedSubject(serverUrl).next(true);
await handleReconnect(serverUrl);
this.getConnectedSubject(serverUrl).next('connected');
};
private onWebsocketClose = async (serverUrl: string, connectFailCount: number, lastDisconnect: number) => {
@ -168,7 +169,7 @@ class WebsocketManager {
await handleClose(serverUrl, lastDisconnect);
this.stopPeriodicStatusUpdates(serverUrl);
this.getConnectedSubject(serverUrl).next(false);
this.getConnectedSubject(serverUrl).next('not_connected');
}
};

View file

@ -69,7 +69,7 @@ const ChannelInfoStartButton = ({
destructiveText={leaveText}
destructiveIconName={'phone-hangup'}
isDestructive={alreadyInCall}
testID='channel_info.options.join_start_call.option'
testID='channel_info.channel_actions.join_start_call.action'
/>
);
};

View file

@ -273,7 +273,7 @@ const CallScreen = ({
const callThreadOptionTitle = intl.formatMessage({id: 'mobile.calls_call_thread', defaultMessage: 'Call Thread'});
const recordOptionTitle = intl.formatMessage({id: 'mobile.calls_record', defaultMessage: 'Record'});
const stopRecordingOptionTitle = intl.formatMessage({id: 'mobile.calls_stop_recording', defaultMessage: 'Stop Recording'});
const openChannelOptionTitle = intl.formatMessage({id: 'mobile.calls_call_thread', defaultMessage: 'Open Channel'});
const openChannelOptionTitle = intl.formatMessage({id: 'mobile.calls_open_channel', defaultMessage: 'Open Channel'});
useEffect(() => {
mergeNavigationOptions('Call', {

View file

@ -9,6 +9,7 @@ import {FAVORITES_CATEGORY} from '@constants/categories';
import {MM_TABLES} from '@constants/database';
import {makeCategoryChannelId} from '@utils/categories';
import {pluckUnique} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {observeChannelsByLastPostAt} from './channel';
@ -38,16 +39,10 @@ export const queryCategoriesByTeamIds = (database: Database, teamIds: string[])
export async function prepareCategoriesAndCategoriesChannels(operator: ServerDataOperator, categories: CategoryWithChannels[], prune = false) {
try {
const modelPromises: Array<Promise<Model[]>> = [];
const preparedCategories = prepareCategories(operator, categories);
if (preparedCategories) {
modelPromises.push(preparedCategories);
}
const preparedCategoryChannels = prepareCategoryChannels(operator, categories);
if (preparedCategoryChannels) {
modelPromises.push(preparedCategoryChannels);
}
const modelPromises: Array<Promise<Model[]>> = [
prepareCategories(operator, categories),
prepareCategoryChannels(operator, categories),
];
const models = await Promise.all(modelPromises);
const flattenedModels = models.flat();
@ -71,7 +66,8 @@ export async function prepareCategoriesAndCategoriesChannels(operator: ServerDat
}
return flattenedModels;
} catch {
} catch (error) {
logDebug('error while preparing categories and categories channels', error);
return [];
}
}

View file

@ -209,6 +209,7 @@ export const queryPinnedPostsInChannel = (database: Database, channelId: string)
Q.where('channel_id', channelId),
Q.where('is_pinned', Q.eq(true)),
),
Q.sortBy('create_at', Q.asc),
);
};

View file

@ -404,7 +404,13 @@ export async function setCurrentTeamAndChannelId(operator: ServerDataOperator, t
export const observeLastUnreadChannelId = (database: Database): Observable<string> => {
return querySystemValue(database, SYSTEM_IDENTIFIERS.LAST_UNREAD_CHANNEL_ID).observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$({value: ''}))),
switchMap((model) => of$(model.value)),
switchMap((model) => {
if (model.value) {
return of$(model.value);
}
return observeCurrentChannelId(database);
}),
);
};

View file

@ -165,31 +165,6 @@ export const getLastTeam = async (database: Database, ignoreIdForDefault?: strin
return getDefaultTeamId(database, ignoreIdForDefault);
};
export async function syncTeamTable(operator: ServerDataOperator, teams: Team[]) {
try {
const deletedTeams = teams.filter((t) => t.delete_at > 0).map((t) => t.id);
const deletedSet = new Set(deletedTeams);
const availableTeams = teams.filter((a) => !deletedSet.has(a.id));
const models = [];
if (deletedTeams.length) {
const notAvailable = await operator.database.get<TeamModel>(TEAM).query(Q.where('id', Q.oneOf(deletedTeams))).fetch();
const deletions = await Promise.all(notAvailable.map((t) => prepareDeleteTeam(t)));
for (const d of deletions) {
models.push(...d);
}
}
models.push(...await operator.handleTeam({teams: availableTeams, prepareRecordsOnly: true}));
if (models.length) {
await operator.batchRecords(models);
}
return {};
} catch (error) {
return {error};
}
}
export const getDefaultTeamId = async (database: Database, ignoreId?: string) => {
const user = await getCurrentUser(database);
const config = await getConfig(database);

View file

@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database} from '@nozbe/watermelondb';
import {of as of$, combineLatest} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {observeLicense, observeConfigBooleanValue, observeConfigValue, observeConfigIntValue} from './system';
import {observeCurrentUser} from './user';
export const observeShowToS = (database: Database) => {
const isLicensed = observeLicense(database).pipe(
switchMap((lcs) => (lcs ? of$(lcs.IsLicensed === 'true') : of$(false))),
);
const currentUser = observeCurrentUser(database);
const customTermsOfServiceEnabled = observeConfigBooleanValue(database, 'EnableCustomTermsOfService');
const customTermsOfServiceId = observeConfigValue(database, 'CustomTermsOfServiceId');
const customTermsOfServicePeriod = observeConfigIntValue(database, 'CustomTermsOfServiceReAcceptancePeriod');
const showToS = combineLatest([
isLicensed,
customTermsOfServiceEnabled,
currentUser,
customTermsOfServiceId,
customTermsOfServicePeriod,
]).pipe(
switchMap(([lcs, cfg, user, id, period]) => {
if (!lcs || !cfg) {
return of$(false);
}
if (user?.termsOfServiceId !== id) {
return of$(true);
}
const timeElapsed = Date.now() - (user?.termsOfServiceCreateAt || 0);
return of$(timeElapsed > (period * 24 * 60 * 60 * 1000));
}),
distinctUntilChanged(),
);
return showToS;
};

View file

@ -141,7 +141,10 @@ export const prepareThreadsFromReceivedPosts = async (operator: ServerDataOperat
};
export const queryThreadsInTeam = (database: Database, teamId: string, onlyUnreads?: boolean, hasReplies?: boolean, isFollowing?: boolean, sort?: boolean, earliest?: number): Query<ThreadModel> => {
const query: Q.Clause[] = [];
const query: Q.Clause[] = [
Q.experimentalNestedJoin(POST, CHANNEL),
Q.on(POST, Q.on(CHANNEL, Q.where('delete_at', 0))),
];
if (isFollowing) {
query.push(Q.where('is_following', true));
@ -189,30 +192,34 @@ export const queryThreads = (database: Database, teamId?: string, onlyUnreads =
Q.where('reply_count', Q.gt(0)),
];
// Only get threads from available channel
const channelCondition: Q.Condition[] = [
Q.where('delete_at', 0),
];
// If teamId is specified, only get threads in that team
if (teamId) {
let condition: Q.Condition = Q.where('team_id', teamId);
if (includeDmGm) {
condition = Q.or(
Q.where('team_id', teamId),
Q.where('team_id', ''),
channelCondition.push(
Q.or(
Q.where('team_id', teamId),
Q.where('team_id', ''),
),
);
} else {
channelCondition.push(Q.where('team_id', teamId));
}
query.push(
Q.experimentalNestedJoin(POST, CHANNEL),
Q.on(POST, Q.on(CHANNEL, condition)),
);
} else if (!includeDmGm) {
// fetching all threads from all teams
// excluding DM/GM channels
query.push(
Q.experimentalNestedJoin(POST, CHANNEL),
Q.on(POST, Q.on(CHANNEL, Q.where('team_id', Q.notEq('')))),
);
channelCondition.push(Q.where('team_id', Q.notEq('')));
}
query.push(
Q.experimentalNestedJoin(POST, CHANNEL),
Q.on(POST, Q.on(CHANNEL, Q.and(...channelCondition))),
);
if (onlyUnreads) {
query.push(Q.where('unread_replies', Q.gt(0)));
}

View file

@ -2,14 +2,12 @@
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import {combineLatest, Observable, of as of$} from 'rxjs';
import {combineLatest, of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {MM_TABLES} from '@constants/database';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {observeMyChannel} from '@queries/servers/channel';
import {isChannelAdmin} from '@utils/user';
import {queryPreferencesByCategoryAndName} from './preference';
import {observeCurrentUserId, observeLicense, getCurrentUserId, getConfig, getLicense, observeConfigValue} from './system';
@ -106,24 +104,17 @@ export const observeUserIsTeamAdmin = (database: Database, userId: string, teamI
const id = `${teamId}-${userId}`;
return database.get<TeamMembershipModel>(TEAM_MEMBERSHIP).query(
Q.where('id', Q.eq(id)),
).observe().pipe(
).observeWithColumns(['scheme_admin']).pipe(
switchMap((tm) => of$(tm.length ? tm[0].schemeAdmin : false)),
);
};
export const observeUserIsChannelAdmin = (database: Database, userId: string, channelId: string) => {
const id = `${channelId}-${userId}`;
const myChannelRoles = observeMyChannel(database, channelId).pipe(
switchMap((mc) => of$(mc?.roles || '')),
distinctUntilChanged(),
);
const channelSchemeAdmin = database.get<ChannelMembershipModel>(CHANNEL_MEMBERSHIP).query(
return database.get<ChannelMembershipModel>(CHANNEL_MEMBERSHIP).query(
Q.where('id', Q.eq(id)),
).observe().pipe(
).observeWithColumns(['scheme_admin']).pipe(
switchMap((cm) => of$(cm.length ? cm[0].schemeAdmin : false)),
distinctUntilChanged(),
);
return combineLatest([myChannelRoles, channelSchemeAdmin]).pipe(
switchMap(([mcr, csa]) => of$(isChannelAdmin(mcr) || csa)),
) as Observable<boolean>;
};

View file

@ -26,8 +26,8 @@ const styles = StyleSheet.create({
icon_container: {
width: 24,
height: 24,
marginTop: 2,
marginRight: 8,
top: -1,
marginRight: 4,
},
});

View file

@ -45,9 +45,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
},
separator: {
height: 1,
right: 16,
right: 20,
borderTopWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.08),
marginBottom: 20,
},
};
});
@ -83,7 +84,7 @@ const BottomSheetContent = ({buttonText, buttonIcon, children, disableButton, on
</>
{showButton && (
<>
<View style={[styles.separator, {width: separatorWidth, marginBottom: (isTablet ? TITLE_SEPARATOR_MARGIN_TABLET : TITLE_SEPARATOR_MARGIN)}]}/>
<View style={[styles.separator, {width: separatorWidth}]}/>
<Button
disabled={disableButton}
onPress={onPress}

View file

@ -28,6 +28,7 @@ type SlideUpPanelProps = {
}
export const PADDING_TOP_MOBILE = 20;
export const PADDING_TOP_TABLET = 8;
const BottomSheet = ({closeButtonId, componentId, initialSnapIndex = 0, renderContent, snapPoints = ['90%', '50%', 50], testID}: SlideUpPanelProps) => {
const sheetRef = useRef<RNBottomSheet>(null);
@ -100,7 +101,7 @@ const BottomSheet = ({closeButtonId, componentId, initialSnapIndex = 0, renderCo
const backdropStyle = useAnimatedStyle(() => ({
opacity: withTiming(backdropOpacity.value, {duration: 250, easing: Easing.inOut(Easing.linear)}),
backgroundColor: 'rgba(0, 0, 0, 0.5)',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}));
const renderBackdrop = () => {
@ -126,9 +127,11 @@ const BottomSheet = ({closeButtonId, componentId, initialSnapIndex = 0, renderCo
<View
style={{
backgroundColor: theme.centerChannelBg,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderWidth: isTablet ? 0 : 1,
opacity: 1,
paddingHorizontal: 20,
paddingTop: isTablet ? 0 : PADDING_TOP_MOBILE,
paddingTop: isTablet ? PADDING_TOP_TABLET : PADDING_TOP_MOBILE,
height: '100%',
width: '100%',
alignSelf: 'center',
@ -154,7 +157,7 @@ const BottomSheet = ({closeButtonId, componentId, initialSnapIndex = 0, renderCo
<RNBottomSheet
ref={sheetRef}
snapPoints={snapPoints}
borderRadius={10}
borderRadius={12}
initialSnap={snapPoints.length - 1}
renderContent={renderContainerContent}
onCloseEnd={handleCloseEnd}

View file

@ -4,7 +4,7 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
import AddPeopleBox from '@components/channel_actions/add_people_box';
// import AddPeopleBox from '@components/channel_actions/add_people_box';
import FavoriteBox from '@components/channel_actions/favorite_box';
import InfoBox from '@components/channel_actions/info_box';
import SetHeaderBox from '@components/channel_actions/set_header_box';
@ -39,9 +39,10 @@ const styles = StyleSheet.create({
},
});
const IntroOptions = ({channelId, header, favorite, people}: Props) => {
const IntroOptions = ({channelId, header, favorite}: Props) => {
return (
<View style={styles.container}>
{/* Add back in after MM-47655 is resolved. https://mattermost.atlassian.net/browse/MM-47655
{people &&
<AddPeopleBox
channelId={channelId}
@ -49,6 +50,7 @@ const IntroOptions = ({channelId, header, favorite, people}: Props) => {
testID='channel_post_list.intro_options.add_people.action'
/>
}
*/}
{header &&
<SetHeaderBox
channelId={channelId}

View file

@ -9,9 +9,10 @@ import {isTypeDMorGM} from '@utils/channel';
import EditChannel from './edit_channel';
import IgnoreMentions from './ignore_mentions';
import Members from './members';
import PinnedMessages from './pinned_messages';
// import Members from './members';
type Props = {
channelId: string;
type?: ChannelType;
@ -28,11 +29,16 @@ const Options = ({channelId, type, callsEnabled}: Props) => {
}
{/*<NotificationPreference channelId={channelId}/>*/}
<PinnedMessages channelId={channelId}/>
{/* Add back in after MM-47653 is resolved. https://mattermost.atlassian.net/browse/MM-47653
{type !== General.DM_CHANNEL &&
<Members channelId={channelId}/>
}
*/}
{callsEnabled && !isDMorGM && // if calls is not enabled, copy link will show in the channel actions
<CopyChannelLinkOption channelId={channelId}/>
<CopyChannelLinkOption
channelId={channelId}
testID='channel_info.options.copy_channel_link.option'
/>
}
{type !== General.DM_CHANNEL && type !== General.GM_CHANNEL &&
<EditChannel channelId={channelId}/>

View file

@ -31,7 +31,7 @@ const Code = ({code, language, textStyle}: Props) => {
<SyntaxHiglight
code={code}
language={language}
selectable={managedConfig.copyAndPasteProtection !== 'false'}
selectable={managedConfig.copyAndPasteProtection !== 'true'}
textStyle={textStyle}
/>
</SafeAreaView>

View file

@ -396,6 +396,7 @@ export default function CreateDirectMessage({
onPress={startConversation}
buttonIcon={'forum-outline'}
buttonText={formatMessage(messages.buttonText)}
testID='create_direct_message'
/>
</SafeAreaView>
);

View file

@ -56,7 +56,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const CustomStatusInput = ({emoji, isStatusSet, onChangeText, onClearHandle, onOpenEmojiPicker, text, theme}: Props) => {
const style = getStyleSheet(theme);
const intl = useIntl();
const placeholder = intl.formatMessage({id: 'custom_status.set_status', defaultMessage: 'Set a Status'});
const placeholder = intl.formatMessage({id: 'custom_status.set_status', defaultMessage: 'Set a custom status'});
return (
<>

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