Compare commits

...

20 commits

Author SHA1 Message Date
unified-ci-app[bot]
25563d1598
Bump app build number to 582 (#8399)
Some checks failed
github-release / test (push) Has been cancelled
github-release / build-ios-unsigned (push) Has been cancelled
github-release / build-android-unsigned (push) Has been cancelled
github-release / release (push) Has been cancelled
Co-authored-by: Anka <runner@Mac-1733465277776.local>
2024-12-06 08:57:53 +02:00
Mattermost Build
a84249a92f
fix: fixed image overflow in message attachments (#8361) (#8398)
* fix: fixed image overflow in message attachments

* fix: fixed image overflow in message attachments

(cherry picked from commit 9607f42243)

Co-authored-by: fxnm <47592197+fxnm@users.noreply.github.com>
2024-12-06 08:51:12 +02:00
Mattermost Build
fc87d16578
Fix join/leave messages not showing (#8396) (#8397)
(cherry picked from commit d996ed8133)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2024-12-06 08:50:56 +02:00
unified-ci-app[bot]
3be3a15976
Bump app build number to 581 (#8394)
Co-authored-by: Anka <runner@Mac-1733298406590.local>
2024-12-04 10:28:24 +02:00
Mattermost Build
6d7370972d
Fix 60879 (#8344) (#8393)
* Fix 60879

* Address feedback

* Address feedback

* Add tests

(cherry picked from commit 7f21754b78)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2024-12-04 08:02:33 +02:00
Mattermost Build
298666755c
Surround text on file info for searches (#8391) (#8392)
(cherry picked from commit 9632ce5038)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2024-12-04 08:02:14 +02:00
unified-ci-app[bot]
a736203f7c
Bump app build number to 580 (#8387)
Co-authored-by: runner <runner@Mac-1732891152535.local>
2024-11-29 18:13:09 +02:00
Mattermost Build
8468d81936
Add post props validation (#8323) (#8383)
* Validate props

* Add calls changes and fix attachments

* Address feedback

(cherry picked from commit 732b17a75c)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2024-11-28 17:15:02 +02:00
Mattermost Build
68aa016921
Fix MM-61710 (#8368) (#8382)
(cherry picked from commit f911a29e4c)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2024-11-28 08:52:28 +02:00
unified-ci-app[bot]
64581b8b2d
Bump app build number to 578 (#8377)
Co-authored-by: runner <runner@Mac-1732619234232.local>
2024-11-26 14:36:51 +02:00
Mattermost Build
dbf2eac879
Ensure myTeam.id is not undefined (#8375) (#8376)
(cherry picked from commit 7123c7f3c0)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2024-11-26 14:30:20 +02:00
unified-ci-app[bot]
fdac66adaf
Bump app build and version number (#8365)
* Bump app build number to 576

* Bump app version number to 2.23.0

---------

Co-authored-by: runner <runner@Mac-1732098906220.local>
2024-11-20 13:21:44 +01:00
Mattermost Build
eb8d3655e1
bump cross-spawn (#8357) (#8360) 2024-11-19 21:27:06 +08:00
Mattermost Build
c256c8f761
fix entry logic to not re-fetch data for the initial team (#8349) (#8354) 2024-11-18 18:19:23 +08:00
Mattermost Build
4cdf36c771
MM-61148 Rewrite table parsing and improve error handling around Markdown code (#8300) (#8352)
* MM-61148 Rewrite table parsing based off cmark-gfm

* MM-61148 Add better error handling to Markdown code

* Use logError instead of console.error

* Switch back to published release

* Update import paths

(cherry picked from commit 0efa409023)

Co-authored-by: Harrison Healey <harrisonmhealey@gmail.com>
2024-11-15 12:30:55 -05:00
Mattermost Build
6aa27d18e3
Update README.md (#8330) (#8351)
* Update README.md

* Update full_description.txt

* Update changelog

(cherry picked from commit 1f28e9bd8b)

Co-authored-by: Amy Blais <29708087+amyblais@users.noreply.github.com>
2024-11-15 08:10:20 -05:00
Mattermost Build
2554cd5ae6
Animate gif as default until MM-22242 is implemented (#8339) (#8341)
(cherry picked from commit 6e032a7a6f)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2024-11-13 07:36:09 -05:00
Mattermost Build
a4082402fe
remove patch warning (#8334) (#8338)
(cherry picked from commit 8c87ec1fc3)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2024-11-13 09:45:11 +08:00
Mattermost Build
ad46cec886
remove warning due to patch version missmatch (#8332) (#8336)
(cherry picked from commit f6bc8ae50b)

Co-authored-by: Guillermo Vayá <guillermo.vaya@mattermost.com>
2024-11-13 09:25:42 +08:00
Mattermost Build
d9e3bc9b6b
Login improvement (#8306) (#8333)
* refactor to improve requests on login

* include in team sidebar order by display name teams not present in the order preference

* Fix iOS reload while developing

* fix code causing tests to fail

* feedback review

* update network-client

(cherry picked from commit ea2cb45a18)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2024-11-13 08:13:19 +08:00
100 changed files with 1807 additions and 614 deletions

View file

@ -1,6 +1,6 @@
# Mattermost Mobile v2
- **Minimum Server versions:** Current ESR version (9.5.0+)
- **Minimum Server versions:** Current ESR version (9.11.0+)
- **Supported iOS versions:** 13.4+
- **Supported Android versions:** 7.0+

View file

@ -111,8 +111,8 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 572
versionName "2.22.0"
versionCode 582
versionName "2.23.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}

View file

@ -160,7 +160,7 @@ export async function markPostAsDeleted(serverUrl: string, post: Post, prepareRe
p.message = '';
p.messageSource = '';
p.metadata = null;
p.props = undefined;
p.props = null;
});
if (!prepareRecordsOnly) {

View file

@ -105,13 +105,13 @@ const mockClient = {
getUser: jest.fn((userId: string) => ({...user, id: userId})),
getMyChannels: jest.fn((teamId: string) => ([{id: channelId, name: 'channel1', creatorId: user.id, team_id: teamId}])),
getMyChannelMembers: jest.fn(() => ([{id: user.id + '-' + channelId, user_id: user.id, channel_id: channelId, roles: ''}])),
getCategories: jest.fn((userId: string, teamId: string) => ({categories: [{id: 'categoryid', channel_id: [channelId], team_id: teamId}], order: ['categoryid']})),
getCategories: jest.fn((userId: string, teamId: string) => ({categories: [{id: 'categoryid', channel_ids: [channelId], team_id: teamId}], order: ['categoryid']})),
viewMyChannel: jest.fn(),
getTeamByName: jest.fn((teamName: string) => ({id: teamId, name: teamName})),
getTeam: jest.fn((id: string) => ({id, name: 'teamname'})),
addToTeam: jest.fn((teamId: string, userId: string) => ({id: userId + '-' + teamId, user_id: userId, team_id: teamId, roles: ''})),
getUserByUsername: jest.fn((username: string) => ({...user, id: 'userid2', username})),
createDirectChannel: jest.fn((userId1: string, userId2: string) => ({id: userId1 + '__' + userId2, team_id: '', type: 'D', display_name: 'displayname'})),
createDirectChannel: jest.fn((userIds: string[]) => ({id: userIds[0] + '__' + userIds[1], team_id: '', type: 'D', display_name: 'displayname'})),
getChannels: jest.fn((teamId: string) => ([{id: channelId, name: 'channel1', creatorId: user.id, team_id: teamId}])),
getArchivedChannels: jest.fn((teamId: string) => ([{id: channelId + 'old', name: 'channel1old', creatorId: user.id, team_id: teamId, delete_at: 1}])),
createGroupChannel: jest.fn(() => ({id: 'groupid', team_id: '', type: 'G', display_name: 'displayname'})),
@ -129,6 +129,11 @@ const mockClient = {
convertChannelToPrivate: jest.fn(),
getGroupMessageMembersCommonTeams: jest.fn(() => ({id: teamId, name: 'teamname'})),
convertGroupMessageToPrivateChannel: jest.fn((channelId: string) => ({id: channelId, name: 'channel1', creatorId: user.id, type: 'P'})),
getPosts: jest.fn(() => ({
order: [],
posts: [],
previousPostId: '',
})),
};
const teamId = 'teamid1';

View file

@ -45,6 +45,7 @@ import type ChannelModel from '@typings/database/models/servers/channel';
import type {IntlShape} from 'react-intl';
export type MyChannelsRequest = {
teamId?: string;
categories?: CategoryWithChannels[];
channels?: Channel[];
memberships?: ChannelMembership[];
@ -441,7 +442,7 @@ export async function fetchMyChannelsForTeam(serverUrl: string, teamId: string,
setTeamLoading(serverUrl, false);
}
return {channels, memberships, categories};
return {channels, memberships, categories, teamId};
} catch (error) {
logDebug('error on fetchMyChannelsForTeam', getFullErrorMessage(error));
if (!fetchOnly) {

View file

@ -10,7 +10,7 @@ import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {type MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
import {fetchRoles} from '@actions/remote/role';
import {fetchConfigAndLicense, fetchDataRetentionPolicy} from '@actions/remote/systems';
import {fetchMyTeams, fetchTeamsChannelsAndUnreadPosts, handleKickFromTeam, type MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {fetchMyTeams, fetchTeamsChannelsThreadsAndUnreadPosts, handleKickFromTeam, type MyTeamsRequest, updateCanJoinTeams} from '@actions/remote/team';
import {syncTeamThreads} from '@actions/remote/thread';
import {fetchMe, type MyUserRequest, updateAllUsersSince, autoUpdateTimezone} from '@actions/remote/user';
import {General, Preferences, Screens} from '@constants';
@ -66,9 +66,6 @@ export type EntryResponse = {
error: unknown;
}
const FETCH_MISSING_DM_TIMEOUT = 2500;
export const FETCH_UNREADS_TIMEOUT = 2500;
export const entry = async (serverUrl: string, teamId?: string, channelId?: string, since = 0): Promise<EntryResponse> => {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const result = entryRest(serverUrl, teamId, channelId, since);
@ -311,8 +308,7 @@ const fetchAlternateTeamData = async (
let initialTeamId = '';
let chData;
for (const teamId of availableTeamIds) {
// eslint-disable-next-line no-await-in-loop
for await (const teamId of availableTeamIds) {
chData = await fetchMyChannelsForTeam(serverUrl, teamId, includeDeleted, since, fetchOnly, false, isCRTEnabled);
const chError = chData.error;
if (isErrorWithStatusCode(chError) && chError.status_code === 403) {
@ -371,49 +367,64 @@ async function entryInitialChannelId(database: Database, requestedChannelId = ''
async function restDeferredAppEntryActions(
serverUrl: string, since: number, currentUserId: string, currentUserLocale: string, preferences: PreferenceType[] | undefined,
config: ClientConfig, license: ClientLicense | undefined, teamData: MyTeamsRequest, chData: MyChannelsRequest | undefined,
initialTeamId?: string, initialChannelId?: string) {
setTimeout(async () => {
if (chData?.channels?.length && chData.memberships?.length) {
// defer fetching posts for unread channels on initial team
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
}
}, FETCH_UNREADS_TIMEOUT);
initialTeamId?: string, initialChannelId?: string,
) {
const isCRTEnabled = (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads, config.Version)) || false;
const directChannels = chData?.channels?.filter(isDMorGM);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
// sidebar DM & GM profiles
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId);
}
updateAllUsersSince(serverUrl, since);
updateCanJoinTeams(serverUrl);
// defer fetch channels and unread posts for other teams
if (teamData.teams?.length && teamData.memberships?.length) {
fetchTeamsChannelsAndUnreadPosts(serverUrl, since, teamData.teams, teamData.memberships, initialTeamId);
}
if (preferences && processIsCRTEnabled(preferences, config.CollapsedThreads, config.FeatureFlagCollapsedThreads, config.Version)) {
if (initialTeamId) {
await syncTeamThreads(serverUrl, initialTeamId);
setTimeout(async () => {
if (chData?.channels?.length && chData.memberships?.length && initialTeamId) {
if (isCRTEnabled && initialTeamId) {
await syncTeamThreads(serverUrl, initialTeamId);
}
fetchPostsForUnreadChannels(serverUrl, chData.channels, chData.memberships, initialChannelId);
}
if (teamData.teams?.length) {
for await (const team of teamData.teams) {
if (team.id !== initialTeamId) {
// need to await here since GM/DM threads in different teams overlap
await syncTeamThreads(serverUrl, team.id);
}
if (teamData.teams?.length && teamData.memberships?.length) {
const teamsOrder = preferences?.find((p) => p.category === Preferences.CATEGORIES.TEAMS_ORDER);
const sortedTeamIds = new Set(teamsOrder?.value.split(','));
const membershipSet = new Set(teamData.memberships.map((m) => m.team_id));
const teamMap = new Map(teamData.teams.map((t) => [t.id, t]));
if (initialTeamId) {
sortedTeamIds.delete(initialTeamId);
membershipSet.delete(initialTeamId);
teamMap.delete(initialTeamId);
}
let myTeams: Team[];
if (sortedTeamIds.size) {
const mySortedTeams = [...sortedTeamIds].
filter((id) => membershipSet.has(id) && teamMap.has(id)).
map((id) => teamMap.get(id)!);
const extraTeams = [...membershipSet].
filter((id) => !sortedTeamIds.has(id) && teamMap.get(id)).
map((id) => teamMap.get(id)!).
sort((a, b) => a.display_name.toLocaleLowerCase().localeCompare(b.display_name.toLocaleLowerCase()));
myTeams = [...mySortedTeams, ...extraTeams];
} else {
myTeams = teamData.teams.
sort((a, b) => a.display_name.toLocaleLowerCase().localeCompare(b.display_name.toLocaleLowerCase()));
}
if (myTeams.length) {
fetchTeamsChannelsThreadsAndUnreadPosts(serverUrl, since, myTeams, isCRTEnabled);
}
}
}
updateCanJoinTeams(serverUrl);
await updateAllUsersSince(serverUrl, since);
});
// Fetch groups for current user
fetchGroupsForMember(serverUrl, currentUserId);
// defer sidebar DM & GM profiles
setTimeout(async () => {
const directChannels = chData?.channels?.filter(isDMorGM);
const channelsToFetchProfiles = new Set<Channel>(directChannels);
if (channelsToFetchProfiles.size) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license);
fetchMissingDirectChannelsInfo(serverUrl, Array.from(channelsToFetchProfiles), currentUserLocale, teammateDisplayNameSetting, currentUserId);
}
}, FETCH_MISSING_DM_TIMEOUT);
}
export const setExtraSessionProps = async (serverUrl: string) => {

View file

@ -4,6 +4,8 @@
/* eslint-disable max-lines */
import {chunk} from 'lodash';
import {markChannelAsUnread, updateLastPostAt} from '@actions/local/channel';
import {addPostAcknowledgement, removePost, removePostAcknowledgement, storePostsForChannel} from '@actions/local/post';
import {addRecentReaction} from '@actions/local/reactions';
@ -40,6 +42,13 @@ type PostsRequest = {
previousPostId?: string;
}
export type PostsForChannel = PostsRequest & {
actionType?: string;
authors?: UserProfile[];
channelId?: string;
error?: unknown;
};
type PostsObjectsRequest = {
error?: unknown;
order?: string[];
@ -71,7 +80,7 @@ export async function createPost(serverUrl: string, post: Partial<Post>, files:
const pendingPostId = post.pending_post_id || `${currentUserId}:${timestamp}`;
const existing = await getPostById(database, pendingPostId);
if (existing && !existing.props.failed) {
if (existing && !existing.props?.failed) {
return {data: false};
}
@ -231,7 +240,7 @@ export const retryFailedPost = async (serverUrl: string, post: PostModel) => {
// timestamps will remain the same as the initial attempt for createAt
// but updateAt will be use for the optimistic post UI
post.prepareUpdate((p) => {
p.props = newPost.props;
p.props = newPost.props || null;
p.updateAt = timestamp;
});
await operator.batchRecords([post], 'retryFailedPost - first update');
@ -275,7 +284,7 @@ export const retryFailedPost = async (serverUrl: string, post: PostModel) => {
return {};
};
export async function fetchPostsForChannel(serverUrl: string, channelId: string, fetchOnly = false) {
export async function fetchPostsForChannel(serverUrl: string, channelId: string, fetchOnly = false): Promise<PostsForChannel> {
try {
if (!fetchOnly) {
EphemeralStore.addLoadingMessagesForChannel(serverUrl, channelId);
@ -312,7 +321,7 @@ export async function fetchPostsForChannel(serverUrl: string, channelId: string,
}
}
return {posts: data.posts, order: data.order, authors, actionType, previousPostId: data.previousPostId};
return {posts: data.posts, order: data.order, authors, actionType, previousPostId: data.previousPostId, channelId};
} catch (error) {
logDebug('error on fetchPostsForChannel', getFullErrorMessage(error));
return {error};
@ -323,15 +332,34 @@ export async function fetchPostsForChannel(serverUrl: string, channelId: string,
}
}
export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: Channel[], memberships: ChannelMembership[], excludeChannelId?: string) => {
const promises = [];
export const fetchPostsForUnreadChannels = async (serverUrl: string, channels: Channel[], memberships: ChannelMembership[], excludeChannelId?: string, fetchOnly = false): Promise<PostsForChannel[]> => {
const membersMap = new Map<string, ChannelMembership>();
for (const member of memberships) {
const channel = channels.find((c) => c.id === member.channel_id);
if (channel && !channel.delete_at && (channel.total_msg_count - member.msg_count) > 0 && channel.id !== excludeChannelId) {
promises.push(fetchPostsForChannel(serverUrl, channel.id));
}
membersMap.set(member.channel_id, member);
}
return Promise.all(promises);
const unreadChannelIds = channels.reduce<string[]>((result, channel) => {
const member = membersMap.get(channel.id);
if (member && !channel.delete_at && (channel.total_msg_count - member.msg_count) > 0 && channel.id !== excludeChannelId) {
result.push(channel.id);
}
return result;
}, []);
const postsForChannel: PostsForChannel[] = [];
// process 10 unread channels at a time
const chunks = chunk(unreadChannelIds, 10);
for await (const channelIds of chunks) {
const promises = [];
for (const channelId of channelIds) {
promises.push(fetchPostsForChannel(serverUrl, channelId, fetchOnly));
}
const results = await Promise.all(promises);
postsForChannel.push(...results);
}
return postsForChannel;
};
export async function fetchPosts(serverUrl: string, channelId: string, page = 0, perPage = General.POST_CHUNK_SIZE, fetchOnly = false): Promise<PostsRequest> {

View file

@ -161,7 +161,7 @@ export const searchFiles = async (serverUrl: string, teamId: string, params: Fil
}, {});
files.forEach((f) => {
if (f.post_id) {
f.postProps = idToPost[f.post_id]?.props;
f.postProps = idToPost[f.post_id]?.props || {};
}
});
return {files, channels};

View file

@ -18,7 +18,6 @@ import {
fetchAllTeams,
fetchTeamsForComponent,
updateCanJoinTeams,
fetchTeamsChannelsAndUnreadPosts,
fetchTeamByName,
removeCurrentUserFromTeam,
removeUserFromTeam,
@ -26,6 +25,7 @@ import {
handleKickFromTeam,
getTeamMembersByIds,
buildTeamIconUrl,
fetchTeamsChannelsThreadsAndUnreadPosts,
} from './team';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -81,12 +81,38 @@ const mockClient = {
getMyTeams: jest.fn(() => ([{id: teamId, name: 'team1'}])),
getMyTeamMembers: jest.fn(() => ([{id: 'userid1-' + teamId, user_id: 'userid1', team_id: teamId, roles: ''}])),
getTeams: jest.fn(() => ([{id: teamId, name: 'team1'}])),
getMyChannels: jest.fn((id: string) => ([{id: channelId, name: 'channel1', creatorId: user.id, team_id: id}])),
getMyChannelMembers: jest.fn(() => ([{id: user.id + '-' + channelId, user_id: user.id, channel_id: channelId, roles: ''}])),
getCategories: jest.fn((userId: string, id: string) => ({categories: [{id: 'categoryid', channel_id: [channelId], team_id: id}], order: ['categoryid']})),
getMyChannels: jest.fn((id: string) => ([{id: channelId, name: 'channel1', creatorId: user.id, team_id: id, total_msg_count: 1}])),
getMyChannelMembers: jest.fn(() => ([{id: user.id + '-' + channelId, user_id: user.id, channel_id: channelId, roles: '', msg_count: 0}])),
getCategories: jest.fn((userId: string, id: string) => ({categories: [{id: 'categoryid', channel_ids: [channelId], team_id: id}], order: ['categoryid']})),
getTeamByName: jest.fn((name: string) => ({id: teamId, name})),
removeFromTeam: jest.fn(),
getTeamMembersByIds: jest.fn((id: string, userIds: string[]) => (userIds.map((userId) => ({id: userId + '-' + id, user_id: userId, team_id: id, roles: ''})))),
getPosts: jest.fn(() => ({
order: ['yocj9xgkh78exk1uhx9yny1zxy', 'ad6yoisgh385fy7rkph49zpfqa', 'o5qqa4ntdigp7rbnf75f8hgeaw'],
posts: [{
channel_id: channelId,
create_at: 1726107604522,
delete_at: 0,
edit_at: 0,
hashtags: '',
id: 'yocj9xgkh78exk1uhx9yny1zxy',
is_pinned: false,
last_reply_at: 0,
message: 'Message ead863',
metadata: {},
original_id: '',
participants: null,
pending_post_id: '',
props: {},
remote_id: '',
reply_count: 0,
root_id: '',
type: '',
update_at: 1726107604522,
user_id: 'k479wsypafgjddz8cerqx4m1ha',
}],
previousPostId: '',
})),
};
beforeAll(() => {
@ -245,16 +271,25 @@ describe('teams', () => {
expect(result).toBeDefined();
});
it('fetchTeamsChannelsAndUnreadPosts - handle not found database', async () => {
const result = await fetchTeamsChannelsAndUnreadPosts('foo', 0, [], []) as {error: unknown};
it('fetchTeamsChannelsThreadsAndUnreadPosts - handle not found database', async () => {
const result = await fetchTeamsChannelsThreadsAndUnreadPosts('foo', 0, []) as {error: unknown};
expect(result?.error).toBeDefined();
});
it('fetchTeamsChannelsAndUnreadPosts - base case', async () => {
const result = await fetchTeamsChannelsAndUnreadPosts(serverUrl, 0, [team], [{team_id: teamId, user_id: 'userid1'} as TeamMembership]);
it('fetchTeamsChannelsThreadsAndUnreadPosts - base case', async () => {
const result = await fetchTeamsChannelsThreadsAndUnreadPosts(
serverUrl, 0,
[team], true, false);
expect(result).toBeDefined();
});
it('fetchTeamsChannelsThreadsAndUnreadPosts - fetch only case', async () => {
const result = await fetchTeamsChannelsThreadsAndUnreadPosts(
serverUrl, 0,
[team], true, true);
expect(result.models).toBeDefined();
});
it('fetchTeamByName - handle not found database', async () => {
const result = await fetchTeamByName('foo', '') as {error: unknown};
expect(result?.error).toBeDefined();

View file

@ -1,12 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {chunk} from 'lodash';
import {DeviceEventEmitter} from 'react-native';
import {storeCategories} from '@actions/local/category';
import {storeMyChannelsForTeam} from '@actions/local/channel';
import {storePostsForChannel} from '@actions/local/post';
import {removeUserFromTeam as localRemoveUserFromTeam} from '@actions/local/team';
import {PER_PAGE_DEFAULT} from '@client/rest/constants';
import {Events} from '@constants';
import DatabaseManager from '@database/manager';
import {removeDuplicatesModels} from '@helpers/database';
import NetworkManager from '@managers/network_manager';
import {getActiveServerUrl} from '@queries/app/servers';
import {prepareCategoriesAndCategoriesChannels} from '@queries/servers/categories';
@ -22,9 +27,10 @@ import {logDebug} from '@utils/log';
import {fetchMyChannelsForTeam, switchToChannelById} from './channel';
import {fetchGroupsForTeamIfConstrained} from './groups';
import {fetchPostsForChannel, fetchPostsForUnreadChannels} from './post';
import {fetchPostsForChannel, fetchPostsForUnreadChannels, type PostsForChannel} from './post';
import {fetchRolesIfNeeded} from './role';
import {forceLogoutIfNecessary} from './session';
import {syncThreadsIfNeeded} from './thread';
import type {Client} from '@client/rest';
import type {Model} from '@nozbe/watermelondb';
@ -312,24 +318,85 @@ export const updateCanJoinTeams = async (serverUrl: string) => {
}
};
export const fetchTeamsChannelsAndUnreadPosts = async (serverUrl: string, since: number, teams: Team[], memberships: TeamMembership[], excludeTeamId?: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
export const fetchTeamsChannelsThreadsAndUnreadPosts = async (
serverUrl: string, since: number, teams: Team[],
isCRTEnabled?: boolean, fetchOnly = false,
) => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const result: Model[] = [];
const membershipSet = new Set(memberships.map((m) => m.team_id));
const myTeams = teams.filter((t) => membershipSet.has(t.id) && t.id !== excludeTeamId);
// process up to 15 teams at a time
const chunks = chunk(teams, 15);
for await (const myTeams of chunks) {
const promises = [];
for (const team of myTeams) {
promises.push(fetchMyChannelsForTeam(serverUrl, team.id, true, since, true, true, isCRTEnabled));
}
for await (const team of myTeams) {
const {channels, memberships: members} = await fetchMyChannelsForTeam(serverUrl, team.id, true, since, false, true);
const results = await Promise.all(promises);
const models: Model[] = [];
const channels: Channel[] = [];
const members: ChannelMembership[] = [];
if (channels?.length && members?.length) {
fetchPostsForUnreadChannels(serverUrl, channels, members);
for await (const r of results) {
if (!r.error && r.teamId && r.categories && r.channels && r.memberships) {
const {models: chModels} = await storeMyChannelsForTeam(serverUrl, r.teamId, r.channels, r.memberships, true, isCRTEnabled);
const {models: catModels} = await storeCategories(serverUrl, r.categories, true, true); // Re-sync
if (chModels?.length) {
models.push(...chModels);
}
if (catModels?.length) {
models.push(...catModels);
}
channels.push(...r.channels);
members.push(...r.memberships);
}
}
const unreadPromise = fetchPostsForUnreadChannels(serverUrl, channels, members, undefined, true);
const threadsPromise = syncThreadsIfNeeded(serverUrl, isCRTEnabled ?? false, myTeams, true);
const postPromises: [Promise<PostsForChannel[]>, Promise<{models?: Model[]; error?: unknown}>] = [
unreadPromise,
threadsPromise,
];
const [unreadPosts, threads] = await Promise.all(postPromises);
for await (const data of unreadPosts) {
if (data.channelId && data.posts?.length && data.order?.length && data.actionType) {
const {models: prepared} = await storePostsForChannel(
serverUrl, data.channelId,
data.posts, data.order, data.previousPostId ?? '',
data.actionType, data.authors || [], true,
);
if (prepared?.length) {
models.push(...prepared);
}
}
}
if (threads.models) {
models.push(...threads.models);
}
if (!fetchOnly && models.length) {
await operator.batchRecords(removeDuplicatesModels(models), 'fetchTeamsChannelsThreadsAndUnreadPosts');
}
if (models.length) {
result.push(...models);
}
}
}
return {};
return {error: undefined, models: result};
} catch (error) {
logDebug('error on fetchTeamsChannelsThreadsAndUnreadPosts', getFullErrorMessage(error));
return {error};
}
};
export async function fetchTeamByName(serverUrl: string, teamName: string, fetchOnly = false) {

View file

@ -19,6 +19,7 @@ import {
syncTeamThreads,
loadEarlierThreads,
fetchAndSwitchToThread,
syncThreadsIfNeeded,
} from './thread';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -150,7 +151,7 @@ describe('get threads', () => {
expect(result).toBeDefined();
expect(result.error).toBeFalsy();
expect(result.models).toBeDefined();
expect(result.models?.length).toBe(5); // 1 thread, 1 thread participant, 1 read thread in team, 1 unread thread in team, 1 team thread sync
expect(result.models?.length).toBe(4); // 1 thread, 1 thread participant, 1 latest thread in team, 1 team thread sync (the unread thread is actually the latest so it the duplicate is removed)
// Sync again after first sync
const result2 = await syncTeamThreads(serverUrl, team.id);
@ -160,6 +161,23 @@ describe('get threads', () => {
expect(result2.models?.length).toBe(1); // 1 team thread sync
});
it('syncThreadsIfNeeded - handle error', async () => {
const result = await syncThreadsIfNeeded('foo', true, []);
expect(result).toBeDefined();
expect(result.error).toBeTruthy();
});
it('syncThreadsIfNeeded - base case', async () => {
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
const result = await syncThreadsIfNeeded(serverUrl, true, [team]);
expect(result).toBeDefined();
expect(result.error).toBeFalsy();
expect(result.models).toBeDefined();
expect(result.models?.length).toBe(4); // 1 thread, 1 thread participant, 1 latest thread in team, 1 team thread sync (the unread thread is actually the latest so it the duplicate is removed)
});
it('loadEarlierThreads - handle error', async () => {
const result = await loadEarlierThreads('foo', '', '');
expect(result).toBeDefined();

View file

@ -5,6 +5,7 @@ import {markTeamThreadsAsRead, markThreadAsViewed, processReceivedThreads, switc
import {fetchPostThread} from '@actions/remote/post';
import {General} from '@constants';
import DatabaseManager from '@database/manager';
import {removeDuplicatesModels} from '@helpers/database';
import PushNotifications from '@init/push_notifications';
import AppsManager from '@managers/apps_manager';
import NetworkManager from '@managers/network_manager';
@ -13,7 +14,7 @@ import {getConfigValue, getCurrentChannelId, getCurrentTeamId} from '@queries/se
import {getIsCRTEnabled, getThreadById, getTeamThreadsSyncData} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {logDebug, logError} from '@utils/log';
import {showThreadFollowingSnackbar} from '@utils/snack_bar';
import {getThreadsListEdges} from '@utils/thread';
@ -30,6 +31,7 @@ type FetchThreadsOptions = {
unread?: boolean;
since?: number;
totalsOnly?: boolean;
excludeDirect?: boolean;
};
enum Direction {
@ -238,10 +240,10 @@ export const fetchThreads = async (
let currentPage = 0;
const fetchThreadsFunc = async (opts: FetchThreadsOptions) => {
const {before, after, perPage = General.CRT_CHUNK_SIZE, deleted, unread, since} = opts;
const {before, after, perPage = General.CRT_CHUNK_SIZE, deleted, unread, since, excludeDirect = false} = opts;
currentPage++;
const {threads} = await client.getThreads(currentUser.id, teamId, before, after, perPage, deleted, unread, since, false, version);
const {threads} = await client.getThreads(currentUser.id, teamId, before, after, perPage, deleted, unread, since, false, version, excludeDirect);
if (threads.length) {
// Mark all fetched threads as following
for (const thread of threads) {
@ -277,7 +279,45 @@ export const fetchThreads = async (
return {error: false, threads: threadsData};
};
export const syncTeamThreads = async (serverUrl: string, teamId: string, prepareRecordsOnly = false) => {
export const syncThreadsIfNeeded = async (serverUrl: string, isCRTEnabled: boolean, teams?: Team[], fetchOnly = false) => {
try {
if (!isCRTEnabled) {
return {models: []};
}
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const promises = [];
const models: Model[][] = [];
if (teams?.length) {
for (const team of teams) {
promises.push(syncTeamThreads(serverUrl, team.id, true, true));
}
}
if (promises.length) {
const results = await Promise.all(promises);
for (const r of results) {
if (r.models?.length) {
models.push(r.models);
}
}
}
const flat = models.flat();
if (!fetchOnly && flat.length) {
const uniqueArray = removeDuplicatesModels(flat);
await operator.batchRecords(uniqueArray, 'syncThreadsIfNeeded');
}
return {models: flat};
} catch (error) {
logError('syncThreadsIfNeeded: Error', error);
return {error, models: undefined};
}
};
export const syncTeamThreads = async (serverUrl: string, teamId: string, excludeDirect = false, fetchOnly = false) => {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const syncData = await getTeamThreadsSyncData(database, teamId);
@ -299,13 +339,13 @@ export const syncTeamThreads = async (serverUrl: string, teamId: string, prepare
fetchThreads(
serverUrl,
teamId,
{unread: true},
{unread: true, excludeDirect},
Direction.Down,
),
fetchThreads(
serverUrl,
teamId,
{},
{excludeDirect},
undefined,
1,
),
@ -313,6 +353,9 @@ export const syncTeamThreads = async (serverUrl: string, teamId: string, prepare
if (allUnreadThreads.error || latestThreads.error) {
return {error: allUnreadThreads.error || latestThreads.error};
}
const dedupe = new Set(latestThreads.threads?.map((t) => t.id));
if (latestThreads.threads?.length) {
// We are fetching the threads for the first time. We get "latest" and "earliest" values.
const {earliestThread, latestThread} = getThreadsListEdges(latestThreads.threads);
@ -322,13 +365,14 @@ export const syncTeamThreads = async (serverUrl: string, teamId: string, prepare
threads.push(...latestThreads.threads);
}
if (allUnreadThreads.threads?.length) {
threads.push(...allUnreadThreads.threads);
const unread = allUnreadThreads.threads.filter((u) => !dedupe.has(u.id));
threads.push(...unread);
}
} else {
const allNewThreads = await fetchThreads(
serverUrl,
teamId,
{deleted: true, since: syncData.latest + 1},
{deleted: true, since: syncData.latest + 1, excludeDirect},
);
if (allNewThreads.error) {
return {error: allNewThreads.error};
@ -361,7 +405,7 @@ export const syncTeamThreads = async (serverUrl: string, teamId: string, prepare
}
}
if (!prepareRecordsOnly && models?.length) {
if (!fetchOnly && models?.length) {
try {
await operator.batchRecords(models, 'syncTeamThreads');
} catch (err) {
@ -372,7 +416,6 @@ export const syncTeamThreads = async (serverUrl: string, teamId: string, prepare
}
}
}
return {error: false, models};
} catch (error) {
return {error};

View file

@ -314,11 +314,11 @@ export async function handleUserAddedToChannelEvent(serverUrl: string, msg: any)
}
const {posts, order, authors, actionType, previousPostId} = await fetchPostsForChannel(serverUrl, channelId, true);
if (posts?.length && order?.length) {
if (posts?.length && order?.length && actionType) {
const {models: prepared} = await storePostsForChannel(
serverUrl, channelId,
posts, order, previousPostId ?? '',
actionType, authors, true,
actionType, authors || [], true,
);
if (prepared?.length) {

View file

@ -8,7 +8,7 @@ import {PER_PAGE_DEFAULT} from './constants';
import type ClientBase from './base';
export interface ClientThreadsMix {
getThreads: (userId: string, teamId: string, before?: string, after?: string, pageSize?: number, deleted?: boolean, unread?: boolean, since?: number, totalsOnly?: boolean, serverVersion?: string) => Promise<GetUserThreadsResponse>;
getThreads: (userId: string, teamId: string, before?: string, after?: string, pageSize?: number, deleted?: boolean, unread?: boolean, since?: number, totalsOnly?: boolean, serverVersion?: string, excludeDirect?: boolean) => Promise<GetUserThreadsResponse>;
getThread: (userId: string, teamId: string, threadId: string, extended?: boolean) => Promise<any>;
markThreadAsRead: (userId: string, teamId: string, threadId: string, timestamp: number) => Promise<any>;
markThreadAsUnread: (userId: string, teamId: string, threadId: string, postId: string) => Promise<any>;
@ -17,7 +17,7 @@ export interface ClientThreadsMix {
}
const ClientThreads = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
getThreads = async (userId: string, teamId: string, before = '', after = '', pageSize = PER_PAGE_DEFAULT, deleted = false, unread = false, since = 0, totalsOnly = false, serverVersion = '') => {
getThreads = async (userId: string, teamId: string, before = '', after = '', pageSize = PER_PAGE_DEFAULT, deleted = false, unread = false, since = 0, totalsOnly = false, serverVersion = '', excludeDirect = false) => {
const queryStringObj: Record<string, any> = {
extended: 'true',
before,
@ -26,6 +26,7 @@ const ClientThreads = <TBase extends Constructor<ClientBase>>(superclass: TBase)
unread,
since,
totalsOnly,
excludeDirect,
};
if (serverVersion && isMinimumServerVersion(serverVersion, 6, 0)) {
queryStringObj.per_page = pageSize;

View file

@ -19,6 +19,7 @@ import {getUserById, observeTeammateNameDisplay} from '@queries/servers/user';
import {goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {displayUsername} from '@utils/user';
import type {WithDatabaseArgs} from '@typings/database/database';
@ -93,7 +94,7 @@ async function getItemName(serverUrl: string, selected: string, teammateNameDisp
return '';
}
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
const database = secureGetFromRecord(DatabaseManager.serverDatabases, serverUrl)?.database;
switch (dataSource) {
case ViewConstants.DATA_SOURCE_USERS: {

View file

@ -51,7 +51,7 @@ const Document = forwardRef<DocumentRef, DocumentProps>(({canDownloadFiles, chil
let exists = false;
try {
path = decodeURIComponent(file.localPath || '');
path = file.localPath || '';
if (path) {
exists = await fileExists(path);
}
@ -103,7 +103,7 @@ const Document = forwardRef<DocumentRef, DocumentProps>(({canDownloadFiles, chil
const openDocument = useCallback(async () => {
if (!didCancel && !preview) {
let path = decodeURIComponent(file.localPath || '');
let path = file.localPath || '';
let exists = false;
if (path) {
exists = await fileExists(path);
@ -116,7 +116,7 @@ const Document = forwardRef<DocumentRef, DocumentProps>(({canDownloadFiles, chil
setPreview(true);
setStatusBarColor('dark-content');
FileViewer.open(path!.replace('file://', ''), {
displayName: decodeURIComponent(file.name),
displayName: file.name,
onDismiss: onDonePreviewingFile,
showOpenWithDialog: true,
showAppsSuggestions: true,

View file

@ -79,7 +79,7 @@ const FileInfo = ({disabled, file, channelName, showDate, onPress}: FileInfoProp
ellipsizeMode='tail'
style={style.fileName}
>
{decodeURIComponent(file.name.trim())}
{file.name.trim()}
</Text>
<View style={style.fileDownloadContainer}>
{channelName &&
@ -98,7 +98,9 @@ const FileInfo = ({disabled, file, channelName, showDate, onPress}: FileInfoProp
</Text>
{showDate && file.create_at != null && (
<>
{' • '}
<Text style={style.infoText}>
{' • '}
</Text>
<FormattedDate
style={style.infoText}
format={FORMAT}

View file

@ -24,7 +24,7 @@ type FilesProps = {
location: string;
isReplyPost: boolean;
postId: string;
postProps: Record<string, any>;
postProps: Record<string, unknown>;
publicLinkEnabled: boolean;
}

View file

@ -46,9 +46,7 @@ const FormattedText = (props: FormattedTextProps) => {
// when the `message` is formatted. This allows the formatted
// message to then be broken-up into parts with references to the
// React Elements inserted back in.
Object.keys(values).forEach((name) => {
const value = values[name];
Object.entries(values).forEach(([name, value]) => {
if (isValidElement(value)) {
const token = generateToken();
tokenizedValues[name] = tokenDelimiter + token + tokenDelimiter;

View file

@ -10,11 +10,38 @@ import {useServerUrl} from '@context/server';
import {t} from '@i18n';
import {alertErrorWithFallback} from '@utils/draft';
import {preventDoubleTap} from '@utils/tap';
import {secureGetFromRecord, isRecordOf} from '@utils/types';
import type ChannelModel from '@typings/database/models/servers/channel';
import type TeamModel from '@typings/database/models/servers/team';
export type ChannelMentions = Record<string, {id?: string; display_name: string; name?: string; team_name: string}>;
export type ChannelMentions = Record<string, {id?: string; display_name: string; name?: string; team_name?: string}>;
export function isChannelMentions(v: unknown): v is ChannelMentions {
return isRecordOf(v, (e) => {
if (typeof e !== 'object' || !e) {
return false;
}
if (!('display_name' in e) || typeof e.display_name !== 'string') {
return false;
}
if ('team_name' in e && typeof e.team_name !== 'string') {
return false;
}
if ('id' in e && typeof e.id !== 'string') {
return false;
}
if ('name' in e && typeof e.name !== 'string') {
return false;
}
return true;
});
}
type ChannelMentionProps = {
channelMentions?: ChannelMentions;
@ -40,7 +67,7 @@ function getChannelFromChannelName(name: string, channels: ChannelModel[], chann
});
while (channelName.length > 0) {
if (channelsByName[channelName]) {
if (secureGetFromRecord(channelsByName, channelName)) {
return channelsByName[channelName];
}

View file

@ -12,8 +12,6 @@ import ChannelMention from './channel_mention';
import type {WithDatabaseArgs} from '@typings/database/database';
export type ChannelMentions = Record<string, {id?: string; display_name: string; name?: string; team_name: string}>;
const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
const currentTeamId = observeCurrentTeamId(database);
const channels = currentTeamId.pipe(

View file

@ -27,7 +27,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
class ErrorBoundary extends React.PureComponent<Props, State, any> {
class ErrorBoundary extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {hasError: false};
@ -38,7 +38,6 @@ class ErrorBoundary extends React.PureComponent<Props, State, any> {
}
render() {
// eslint-disable-next-line react/prop-types
const {children, error, theme} = this.props;
const {hasError} = this.state;
const style = getStyleSheet(theme);

View file

@ -0,0 +1,140 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react-native';
import * as CommonMark from 'commonmark';
const {Node} = CommonMark;
import React from 'react';
import {Preferences} from '@constants';
import {renderWithIntl} from '@test/intl-test-helper';
import Markdown from './markdown';
import * as Transforms from './transform';
const Parser = jest.requireActual('commonmark').Parser;
describe('Markdown', () => {
const baseProps: React.ComponentProps<typeof Markdown> = {
baseTextStyle: {},
enableInlineLatex: true,
enableLatex: true,
location: 'somewhere?',
maxNodes: 2000,
theme: Preferences.THEMES.denim,
};
describe('error handling', () => {
test('should render Markdown normally', () => {
renderWithIntl(
<Markdown
{...baseProps}
value='This is a test'
/>,
);
expect(screen.getByText('This is a test')).toBeVisible();
});
test('should catch errors when parsing Markdown', () => {
jest.spyOn(CommonMark, 'Parser').mockImplementation(() => {
const parser = new Parser();
parser.incorporateLine = () => {
throw new Error('test error');
};
return parser;
});
renderWithIntl(
<Markdown
{...baseProps}
value='This is a test'
/>,
);
expect(screen.queryByText('This is a text')).not.toBeVisible();
expect(screen.getByText('An error occurred while parsing this text')).toBeVisible();
});
test('should catch errors when parsing Markdown', () => {
jest.spyOn(CommonMark, 'Parser').mockImplementation(() => {
const parser = new Parser();
parser.incorporateLine = () => {
throw new Error('test error');
};
return parser;
});
renderWithIntl(
<Markdown
{...baseProps}
value='This is a test'
/>,
);
expect(screen.queryByText('This is a text')).not.toBeVisible();
expect(screen.getByText('An error occurred while parsing this text')).toBeVisible();
});
test('should catch errors when transforming Markdown', () => {
jest.spyOn(Transforms, 'highlightMentions').mockImplementation(() => {
throw new Error('test error');
});
renderWithIntl(
<Markdown
{...baseProps}
value='This is a test'
/>,
);
expect(screen.queryByText('This is a text')).not.toBeVisible();
expect(screen.getByText('An error occurred while parsing this text')).toBeVisible();
});
test('should catch errors when rendering Markdown', () => {
jest.spyOn(CommonMark, 'Parser').mockImplementation(() => {
const parser = new Parser();
parser.parse = () => {
// This replicates what was returned by the parser to cause MM-61148
const document = new Node('document');
const paragraph = new Node('paragraph');
(paragraph as any)._string_content = 'some text';
document.appendChild(new Node('table'));
return document;
};
return parser;
});
renderWithIntl(
<Markdown
{...baseProps}
value='This is a test'
/>,
);
expect(screen.queryByText('This is a text')).not.toBeVisible();
expect(screen.getByText('An error occurred while rendering this text')).toBeVisible();
});
test('should catch errors when with Latex', () => {
const value =
'```latex\n' +
'\\\\\\\\asdfasdfasdf\n' +
'```\n';
renderWithIntl(
<Markdown
{...baseProps}
value={value}
/>,
);
expect(screen.queryByText('This is a text')).not.toBeVisible();
expect(screen.getByText('An error occurred while rendering this text')).toBeVisible();
});
});
});

View file

@ -10,12 +10,14 @@ import {Dimensions, type GestureResponderEvent, Platform, type StyleProp, StyleS
import CompassIcon from '@components/compass_icon';
import Emoji from '@components/emoji';
import FormattedText from '@components/formatted_text';
import {logError} from '@utils/log';
import {computeTextStyle} from '@utils/markdown';
import {blendColors, changeOpacity, concatStyles, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {getScheme} from '@utils/url';
import AtMention from './at_mention';
import ChannelMention, {type ChannelMentions} from './channel_mention';
import ChannelMention from './channel_mention';
import Hashtag from './hashtag';
import MarkdownBlockQuote from './markdown_block_quote';
import MarkdownCodeBlock from './markdown_code_block';
@ -31,6 +33,7 @@ import MarkdownTableImage from './markdown_table_image';
import MarkdownTableRow, {type MarkdownTableRowProps} from './markdown_table_row';
import {addListItemIndices, combineTextNodes, highlightMentions, highlightWithoutNotification, highlightSearchPatterns, parseTaskLists, pullOutImages} from './transform';
import type {ChannelMentions} from './channel_mention/channel_mention';
import type {
MarkdownAtMentionRenderer, MarkdownBaseRenderer, MarkdownBlockStyles, MarkdownChannelMentionRenderer,
MarkdownEmojiRenderer, MarkdownImageRenderer, MarkdownLatexRenderer, MarkdownTextStyles, SearchPattern, UserMentionKey, HighlightWithoutNotificationKey,
@ -98,6 +101,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
color: editedColor,
opacity: editedOpacity,
},
errorMessage: {
color: theme.errorTextColor,
...typography('Body', 100),
},
maxNodesWarning: {
color: theme.errorTextColor,
},
@ -606,34 +613,74 @@ const Markdown = ({
const parser = useRef(new Parser({urlFilter, minimumHashtagLength})).current;
const renderer = useMemo(createRenderer, [theme, textStyles]);
let ast = parser.parse(value.toString());
ast = combineTextNodes(ast);
ast = addListItemIndices(ast);
ast = pullOutImages(ast);
ast = parseTaskLists(ast);
if (mentionKeys) {
ast = highlightMentions(ast, mentionKeys);
}
if (highlightKeys) {
ast = highlightWithoutNotification(ast, highlightKeys);
}
if (searchPatterns) {
ast = highlightSearchPatterns(ast, searchPatterns);
}
if (isEdited) {
const editIndicatorNode = new Node('edited_indicator');
if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) {
ast.appendChild(editIndicatorNode);
} else {
const node = new Node('paragraph');
node.appendChild(editIndicatorNode);
const errorLogged = useRef(false);
ast.appendChild(node);
let ast;
try {
ast = parser.parse(value.toString());
ast = combineTextNodes(ast);
ast = addListItemIndices(ast);
ast = pullOutImages(ast);
ast = parseTaskLists(ast);
if (mentionKeys) {
ast = highlightMentions(ast, mentionKeys);
}
if (highlightKeys) {
ast = highlightWithoutNotification(ast, highlightKeys);
}
if (searchPatterns) {
ast = highlightSearchPatterns(ast, searchPatterns);
}
if (isEdited) {
const editIndicatorNode = new Node('edited_indicator');
if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) {
ast.appendChild(editIndicatorNode);
} else {
const node = new Node('paragraph');
node.appendChild(editIndicatorNode);
ast.appendChild(node);
}
}
} catch (e) {
if (!errorLogged.current) {
logError('An error occurred while parsing Markdown', e);
errorLogged.current = true;
}
return (
<FormattedText
id='markdown.parse_error'
defaultMessage='An error occurred while parsing this text'
style={style.errorMessage}
/>
);
}
return renderer.render(ast) as JSX.Element;
let output;
try {
output = renderer.render(ast);
} catch (e) {
if (!errorLogged.current) {
logError('An error occurred while rendering Markdown', e);
errorLogged.current = true;
}
return (
<FormattedText
id='markdown.render_error'
defaultMessage='An error occurred while rendering this text'
style={style.errorMessage}
/>
);
}
return output;
};
export default Markdown;

View file

@ -29,7 +29,8 @@ import {bottomSheetSnapPoint} from '@utils/helpers';
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
import {getMarkdownImageSize} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {normalizeProtocol, tryOpenURL} from '@utils/url';
import {secureGetFromRecord} from '@utils/types';
import {normalizeProtocol, safeDecodeURIComponent, tryOpenURL} from '@utils/url';
import type {GalleryItemType} from '@typings/screens/gallery';
@ -80,7 +81,7 @@ const MarkdownImage = ({
const style = getStyleSheet(theme);
const managedConfig = useManagedConfig<ManagedConfig>();
const genericFileId = useRef(generateId('uid')).current;
const metadata = imagesMetadata?.[source] || Object.values(imagesMetadata || {})[0];
const metadata = secureGetFromRecord(imagesMetadata, source) || Object.values(imagesMetadata || {})[0];
const [failed, setFailed] = useState(isGifTooLarge(metadata));
const originalSize = getMarkdownImageSize(isReplyPost, isTablet, sourceSize, metadata, layoutWidth, layoutHeight);
const serverUrl = useServerUrl();
@ -88,8 +89,8 @@ const MarkdownImage = ({
const uri = source.startsWith('/') ? serverUrl + source : source;
const fileInfo = useMemo(() => {
const link = decodeURIComponent(uri);
let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', '');
const decodedLink = safeDecodeURIComponent(uri);
let filename = parseUrl(decodedLink.substr(decodedLink.lastIndexOf('/'))).pathname.replace('/', '');
let extension = metadata?.format || filename.split('.').pop();
if (extension === filename) {
const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.'));
@ -104,7 +105,7 @@ const MarkdownImage = ({
has_preview_image: true,
mime_type: lookupMimeType(filename),
post_id: postId,
uri: link,
uri,
width: originalSize.width,
height: originalSize.height,
} as FileInfo;

View file

@ -13,6 +13,8 @@ import {useGalleryItem} from '@hooks/gallery';
import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery';
import {generateId} from '@utils/general';
import {calculateDimensions, isGifTooLarge} from '@utils/images';
import {secureGetFromRecord} from '@utils/types';
import {safeDecodeURIComponent} from '@utils/url';
import type {GalleryItemType} from '@typings/screens/gallery';
@ -33,7 +35,7 @@ const style = StyleSheet.create({
});
const MarkTableImage = ({disabled, imagesMetadata, location, postId, serverURL, source}: MarkdownTableImageProps) => {
const metadata = imagesMetadata[source];
const metadata = secureGetFromRecord(imagesMetadata, source);
const fileId = useRef(generateId('uid')).current;
const [failed, setFailed] = useState(isGifTooLarge(metadata));
const currentServerUrl = useServerUrl();
@ -57,8 +59,9 @@ const MarkTableImage = ({disabled, imagesMetadata, location, postId, serverURL,
const getFileInfo = () => {
const height = metadata?.height || 0;
const width = metadata?.width || 0;
const link = decodeURIComponent(getImageSource());
let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', '');
const uri = getImageSource();
const decodedLink = safeDecodeURIComponent(uri);
let filename = parseUrl(decodedLink.substring(decodedLink.lastIndexOf('/'))).pathname.replace('/', '');
let extension = filename.split('.').pop();
if (extension === filename) {
@ -73,7 +76,7 @@ const MarkTableImage = ({disabled, imagesMetadata, location, postId, serverURL,
extension,
has_preview_image: true,
post_id: postId,
uri: link,
uri,
width,
height,
};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect} from 'react';
import React, {useCallback, useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, type StyleProp, TouchableHighlight, View, type ViewStyle} from 'react-native';
@ -15,7 +15,9 @@ import {useIsTablet} from '@hooks/device';
import {bottomSheetModalOptions, showModal, showModalOverCurrentContext} from '@screens/navigation';
import {emptyFunction} from '@utils/general';
import {getMarkdownTextStyles} from '@utils/markdown';
import {isUserActivityProp} from '@utils/post_list';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import LastUsers from './last_users';
import {postTypeMessages} from './messages';
@ -70,13 +72,20 @@ const CombinedUserActivity = ({
const content = [];
const removedUserIds: string[] = [];
const userActivity = useMemo(() => {
if (isUserActivityProp(post?.props?.user_activity)) {
return post?.props?.user_activity;
}
return undefined;
}, [post?.props?.user_activity]);
const getUsernames = (userIds: string[]) => {
const someone = intl.formatMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'});
const you = intl.formatMessage({id: 'combined_system_message.you', defaultMessage: 'You'});
const usernamesValues = Object.values(usernamesById);
const usernames = userIds.reduce((acc: string[], id: string) => {
if (id !== currentUserId && id !== currentUsername) {
const name = usernamesById[id] ?? usernamesValues.find((n) => n === id);
const name = secureGetFromRecord(usernamesById, id) ?? usernamesValues.find((n) => n === id);
acc.push(name ? `@${name}` : someone);
}
return acc;
@ -107,12 +116,12 @@ const CombinedUserActivity = ({
}
}, [post, canDelete, isTablet, intl, location]);
const renderMessage = (postType: string, userIds: string[], actorId: string) => {
const renderMessage = (postType: string, userIds: string[], actorId?: string) => {
if (!post) {
return null;
}
let actor = '';
if (usernamesById[actorId]) {
if (actorId && secureGetFromRecord(usernamesById, actorId)) {
actor = `@${usernamesById[actorId]}`;
}
@ -141,11 +150,11 @@ const CombinedUserActivity = ({
const secondUser = usernames[1];
let localeHolder;
if (numOthers === 0) {
localeHolder = postTypeMessages[postType].one;
localeHolder = secureGetFromRecord(postTypeMessages, postType)?.one;
if (
(userIds[0] === currentUserId || userIds[0] === currentUsername) &&
postTypeMessages[postType].one_you
secureGetFromRecord(postTypeMessages, postType)?.one_you
) {
localeHolder = postTypeMessages[postType].one_you;
}
@ -153,7 +162,8 @@ const CombinedUserActivity = ({
localeHolder = postTypeMessages[postType].two;
}
const formattedMessage = intl.formatMessage(localeHolder, {firstUser, secondUser, actor});
// We default to empty string, but this should never happen
const formattedMessage = localeHolder ? intl.formatMessage(localeHolder, {firstUser, secondUser, actor}) : '';
return (
<Markdown
channelId={post.channel_id}
@ -168,11 +178,12 @@ const CombinedUserActivity = ({
};
useEffect(() => {
if (!post) {
if (!userActivity) {
return;
}
const {allUserIds, allUsernames} = post.props.user_activity;
const allUserIds = userActivity.allUserIds;
const allUsernames = userActivity.allUsernames;
if (allUserIds.length) {
fetchMissingProfilesByIds(serverUrl, allUserIds);
}
@ -180,14 +191,14 @@ const CombinedUserActivity = ({
if (allUsernames.length) {
fetchMissingProfilesByUsernames(serverUrl, allUsernames);
}
}, [post?.props.user_activity.allUserIds, post?.props.user_activity.allUsernames]);
}, [userActivity?.allUserIds, userActivity?.allUsernames]);
if (!post) {
return null;
}
const itemTestID = `${testID}.${post.id}`;
const {messageData} = post.props.user_activity;
const messageData = userActivity?.messageData || [];
for (const message of messageData) {
const {postType, actorId} = message;
const userIds = new Set<string>(message.userIds);

View file

@ -11,7 +11,7 @@ import {queryPostsById} from '@queries/servers/post';
import {observePermissionForPost} from '@queries/servers/role';
import {observeCurrentUserId} from '@queries/servers/system';
import {observeUser, queryUsersByIdsOrUsernames} from '@queries/servers/user';
import {generateCombinedPost, getPostIdsForCombinedUserActivityPost} from '@utils/post_list';
import {generateCombinedPost, getPostIdsForCombinedUserActivityPost, isUserActivityProp} from '@utils/post_list';
import CombinedUserActivity from './combined_user_activity';
@ -35,10 +35,11 @@ const withCombinedPosts = withObservables(['postId'], ({database, postId}: WithD
const usernamesById = post.pipe(
switchMap(
(p) => {
if (!p) {
const userActivity = isUserActivityProp(p?.props?.user_activity) ? p.props.user_activity : undefined;
if (!userActivity) {
return of$<Record<string, string>>({});
}
return queryUsersByIdsOrUsernames(database, p.props.user_activity.allUserIds, p.props.user_activity.allUsernames).observeWithColumns(['username']).
return queryUsersByIdsOrUsernames(database, userActivity.allUserIds, userActivity.allUsernames).observeWithColumns(['username']).
pipe(
// eslint-disable-next-line max-nested-callbacks
switchMap((users) => {

View file

@ -10,6 +10,7 @@ import FormattedText from '@components/formatted_text';
import Markdown from '@components/markdown';
import {getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {postTypeMessages, systemMessages} from './messages';
@ -50,12 +51,14 @@ const LastUsers = ({actor, channelId, location, postType, theme, usernames}: Las
if (expanded) {
const lastIndex = usernames.length - 1;
const lastUser = usernames[lastIndex];
const expandedMessage = postTypeMessages[postType].many_expanded;
const formattedMessage = intl.formatMessage(expandedMessage, {
const expandedMessage = secureGetFromRecord(postTypeMessages, postType)?.many_expanded;
// We default to empty string, but this should never happen
const formattedMessage = expandedMessage ? intl.formatMessage(expandedMessage, {
users: usernames.slice(0, lastIndex).join(', '),
lastUser,
actor,
});
}) : '';
return (
<Markdown
@ -72,6 +75,8 @@ const LastUsers = ({actor, channelId, location, postType, theme, usernames}: Las
const firstUser = usernames[0];
const numOthers = usernames.length - 1;
const message = secureGetFromRecord(systemMessages, postType);
return (
<Text>
<FormattedMarkdownText
@ -96,8 +101,8 @@ const LastUsers = ({actor, channelId, location, postType, theme, usernames}: Las
</Text>
<FormattedMarkdownText
channelId={channelId}
id={systemMessages[postType].id}
defaultMessage={systemMessages[postType].defaultMessage}
id={message?.id || ''}
defaultMessage={message?.defaultMessage || ''}
location={location}
values={{actor}}
baseTextStyle={style.baseText}

View file

@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {openAsBottomSheet} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {ensureString} from '@utils/types';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
@ -39,12 +40,15 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, location, post}:
const fromWebHook = post.props?.from_webhook === 'true';
const iconOverride = enablePostIconOverride && post.props?.use_user_icon !== 'true';
const propsIconUrl = ensureString(post.props?.override_icon_url);
const propsUsername = ensureString(post.props?.override_username);
if (fromWebHook && iconOverride) {
const isEmoji = Boolean(post.props?.override_icon_emoji);
const frameSize = ViewConstant.PROFILE_PICTURE_SIZE;
const pictureSize = isEmoji ? ViewConstant.PROFILE_PICTURE_EMOJI_SIZE : ViewConstant.PROFILE_PICTURE_SIZE;
const borderRadius = isEmoji ? 0 : ViewConstant.PROFILE_PICTURE_SIZE / 2;
const overrideIconUrl = buildAbsoluteUrl(serverUrl, post.props?.override_icon_url);
const overrideIconUrl = buildAbsoluteUrl(serverUrl, propsIconUrl);
let iconComponent: ReactNode;
if (overrideIconUrl) {
@ -95,8 +99,8 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, location, post}:
userId: author.id,
channelId: post.channelId,
location,
userIconOverride: post.props?.override_username,
usernameOverride: post.props?.override_icon_url,
userIconOverride: propsIconUrl,
usernameOverride: propsUsername,
};
Keyboard.dismiss();

View file

@ -14,6 +14,7 @@ import {useServerUrl} from '@context/server';
import {t} from '@i18n';
import {getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {isStringArray} from '@utils/types';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
@ -26,6 +27,39 @@ type AddMembersProps = {
theme: Theme;
}
export type AddMemberPostProps = {
post_id: string;
not_in_channel_user_ids?: string[];
not_in_groups_usernames?: string[];
not_in_channel_usernames?: string[];
user_ids?: string[];
usernames?: string[];
}
export function isAddMemberProps(v: unknown): v is AddMemberPostProps {
if (typeof v !== 'object' || !v) {
return false;
}
if (!('post_id' in v) || typeof v.post_id !== 'string') {
return false;
}
if (('not_in_channel_user_ids' in v) && !isStringArray(v.not_in_channel_user_ids)) {
return false;
}
if (('not_in_groups_usernames' in v) && !isStringArray(v.not_in_groups_usernames)) {
return false;
}
if (('not_in_channel_usernames' in v) && !isStringArray(v.not_in_channel_usernames)) {
return false;
}
return true;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
message: {
@ -41,20 +75,17 @@ const AddMembers = ({channelType, currentUser, location, post, theme}: AddMember
const styles = getStyleSheet(theme);
const textStyles = getMarkdownTextStyles(theme);
const serverUrl = useServerUrl();
const postId: string = post.props.add_channel_member?.post_id;
const noGroupsUsernames = post.props.add_channel_member?.not_in_groups_usernames;
let userIds: string[] = post.props.add_channel_member?.not_in_channel_user_ids;
let usernames: string[] = post.props.add_channel_member?.not_in_channel_usernames;
if (!postId || !channelType) {
if (!isAddMemberProps(post.props?.add_channel_member)) {
return null;
}
if (!userIds) {
userIds = post.props.add_channel_member?.user_ids;
}
if (!usernames) {
usernames = post.props.add_channel_member?.usernames;
const postId = post.props.add_channel_member.post_id;
const noGroupsUsernames = post.props.add_channel_member.not_in_groups_usernames || [];
const userIds = post.props.add_channel_member.not_in_channel_user_ids || post.props.add_channel_member.user_ids || [];
const usernames = post.props.add_channel_member.not_in_channel_usernames || post.props.add_channel_member?.usernames || [];
if (!postId || !channelType) {
return null;
}
const handleAddChannelMember = () => {

View file

@ -15,7 +15,7 @@ import {observeChannel} from '@queries/servers/channel';
import {observeCurrentTeamId} from '@queries/servers/system';
import {showAppForm} from '@screens/navigation';
import {createCallContext} from '@utils/apps';
import {getStatusColors} from '@utils/message_attachment_colors';
import {getStatusColors} from '@utils/message_attachment';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';

View file

@ -4,6 +4,9 @@
import React from 'react';
import {View} from 'react-native';
import {isAppBinding, validateBindings} from '@utils/apps';
import {isArrayOf} from '@utils/types';
import EmbeddedBinding from './embedded_binding';
import type PostModel from '@typings/database/models/servers/post';
@ -16,7 +19,7 @@ type Props = {
const EmbeddedBindings = ({location, post, theme}: Props) => {
const content: React.ReactNode[] = [];
const embeds: AppBinding[] = post.props.app_bindings;
const embeds: AppBinding[] = isArrayOf<AppBinding>(post.props?.app_bindings, isAppBinding) ? validateBindings(post.props.app_bindings) : [];
embeds.forEach((embed, i) => {
content.push(

View file

@ -18,6 +18,7 @@ import {openGalleryAtIndex} from '@utils/gallery';
import {generateId} from '@utils/general';
import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images';
import {changeOpacity} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {extractFilenameFromUrl, isImageLink, isValidUrl} from '@utils/url';
import type {GalleryItemType} from '@typings/screens/gallery';
@ -55,7 +56,7 @@ const ImagePreview = ({expandedLink, isReplyPost, layoutWidth, link, location, m
const fileId = useRef(generateId('uid')).current;
const [imageUrl, setImageUrl] = useState(expandedLink || link);
const isTablet = useIsTablet();
const imageProps = metadata?.images?.[link];
const imageProps = secureGetFromRecord(metadata?.images, link);
const dimensions = calculateDimensions(imageProps?.height, imageProps?.width, layoutWidth || getViewPortWidth(isReplyPost, isTablet));
const onError = useCallback(() => {

View file

@ -3,6 +3,7 @@
import React from 'react';
import {isMessageAttachmentArray} from '@utils/message_attachment';
import {isYoutubeLink} from '@utils/url';
import EmbeddedBindings from './embedded_bindings';
@ -31,7 +32,9 @@ const contentType: Record<string, string> = {
const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps) => {
let type: string | undefined = post.metadata?.embeds?.[0].type;
if (!type && post.props?.app_bindings?.length) {
const nAppBindings = Array.isArray(post.props?.app_bindings) ? post.props.app_bindings.length : 0;
if (!type && nAppBindings) {
type = contentType.app_bindings;
}
@ -39,6 +42,8 @@ const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps
return null;
}
const attachments = isMessageAttachmentArray(post.props?.attachments) ? post.props.attachments : [];
switch (contentType[type]) {
case contentType.image:
return (
@ -74,10 +79,10 @@ const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps
/>
);
case contentType.message_attachment:
if (post.props.attachments?.length) {
if (attachments.length) {
return (
<MessageAttachments
attachments={post.props.attachments}
attachments={attachments}
channelId={post.channelId}
layoutWidth={layoutWidth}
location={location}
@ -89,7 +94,7 @@ const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps
}
break;
case contentType.app_bindings:
if (post.props.app_bindings?.length) {
if (nAppBindings) {
return (
<EmbeddedBindings
location={location}

View file

@ -6,9 +6,10 @@ import React, {useCallback, useRef} from 'react';
import {postActionWithCookie} from '@actions/remote/integrations';
import {useServerUrl} from '@context/server';
import {getStatusColors} from '@utils/message_attachment_colors';
import {getStatusColors} from '@utils/message_attachment';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import ActionButtonText from './action_button_text';
@ -65,7 +66,7 @@ const ActionButton = ({buttonColor, cookie, disabled, id, name, postId, theme}:
if (buttonColor) {
const STATUS_COLORS = getStatusColors(theme);
const hexColor = STATUS_COLORS[buttonColor] || theme[buttonColor] || buttonColor;
const hexColor = secureGetFromRecord(STATUS_COLORS, buttonColor) || secureGetFromRecord(theme, buttonColor) || buttonColor;
customButtonStyle = {borderColor: changeOpacity(hexColor, 0.25), backgroundColor: '#ffffff'};
customButtonTextStyle = {color: hexColor};
}

View file

@ -57,7 +57,7 @@ const AttachmentImage = ({imageUrl, imageMetadata, layoutWidth, location, postId
const [error, setError] = useState(false);
const fileId = useRef(generateId('uid')).current;
const isTablet = useIsTablet();
const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, layoutWidth || getViewPortWidth(false, isTablet));
const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, layoutWidth || getViewPortWidth(false, isTablet, true));
const style = getStyleSheet(theme);
const onError = useCallback(() => {

View file

@ -5,8 +5,9 @@ import React from 'react';
import {View} from 'react-native';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {getStatusColors} from '@utils/message_attachment_colors';
import {getStatusColors} from '@utils/message_attachment';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {isValidUrl} from '@utils/url';
import AttachmentActions from './attachment_actions';
@ -62,7 +63,7 @@ export default function MessageAttachment({attachment, channelId, layoutWidth, l
if (attachment.color) {
if (attachment.color[0] === '#') {
borderStyle = {borderLeftColor: attachment.color};
} else if (STATUS_COLORS[attachment.color]) {
} else if (secureGetFromRecord(STATUS_COLORS, attachment.color)) {
borderStyle = {borderLeftColor: STATUS_COLORS[attachment.color]};
}
}

View file

@ -16,6 +16,7 @@ import {isTablet} from '@utils/helpers';
import {calculateDimensions} from '@utils/images';
import {type BestImage, getNearestPoint} from '@utils/opengraph';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {extractFilenameFromUrl, isValidUrl} from '@utils/url';
import type {GalleryItemType} from '@typings/screens/gallery';
@ -71,10 +72,7 @@ const OpengraphImage = ({isReplyPost, layoutWidth, location, metadata, openGraph
const imageUrl = (bestImage.secure_url || bestImage.url)!;
const imagesMetadata = metadata?.images;
let ogImage;
if (imagesMetadata && imagesMetadata[imageUrl]) {
ogImage = imagesMetadata[imageUrl];
}
let ogImage = secureGetFromRecord(imagesMetadata, imageUrl);
if (!ogImage) {
ogImage = openGraphImages.find((i: BestImage) => i.url === imageUrl || i.secure_url === imageUrl);

View file

@ -96,8 +96,11 @@ const Body = ({
let body;
let message;
const nBindings = Array.isArray(post.props?.app_bindings) ? post.props?.app_bindings.length : 0;
const nAttachments = Array.isArray(post.props?.attachments) ? post.props?.attachments.length : 0;
const isReplyPost = Boolean(post.rootId && (!isEphemeral || !hasBeenDeleted) && location !== THREAD);
const hasContent = Boolean((post.metadata?.embeds?.length || (appsEnabled && post.props?.app_bindings?.length)) || post.props?.attachments?.length);
const hasContent = Boolean((post.metadata?.embeds?.length || (appsEnabled && nBindings)) || nAttachments);
const replyBarStyle = useCallback((): StyleProp<ViewStyle>|undefined => {
if (!isReplyPost || (isCRTEnabled && location === Screens.PERMALINK)) {

View file

@ -1,11 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import React, {useCallback, useMemo, useState} from 'react';
import {type LayoutChangeEvent, ScrollView, useWindowDimensions, View} from 'react-native';
import Animated from 'react-native-reanimated';
import Markdown from '@components/markdown';
import {isChannelMentions} from '@components/markdown/channel_mention/channel_mention';
import {SEARCH} from '@constants/screens';
import {useShowMoreAnimatedStyle} from '@hooks/show_more';
import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown';
@ -69,6 +70,10 @@ const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight
const onLayout = useCallback((event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height), []);
const onPress = () => setOpen(!open);
const channelMentions = useMemo(() => {
return isChannelMentions(post.props?.channel_mentions) ? post.props.channel_mentions : {};
}, [post.props?.channel_mentions]);
return (
<>
<Animated.View style={animatedStyle}>
@ -86,7 +91,7 @@ const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight
baseTextStyle={style.message}
blockStyles={blockStyles}
channelId={post.channelId}
channelMentions={post.props?.channel_mentions}
channelMentions={channelMentions}
imagesMetadata={post.metadata?.images}
isEdited={isEdited}
isReplyPost={isReplyPost}
@ -100,7 +105,7 @@ const Message = ({currentUser, isHighlightWithoutNotificationLicensed, highlight
highlightKeys={isHighlightWithoutNotificationLicensed ? (currentUser?.highlightKeys ?? EMPTY_HIGHLIGHT_KEYS) : EMPTY_HIGHLIGHT_KEYS}
searchPatterns={searchPatterns}
theme={theme}
isUnsafeLinksPost={post.props.unsafe_links && post.props.unsafe_links !== ''}
isUnsafeLinksPost={Boolean(post.props?.unsafe_links && post.props.unsafe_links !== '')}
/>
</View>
</ScrollView>

View file

@ -12,6 +12,7 @@ import {useTheme} from '@context/theme';
import {DEFAULT_LOCALE} from '@i18n';
import {postUserDisplayName} from '@utils/post';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {ensureString} from '@utils/types';
import {typography} from '@utils/typography';
import {displayUsername, getUserCustomStatus, getUserTimezone, isCustomStatusExpired} from '@utils/user';
@ -96,6 +97,8 @@ const Header = (props: HeaderProps) => {
isCustomStatusEnabled && displayName && customStatus &&
!(isSystemPost || author?.isBot || isAutoResponse || isWebHook),
) && !isCustomStatusExpired(author) && Boolean(customStatus?.emoji);
const userIconOverride = ensureString(post.props?.override_icon_url);
const usernameOverride = ensureString(post.props?.override_username);
return (
<>
@ -109,9 +112,9 @@ const Header = (props: HeaderProps) => {
rootPostAuthor={rootAuthorDisplayName}
shouldRenderReplyButton={shouldRenderReplyButton}
theme={theme}
userIconOverride={post.props?.override_icon_url}
userIconOverride={userIconOverride}
userId={post.userId}
usernameOverride={post.props?.override_username}
usernameOverride={usernameOverride}
showCustomStatusEmoji={showCustomStatusEmoji}
customStatus={customStatus!}
/>

View file

@ -12,6 +12,7 @@ import {useTheme} from '@context/theme';
import {t} from '@i18n';
import {getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord, ensureString} from '@utils/types';
import {typography} from '@utils/typography';
import type PostModel from '@typings/database/models/servers/post';
@ -100,8 +101,8 @@ const renderHeaderChangeMessage = ({post, author, location, styles, intl, theme}
}
const username = renderUsername(author.username);
const oldHeader = post.props?.old_header;
const newHeader = post.props?.new_header;
const oldHeader = ensureString(post.props?.old_header);
const newHeader = ensureString(post.props?.new_header);
let localeHolder;
if (post.props?.new_header) {
@ -143,12 +144,12 @@ const renderPurposeChangeMessage = ({post, author, location, styles, intl, theme
}
const username = renderUsername(author.username);
const oldPurpose = post.props?.old_purpose;
const newPurpose = post.props?.new_purpose;
const oldPurpose = ensureString(post.props?.old_purpose);
const newPurpose = ensureString(post.props?.new_purpose);
let localeHolder;
if (post.props?.new_purpose) {
if (post.props?.old_purpose) {
if (newPurpose) {
if (oldPurpose) {
localeHolder = {
id: t('mobile.system_message.update_channel_purpose_message.updated_from'),
defaultMessage: '{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}',
@ -165,7 +166,7 @@ const renderPurposeChangeMessage = ({post, author, location, styles, intl, theme
values = {username, oldPurpose, newPurpose};
return renderMessage({post, styles, intl, location, localeHolder, values, skipMarkdown: true, theme});
} else if (post.props?.old_purpose) {
} else if (oldPurpose) {
localeHolder = {
id: t('mobile.system_message.update_channel_purpose_message.removed'),
defaultMessage: '{username} removed the channel purpose (was: {oldPurpose})',
@ -179,8 +180,8 @@ const renderPurposeChangeMessage = ({post, author, location, styles, intl, theme
};
const renderDisplayNameChangeMessage = ({post, author, location, styles, intl, theme}: RenderersProps) => {
const oldDisplayName = post.props?.old_displayname;
const newDisplayName = post.props?.new_displayname;
const oldDisplayName = ensureString(post.props?.old_displayname);
const newDisplayName = ensureString(post.props?.new_displayname);
if (!(author?.username)) {
return null;
@ -223,13 +224,13 @@ const renderUnarchivedMessage = ({post, author, location, styles, intl, theme}:
};
const renderAddGuestToChannelMessage = ({post, location, styles, intl, theme}: RenderersProps, hideGuestTags: boolean) => {
if (!post.props.username || !post.props.addedUsername) {
const username = renderUsername(ensureString(post.props?.username));
const addedUsername = renderUsername(ensureString(post.props?.addedUsername));
if (!username || !addedUsername) {
return null;
}
const username = renderUsername(post.props.username);
const addedUsername = renderUsername(post.props.addedUsername);
const localeHolder = hideGuestTags ? postTypeMessages[Post.POST_TYPES.ADD_TO_CHANNEL].one : {
id: t('api.channel.add_guest.added'),
defaultMessage: '{addedUsername} added to the channel as a guest by {username}.',
@ -240,11 +241,11 @@ const renderAddGuestToChannelMessage = ({post, location, styles, intl, theme}: R
};
const renderGuestJoinChannelMessage = ({post, styles, location, intl, theme}: RenderersProps, hideGuestTags: boolean) => {
if (!post.props.username) {
const username = renderUsername(ensureString(post.props?.username));
if (!username) {
return null;
}
const username = renderUsername(post.props.username);
const localeHolder = hideGuestTags ? postTypeMessages[Post.POST_TYPES.JOIN_CHANNEL].one : {
id: t('api.channel.guest_join_channel.post_and_forget'),
defaultMessage: '{username} joined the channel as a guest.',
@ -276,7 +277,7 @@ export const SystemMessage = ({post, location, author, hideGuestTags}: SystemMes
return renderAddGuestToChannelMessage({post, author, location, styles, intl, theme}, hideGuestTags);
}
const renderer = systemMessageRenderers[post.type];
const renderer = secureGetFromRecord(systemMessageRenderers, post.type);
if (!renderer) {
return (
<Markdown

View file

@ -101,7 +101,7 @@ const ProgressiveImage = ({
transition={300}
style={[StyleSheet.absoluteFill, imageStyle]}
source={(showImage) ? {uri: imageUri} : undefined}
autoplay={false}
autoplay={true}
/>
</Animated.View>
);

View file

@ -163,15 +163,15 @@ export default function SelectedUsers({
const users = useMemo(() => {
const u = [];
for (const id of Object.keys(selectedIds)) {
if (!selectedIds[id]) {
for (const [id, user] of Object.entries(selectedIds)) {
if (!user) {
continue;
}
u.push(
<SelectedUser
key={id}
user={selectedIds[id]}
user={user}
teammateNameDisplay={teammateNameDisplay}
onRemove={onRemove}
testID={`${testID}.selected_user`}

View file

@ -0,0 +1,243 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render, waitFor} from '@testing-library/react-native';
import React from 'react';
import {of as of$} from 'rxjs';
import TeamList from './team_list';
import TeamListWrapper from './';
// Mock database queries
jest.mock('@queries/servers/team', () => {
const {of} = require('rxjs');
return {
queryMyTeams: jest.fn(() => of([])),
queryJoinedTeams: jest.fn(() => ({
observe: jest.fn(() => of([])),
})),
};
});
jest.mock('@queries/servers/preference', () => {
const {of} = require('rxjs');
return {
queryPreferencesByCategoryAndName: jest.fn(() => ({
observe: jest.fn(() => of([])),
observeWithColumns: jest.fn(() => of([])),
})),
};
});
const {queryPreferencesByCategoryAndName} = require('@queries/servers/preference');
const {queryMyTeams, queryJoinedTeams} = require('@queries/servers/team');
// Mock WatermelonDB HOCs
jest.mock('@nozbe/watermelondb/react', () => ({
withDatabase: jest.fn((Component) => Component),
withObservables: jest.fn((_, observableMapper) => {
// eslint-disable-next-line react/display-name
return (Component: any) => (props: any) => {
const observables = observableMapper(props);
const mockedProps: Record<string, any> = {};
// eslint-disable-next-line guard-for-in
for (const key in observables) {
observables[key].subscribe((value: any) => {
mockedProps[key] = value;
});
}
return (
<Component
{...props}
{...mockedProps}
/>);
};
}),
}));
jest.mock('./team_list', () => {
return jest.fn(() => null); // Mock implementation for TeamList
});
describe('withTeams HOC', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should handle valid data correctly', async () => {
queryMyTeams.mockReturnValue({
observe: jest.fn(() => of$([{id: '1', roles: 'team_user'}])),
});
queryJoinedTeams.mockReturnValue({
observe: jest.fn(() => of$([
{id: '1', displayName: 'Team 1'},
{id: '2', displayName: 'Team 2'},
])),
});
queryPreferencesByCategoryAndName.mockReturnValue({
observeWithColumns: jest.fn(() => of$([{value: '1,2'}])),
});
render(<TeamListWrapper/>);
await waitFor(() => {
expect(TeamList).toHaveBeenCalledWith(
expect.objectContaining({
myOrderedTeams: [{id: '1', roles: 'team_user'}],
}),
expect.anything(),
);
});
});
it('should handle missing team IDs gracefully', async () => {
queryMyTeams.mockReturnValue({
observe: jest.fn(() => of$([{id: '1', roles: 'team_user'}])),
});
queryJoinedTeams.mockReturnValue({
observe: jest.fn(() => of$([
{id: '1', displayName: 'Team 1'},
{id: undefined, displayName: 'Team 2'},
])),
});
queryPreferencesByCategoryAndName.mockReturnValue({
observeWithColumns: jest.fn(() => of$([{value: '1,2'}])),
});
render(<TeamListWrapper/>);
await waitFor(() => {
expect(TeamList).toHaveBeenCalledWith(
expect.objectContaining({
myOrderedTeams: [{id: '1', roles: 'team_user'}],
}),
expect.anything(),
);
});
});
it('should handle empty team order preferences gracefully', async () => {
queryMyTeams.mockReturnValue({
observe: jest.fn(() => of$([{id: '1', roles: 'team_user'}])),
});
queryJoinedTeams.mockReturnValue({
observe: jest.fn(() => of$([
{id: '1', displayName: 'Team 1'},
{id: '2', displayName: 'Team 2'},
])),
});
queryPreferencesByCategoryAndName.mockReturnValue({
observeWithColumns: jest.fn(() => of$([])),
});
render(<TeamListWrapper/>);
await waitFor(() => {
expect(TeamList).toHaveBeenCalledWith(
expect.objectContaining({
myOrderedTeams: [{id: '1', roles: 'team_user'}],
}),
expect.anything(),
);
});
});
it('should handle undefined in team order preferences gracefully', async () => {
queryMyTeams.mockReturnValue({
observe: jest.fn(() => of$([
{id: '1', roles: 'team_user'},
{id: '2', roles: 'team_user'},
])),
});
queryJoinedTeams.mockReturnValue({
observe: jest.fn(() => of$([
{id: '1', displayName: 'Team 1'},
{id: '2', displayName: 'Team 2'},
])),
});
queryPreferencesByCategoryAndName.mockReturnValue({
observeWithColumns: jest.fn(() => of$([{value: '1,,2'}])),
});
render(<TeamListWrapper/>);
await waitFor(() => {
expect(TeamList).toHaveBeenCalledWith(
expect.objectContaining({
myOrderedTeams: [
{id: '1', roles: 'team_user'},
{id: '2', roles: 'team_user'},
],
}),
expect.anything(),
);
});
});
it('should handle wrong data in team order preferences gracefully', async () => {
queryMyTeams.mockReturnValue({
observe: jest.fn(() => of$([
{id: '1', roles: 'team_user'},
{id: '2', roles: 'team_user'},
])),
});
queryJoinedTeams.mockReturnValue({
observe: jest.fn(() => of$([
{id: '1', displayName: 'Team 1'},
{id: '2', displayName: 'Team 2'},
])),
});
queryPreferencesByCategoryAndName.mockReturnValue({
observeWithColumns: jest.fn(() => of$([{value: '1,undefined,2'}])),
});
render(<TeamListWrapper/>);
await waitFor(() => {
expect(TeamList).toHaveBeenCalledWith(
expect.objectContaining({
myOrderedTeams: [
{id: '1', roles: 'team_user'},
{id: '2', roles: 'team_user'},
],
}),
expect.anything(),
);
});
});
it('should handle empty teams list gracefully', async () => {
queryMyTeams.mockReturnValue({
observe: jest.fn(() => of$([])),
});
queryJoinedTeams.mockReturnValue({
observe: jest.fn(() => of$([])),
});
queryPreferencesByCategoryAndName.mockReturnValue({
observeWithColumns: jest.fn(() => of$([])),
});
render(<TeamListWrapper/>);
await waitFor(() => {
expect(TeamList).toHaveBeenCalledWith(
expect.objectContaining({
myOrderedTeams: [],
}),
expect.anything(),
);
});
});
});

View file

@ -14,7 +14,6 @@ import {queryJoinedTeams, queryMyTeams} from '@queries/servers/team';
import TeamList from './team_list';
import type {WithDatabaseArgs} from '@typings/database/database';
import type MyTeamModel from '@typings/database/models/servers/my_team';
const withTeams = withObservables([], ({database}: WithDatabaseArgs) => {
const myTeams = queryMyTeams(database).observe();
@ -26,26 +25,27 @@ const withTeams = withObservables([], ({database}: WithDatabaseArgs) => {
switchMap((p) => (p.length ? of$(p[0].value.split(',')) : of$([]))),
);
const myOrderedTeams = combineLatest([myTeams, order, teamIds]).pipe(
map(([ts, o, tids]) => {
let ids: string[] = o;
if (!o.length) {
ids = tids.
map(([memberships, o, teams]) => {
const sortedTeamIds = new Set(o);
const membershipMap = new Map(memberships.map((m) => [m.id, m]));
if (sortedTeamIds.size) {
const mySortedTeams = [...sortedTeamIds].
filter((id) => id && membershipMap.has(id)).
map((id) => membershipMap.get(id)!);
const extraTeams = teams.
filter((t) => t.id && !sortedTeamIds.has(t.id) && membershipMap.has(t.id)).
sort((a, b) => a.displayName.toLocaleLowerCase().localeCompare(b.displayName.toLocaleLowerCase())).
map((t) => t.id);
map((t) => membershipMap.get(t.id)!);
return [...mySortedTeams, ...extraTeams];
}
const teamMap = ts.reduce<{[x: string]: MyTeamModel}>((result, team) => {
result[team.id] = team;
return result;
}, {});
return ids.reduce<MyTeamModel[]>((result, id) => {
const t = teamMap[id];
if (t) {
result.push(t);
}
return result;
}, []);
return teams.
filter((t) => t.id && membershipMap.has(t.id)).
sort((a, b) => a.displayName.toLocaleLowerCase().localeCompare(b.displayName.toLocaleLowerCase())).
map((t) => membershipMap.get(t.id)!);
}),
);
return {

View file

@ -6,6 +6,7 @@ export const IMAGE_MIN_DIMENSION = 50;
export const MAX_GIF_SIZE = 100 * 1024 * 1024;
export const VIEWPORT_IMAGE_OFFSET = 70;
export const VIEWPORT_IMAGE_REPLY_OFFSET = 11;
export const VIEWPORT_IMAGE_ATTACHMENT_OFFSET = 31.5; // (2 * 12) MessageAttachment Padding + (2,5 + 5) AttachmentImage Padding
export const MAX_RESOLUTION = 7680 * 4320; // 8K, ~33MPX
export default {
@ -15,4 +16,5 @@ export default {
MAX_RESOLUTION,
VIEWPORT_IMAGE_OFFSET,
VIEWPORT_IMAGE_REPLY_OFFSET,
VIEWPORT_IMAGE_ATTACHMENT_OFFSET,
};

View file

@ -10,6 +10,7 @@ import ThemeProvider from '@context/theme';
import UserLocaleProvider from '@context/user_locale';
import DatabaseManager from '@database/manager';
import {subscribeActiveServers} from '@database/subscription/servers';
import {secureGetFromRecord} from '@utils/types';
import type {Database} from '@nozbe/watermelondb';
import type ServersModel from '@typings/database/models/app/servers';
@ -31,7 +32,7 @@ export function withServerDatabase<T extends JSX.IntrinsicAttributes>(Component:
if (server) {
const database =
DatabaseManager.serverDatabases[server.url]?.database;
secureGetFromRecord(DatabaseManager.serverDatabases, server.url)?.database;
if (database) {
setState({

View file

@ -63,9 +63,12 @@ export const transformThreadRecord = ({action, database, value}: TransformerArgs
*/
export const transformThreadParticipantRecord = ({action, database, value}: TransformerArgs): Promise<ThreadParticipantModel> => {
const raw = value.raw as ThreadParticipant;
const record = value.record as ThreadParticipantModel;
const isCreateAction = action === OperationType.CREATE;
// id of participant comes from server response
const fieldsMapper = (participant: ThreadParticipantModel) => {
participant._raw.id = isCreateAction ? `${raw.thread_id}-${raw.id}` : record.id;
participant.threadId = raw.thread_id;
participant.userId = raw.id;
};
@ -81,8 +84,11 @@ export const transformThreadParticipantRecord = ({action, database, value}: Tran
export const transformThreadInTeamRecord = ({action, database, value}: TransformerArgs): Promise<ThreadInTeamModel> => {
const raw = value.raw as ThreadInTeam;
const record = value.record as ThreadInTeamModel;
const isCreateAction = action === OperationType.CREATE;
const fieldsMapper = (threadInTeam: ThreadInTeamModel) => {
threadInTeam._raw.id = isCreateAction ? `${raw.thread_id}-${raw.team_id}` : record.id;
threadInTeam.threadId = raw.thread_id;
threadInTeam.teamId = raw.team_id;
};

View file

@ -3,6 +3,7 @@
import {General} from '@constants';
import {MENTIONS_REGEX} from '@constants/autocomplete';
import {isMessageAttachmentArray} from '@utils/message_attachment';
export const getNeededAtMentionedUsernames = (usernames: Set<string>, posts: Post[], excludeUsername?: string) => {
const usernamesToLoad = new Set<string>();
@ -36,8 +37,9 @@ export const getNeededAtMentionedUsernames = (usernames: Set<string>, posts: Pos
// These correspond to the fields searched by getMentionsEnabledFields on the server
findNeededUsernames(post.message);
if (post.props?.attachments) {
for (const attachment of post.props.attachments) {
const attachments = isMessageAttachmentArray(post.props?.attachments) ? post.props.attachments : undefined;
if (attachments) {
for (const attachment of attachments) {
findNeededUsernames(attachment.pretext);
findNeededUsernames(attachment.text);
}

View file

@ -5,6 +5,7 @@ import xRegExp from 'xregexp';
import {General} from '@constants';
import type {Model} from '@nozbe/watermelondb';
import type ChannelModel from '@typings/database/models/servers/channel';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
@ -91,3 +92,15 @@ export const filterAndSortMyChannels = ([myChannels, channels, notifyProps]: Fil
// Matches letters from any alphabet and numbers
const sqliteLikeStringRegex = xRegExp('[^\\p{L}\\p{Nd}]', 'g');
export const sanitizeLikeString = (value: string) => value.replace(sqliteLikeStringRegex, '_');
export function removeDuplicatesModels(array: Model[]) {
const seen = new Set();
return array.filter((item) => {
const key = `${item.collection.table}-${item.id}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}

View file

@ -60,7 +60,7 @@ class NetworkManager {
sessionConfiguration: {
allowsCellularAccess: true,
waitsForConnectivity: false,
httpMaximumConnectionsPerHost: 10,
httpMaximumConnectionsPerHost: 100,
cancelRequestsOnUnauthorized: true,
},
retryPolicyConfiguration: {

View file

@ -30,7 +30,7 @@ const enhanced = withObservables(['post'], ({serverUrl, post, database}: OwnProp
);
// The call is not active, so return early with what we need to render the post.
if (post.props.end_at) {
if (post.props?.end_at) {
return {
currentUser,
isMilitaryTime,

View file

@ -194,12 +194,12 @@ describe('getCallPropsFromPost', () => {
const props = getCallPropsFromPost(post);
expect(props.title).toBe(post.props.title);
expect(props.start_at).toBe(post.props.start_at);
expect(props.end_at).toBe(post.props.end_at);
expect(props.recordings).toBe(post.props.recordings);
expect(props.recording_files).toBe(post.props.recording_files);
expect(props.transcriptions).toBe(post.props.transcriptions);
expect(props.participants).toBe(post.props.participants);
expect(props.title).toBe(post.props?.title);
expect(props.start_at).toBe(post.props?.start_at);
expect(props.end_at).toBe(post.props?.end_at);
expect(props.recordings).toBe(post.props?.recordings);
expect(props.recording_files).toBe(post.props?.recording_files);
expect(props.transcriptions).toBe(post.props?.transcriptions);
expect(props.participants).toBe(post.props?.participants);
});
});

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls';
import {type CallsConfig, type CallPostProps, isCaption, type Caption, isCallJobMetadata, type CallJobMetadata} from '@mattermost/calls/lib/types';
import {Alert} from 'react-native';
import {SelectedTrackType, TextTrackType, type ISO639_1, type SelectedTrack, type TextTracks} from 'react-native-video';
@ -9,6 +10,7 @@ import {buildFileUrl} from '@actions/remote/file';
import {Calls, Post} from '@constants';
import {NOTIFICATION_SUB_TYPE} from '@constants/push_notification';
import {isMinimumServerVersion} from '@utils/helpers';
import {ensureNumber, ensureString, isArrayOf, isRecordOf, isStringArray} from '@utils/types';
import {displayUsername} from '@utils/user';
import type {
@ -17,7 +19,6 @@ import type {
CallsTheme,
CallsVersion,
} from '@calls/types/calls';
import type {CallsConfig, Caption, CallPostProps} from '@mattermost/calls/lib/types';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
import type {IntlShape} from 'react-intl';
@ -217,17 +218,17 @@ export function isCallsStartedMessage(payload?: NotificationData) {
return (payload?.message === 'You\'ve been invited to a call' || callsMessageRegex.test(payload?.message || ''));
}
export const hasCaptions = (postProps?: Record<string, any> & { captions?: Caption[] }): boolean => {
return !(!postProps || !postProps.captions?.[0]);
export const hasCaptions = (postProps?: Record<string, unknown>): boolean => {
return Boolean(isArrayOf<Caption>(postProps?.captions, isCaption) && postProps.captions[0]);
};
export const getTranscriptionUri = (serverUrl: string, postProps?: Record<string, any> & { captions?: Caption[] }): {
export const getTranscriptionUri = (serverUrl: string, postProps?: Record<string, unknown>): {
tracks?: TextTracks;
selected: SelectedTrack;
} => {
// Note: We're not using hasCaptions above because this tells typescript that the caption exists later.
// We could use some fancy typescript to do the same, but it's not worth the complexity.
if (!postProps || !postProps.captions?.[0]) {
if (!isArrayOf<Caption>(postProps?.captions, isCaption) || !postProps.captions[0]) {
return {
tracks: undefined,
selected: {type: SelectedTrackType.DISABLED, value: ''},
@ -247,20 +248,16 @@ export const getTranscriptionUri = (serverUrl: string, postProps?: Record<string
};
};
function isValidObject(v: any) {
return typeof v === 'object' && !Array.isArray(v) && v !== null;
}
export function getCallPropsFromPost(post: PostModel | Post): CallPostProps {
return {
title: typeof post.props?.title === 'string' ? post.props.title : '',
start_at: typeof post.props?.start_at === 'number' ? post.props.start_at : 0,
end_at: typeof post.props?.end_at === 'number' ? post.props.end_at : 0,
recordings: isValidObject(post.props?.recordings) ? post.props.recordings : {},
transcriptions: isValidObject(post.props?.transcriptions) ? post.props.transcriptions : {},
participants: Array.isArray(post.props?.participants) ? post.props.participants : [],
title: ensureString(post.props?.title),
start_at: ensureNumber(post.props?.start_at),
end_at: ensureNumber(post.props?.end_at),
recordings: isRecordOf<CallJobMetadata>(post.props?.recordings, isCallJobMetadata) ? post.props.recordings : {},
transcriptions: isRecordOf<CallJobMetadata>(post.props?.transcriptions, isCallJobMetadata) ? post.props.transcriptions : {},
participants: isStringArray(post.props?.participants) ? post.props.participants : [],
// DEPRECATED
recording_files: Array.isArray(post.props?.recording_files) ? post.props.recording_files : [],
recording_files: isStringArray(post.props?.recording_files) ? post.props.recording_files : [],
};
}

View file

@ -20,6 +20,7 @@ import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {checkDialogElementForError, checkIfErrorsMatchElements} from '@utils/integrations';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import DialogIntroductionText from '../interactive_dialog/dialog_introduction_text';
import {buildNavigationButton, dismissModal, setButtons} from '../navigation';
@ -263,7 +264,7 @@ function AppsFormComponent({
elements?.forEach((element) => {
const newError = checkDialogElementForError(
element,
element.name === form.submit_buttons ? button : values[element.name],
element.name === form.submit_buttons ? button : secureGetFromRecord(values, element.name),
);
if (newError) {
hasErrors = true;
@ -408,13 +409,17 @@ function AppsFormComponent({
/>
}
{form.fields && form.fields.filter((f) => f.name !== form.submit_buttons).map((field) => {
const value = secureGetFromRecord(values, field.name);
if (!value) {
return null;
}
return (
<AppsFormField
field={field}
key={field.name}
name={field.name}
errorText={errors[field.name]}
value={values[field.name]}
errorText={secureGetFromRecord(errors, field.name)}
value={value}
performLookup={performLookup}
onChange={onChange}
/>

View file

@ -308,7 +308,7 @@ const BookmarkFile = ({channelId, close, disabled, initialFile, maxFileSize, set
ellipsizeMode='tail'
style={styles.filename}
>
{decodeURIComponent(file.name.trim())}
{file.name.trim()}
</Text>
{info}
</View>

View file

@ -159,13 +159,13 @@ const ChannelBookmarkAddOrEdit = ({
...(bookmark || emptyBookmark),
owner_id: ownerId,
channel_id: channelId,
display_name: decodeURIComponent(f.name),
display_name: f.name,
type: 'file',
file_id: f.id,
};
setBookmarkToSave(b);
setFile(f);
}, [bookmark, channelId, ownerId]);
}, [bookmark, channelId, ownerId, setBookmarkToSave]);
const setBookmarkDisplayName = useCallback((displayName: string) => {
if (bookmark) {

View file

@ -68,8 +68,7 @@ const SkinSelector = ({onSelectSkin, selected, skins}: Props) => {
/>
</View>
<View style={[styles.skins, isTablet && {marginRight: 10}]}>
{Object.keys(skins).map((key) => {
const name = skins[key];
{Object.entries(skins).map(([key, name]) => {
return (
<View
key={name}

View file

@ -44,8 +44,7 @@ const styles = StyleSheet.create({
},
});
const skins = Object.keys(skinCodes).reduce<Record<string, string>>((result, value) => {
const skin = skinCodes[value];
const skins = Object.entries(skinCodes).reduce<Record<string, string>>((result, [value, skin]) => {
if (value === 'default') {
result[value] = 'hand';
} else {

View file

@ -273,7 +273,7 @@ const DownloadWithAction = ({action, item, onDownloadSuccess, setAction, gallery
actionToExecute({
code: 200,
ok: true,
data: {path},
data: {path: path.replace('file://', '')},
});
} else {
if (item.type === 'avatar') {

View file

@ -9,6 +9,7 @@ import {SafeAreaView, type Edge, useSafeAreaInsets} from 'react-native-safe-area
import {Events} from '@constants';
import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery';
import {changeOpacity} from '@utils/theme';
import {ensureString} from '@utils/types';
import {displayUsername} from '@utils/user';
import Actions from './actions';
@ -71,14 +72,14 @@ const Footer = ({
let overrideIconUrl;
if (enablePostIconOverride && post?.props?.use_user_icon !== 'true' && post?.props?.override_icon_url) {
overrideIconUrl = post.props.override_icon_url;
overrideIconUrl = ensureString(post.props.override_icon_url);
}
let userDisplayName;
if (item.type === 'avatar') {
userDisplayName = item.name;
} else if (enablePostUsernameOverride && post?.props?.override_username) {
userDisplayName = post.props.override_username as string;
userDisplayName = ensureString(post.props.override_username);
} else {
userDisplayName = displayUsername(author, undefined, teammateNameDisplay);
}

View file

@ -15,6 +15,7 @@ import {useIsTablet} from '@hooks/device';
import {dismissOverlay} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import Icon from './icon';
import Server from './server';
@ -148,7 +149,7 @@ const InAppNotification = ({componentId, serverName, serverUrl, notification}: I
// eslint-disable-next-line new-cap
const gesture = Gesture.Pan().activeOffsetY(-20).onStart(() => runOnJS(animateDismissOverlay)());
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
const database = secureGetFromRecord(DatabaseManager.serverDatabases, serverUrl)?.database;
return (
<GestureHandlerRootView style={styles.gestureHandler}>

View file

@ -24,6 +24,7 @@ import {
} from '@screens/navigation';
import {filterChannelsMatchingTerm} from '@utils/channel';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {typography} from '@utils/typography';
import ChannelListRow from './channel_list_row';
@ -68,7 +69,8 @@ const extractItemKey = (dataSource: string, item: DataType): string => {
const toggleFromMap = <T extends DialogOption | Channel | UserProfile>(current: MultiselectSelectedMap, key: string, item: T): MultiselectSelectedMap => {
const newMap = {...current};
if (current[key]) {
const hasValue = Boolean(secureGetFromRecord<any>(current, key));
if (hasValue) {
delete newMap[key];
} else {
newMap[key] = item;
@ -96,7 +98,7 @@ const filterSearchData = (source: string, searchData: DataTypeList, searchTerm:
const handleIdSelection = (dataSource: string, currentIds: {[id: string]: DataType}, item: DataType) => {
const newSelectedIds = {...currentIds};
const key = extractItemKey(dataSource, item);
const wasSelected = currentIds[key];
const wasSelected = secureGetFromRecord(currentIds, key);
if (wasSelected) {
Reflect.deleteProperty(newSelectedIds, key);
@ -239,7 +241,9 @@ function IntegrationSelector(
} else {
setMultiselectSelected((current) => {
const multiselectSelectedItems = {...current};
delete multiselectSelectedItems[itemKey];
if (secureGetFromRecord<any>(multiselectSelectedItems, itemKey) !== undefined) {
delete multiselectSelectedItems[itemKey];
}
return multiselectSelectedItems;
});
}
@ -470,7 +474,7 @@ function IntegrationSelector(
}, [multiselectSelected, theme, isMultiselect]);
const renderOptionItem = useCallback((itemProps: any) => {
const itemSelected = Boolean(multiselectSelected[itemProps.item.value]);
const itemSelected = Boolean(secureGetFromRecord<any>(multiselectSelected, itemProps.item.value));
return (
<OptionListRow
key={itemProps.id}

View file

@ -16,6 +16,7 @@ import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation';
import {checkDialogElementForError, checkIfErrorsMatchElements} from '@utils/integrations';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import DialogElement from './dialog_element';
import DialogIntroductionText from './dialog_introduction_text';
@ -142,7 +143,7 @@ function InteractiveDialog({
let hasErrors = false;
if (elements) {
elements.forEach((elem) => {
const newError = checkDialogElementForError(elem, values[elem.name]);
const newError = checkDialogElementForError(elem, secureGetFromRecord(values, elem.name));
if (newError) {
newErrors[elem.name] = intl.formatMessage({id: newError.id, defaultMessage: newError.defaultMessage}, newError.values);
hasErrors = true;
@ -244,6 +245,10 @@ function InteractiveDialog({
/>
}
{Boolean(elements) && elements.map((e) => {
const value = secureGetFromRecord(values, e.name);
if (value === undefined) {
return null;
}
return (
<DialogElement
key={'dialogelement' + e.name}
@ -252,13 +257,13 @@ function InteractiveDialog({
type={e.type}
subtype={e.subtype}
helpText={e.help_text}
errorText={errors[e.name]}
errorText={secureGetFromRecord(errors, e.name)}
placeholder={e.placeholder}
maxLength={e.max_length}
dataSource={e.data_source}
optional={e.optional}
options={e.options}
value={values[e.name]}
value={value}
onChange={onChange}
/>
);

View file

@ -19,6 +19,7 @@ import {dismissModal, setButtons} from '@screens/navigation';
import {isEmail} from '@utils/helpers';
import {mergeNavigationOptions} from '@utils/navigation';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {isGuest} from '@utils/user';
import Selection from './selection';
@ -183,7 +184,7 @@ export default function Invite({
const id = email ? item : (item as UserProfile).id;
const newSelectedIds = Object.assign({}, selectedIds);
if (!selectedIds[id]) {
if (!secureGetFromRecord(selectedIds, id)) {
newSelectedIds[id] = item;
}
@ -301,8 +302,9 @@ export default function Invite({
}
for (const email of emails) {
if (membersWithError[email]) {
notSent.push({userId: email, reason: membersWithError[email]});
const error = secureGetFromRecord(membersWithError, email);
if (error) {
notSent.push({userId: email, reason: error});
} else {
sent.push({userId: email, reason: formatMessage({id: 'invite.summary.email_invite', defaultMessage: 'An invitation email has been sent'})});
}

View file

@ -9,6 +9,7 @@ import CompassIcon from '@components/compass_icon';
import UserItem from '@components/user_item';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {typography} from '@utils/typography';
import TextItem, {TextItemType} from './text_item';
@ -114,7 +115,7 @@ export default function SummaryReport({
</Text>
</View>
{invites.map(({userId, reason}) => {
const item = selectedIds[userId];
const item = secureGetFromRecord(selectedIds, userId);
return (
<View

View file

@ -26,6 +26,7 @@ import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {closePermalink} from '@utils/permalink';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {typography} from '@utils/typography';
import PermalinkError from './permalink_error';
@ -176,7 +177,7 @@ function Permalink({
return;
}
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
const database = secureGetFromRecord(DatabaseManager.serverDatabases, serverUrl)?.database;
if (!database) {
setError({unreachable: true});
setLoading(false);

View file

@ -4,6 +4,7 @@
import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes} from '@constants/apps';
import {generateId} from './general';
import {isArrayOf, isStringArray} from './types';
export function cleanBinding(binding: AppBinding, topLocation: string): AppBinding|null {
return cleanBindingRec(binding, topLocation, 0);
@ -253,3 +254,297 @@ export const makeCallErrorResponse = (errMessage: string): AppCallResponse<any>
};
export const filterEmptyOptions = (option: AppSelectOption): boolean => Boolean(option.value && !option.value.match(/^[ \t]+$/));
function isAppExpand(v: unknown): v is AppExpand {
if (typeof v !== 'object' || v === null) {
return false;
}
const expand = v as AppExpand;
if (expand.app !== undefined && typeof expand.app !== 'string') {
return false;
}
if (expand.acting_user !== undefined && typeof expand.acting_user !== 'string') {
return false;
}
if (expand.channel !== undefined && typeof expand.channel !== 'string') {
return false;
}
if (expand.config !== undefined && typeof expand.config !== 'string') {
return false;
}
if (expand.mentioned !== undefined && typeof expand.mentioned !== 'string') {
return false;
}
if (expand.parent_post !== undefined && typeof expand.parent_post !== 'string') {
return false;
}
if (expand.post !== undefined && typeof expand.post !== 'string') {
return false;
}
if (expand.root_post !== undefined && typeof expand.root_post !== 'string') {
return false;
}
if (expand.team !== undefined && typeof expand.team !== 'string') {
return false;
}
if (expand.user !== undefined && typeof expand.user !== 'string') {
return false;
}
return true;
}
function isAppCall(obj: unknown): obj is AppCall {
if (typeof obj !== 'object' || obj === null) {
return false;
}
const call = obj as AppCall;
if (typeof call.path !== 'string') {
return false;
}
if (call.expand !== undefined && !isAppExpand(call.expand)) {
return false;
}
// Here we're assuming that 'state' can be of any type, so no type check for 'state'
return true;
}
function isAppFormValue(v: unknown): v is AppFormValue {
if (typeof v === 'string') {
return true;
}
if (typeof v === 'boolean') {
return true;
}
if (v === null) {
return true;
}
return isAppSelectOption(v);
}
function isAppSelectOption(v: unknown): v is AppSelectOption {
if (typeof v !== 'object' || v === null) {
return false;
}
const option = v as AppSelectOption;
if (typeof option.label !== 'string' || typeof option.value !== 'string') {
return false;
}
if (option.icon_data !== undefined && typeof option.icon_data !== 'string') {
return false;
}
return true;
}
function isAppField(v: unknown): v is AppField {
if (typeof v !== 'object' || v === null) {
return false;
}
const field = v as AppField;
if (typeof field.name !== 'string' || typeof field.type !== 'string') {
return false;
}
if (field.is_required !== undefined && typeof field.is_required !== 'boolean') {
return false;
}
if (field.readonly !== undefined && typeof field.readonly !== 'boolean') {
return false;
}
if (field.value !== undefined && !isAppFormValue(field.value)) {
return false;
}
if (field.description !== undefined && typeof field.description !== 'string') {
return false;
}
if (field.label !== undefined && typeof field.label !== 'string') {
return false;
}
if (field.hint !== undefined && typeof field.hint !== 'string') {
return false;
}
if (field.position !== undefined && typeof field.position !== 'number') {
return false;
}
if (field.modal_label !== undefined && typeof field.modal_label !== 'string') {
return false;
}
if (field.refresh !== undefined && typeof field.refresh !== 'boolean') {
return false;
}
if (field.options !== undefined && !isArrayOf(field.options, isAppSelectOption)) {
return false;
}
if (field.multiselect !== undefined && typeof field.multiselect !== 'boolean') {
return false;
}
if (field.lookup !== undefined && !isAppCall(field.lookup)) {
return false;
}
if (field.subtype !== undefined && typeof field.subtype !== 'string') {
return false;
}
if (field.min_length !== undefined && typeof field.min_length !== 'number') {
return false;
}
if (field.max_length !== undefined && typeof field.max_length !== 'number') {
return false;
}
return true;
}
function isAppForm(v: unknown): v is AppForm {
if (typeof v !== 'object' || v === null) {
return false;
}
const form = v as AppForm;
if (form.title !== undefined && typeof form.title !== 'string') {
return false;
}
if (form.header !== undefined && typeof form.header !== 'string') {
return false;
}
if (form.footer !== undefined && typeof form.footer !== 'string') {
return false;
}
if (form.icon !== undefined && typeof form.icon !== 'string') {
return false;
}
if (form.submit_buttons !== undefined && typeof form.submit_buttons !== 'string') {
return false;
}
if (form.cancel_button !== undefined && typeof form.cancel_button !== 'boolean') {
return false;
}
if (form.submit_on_cancel !== undefined && typeof form.submit_on_cancel !== 'boolean') {
return false;
}
if (form.fields !== undefined && !isArrayOf(form.fields, isAppField)) {
return false;
}
if (form.source !== undefined && !isAppCall(form.source)) {
return false;
}
if (form.submit !== undefined && !isAppCall(form.submit)) {
return false;
}
if (form.depends_on !== undefined && !isStringArray(form.depends_on)) {
return false;
}
return true;
}
export function isAppBinding(obj: unknown): obj is AppBinding {
if (typeof obj !== 'object' || obj === null) {
return false;
}
const binding = obj as AppBinding;
if (typeof binding.app_id !== 'string' || typeof binding.label !== 'string') {
return false;
}
if (binding.location !== undefined && typeof binding.location !== 'string') {
return false;
}
if (binding.icon !== undefined && typeof binding.icon !== 'string') {
return false;
}
if (binding.hint !== undefined && typeof binding.hint !== 'string') {
return false;
}
if (binding.description !== undefined && typeof binding.description !== 'string') {
return false;
}
if (binding.role_id !== undefined && typeof binding.role_id !== 'string') {
return false;
}
if (binding.depends_on_team !== undefined && typeof binding.depends_on_team !== 'boolean') {
return false;
}
if (binding.depends_on_channel !== undefined && typeof binding.depends_on_channel !== 'boolean') {
return false;
}
if (binding.depends_on_user !== undefined && typeof binding.depends_on_user !== 'boolean') {
return false;
}
if (binding.depends_on_post !== undefined && typeof binding.depends_on_post !== 'boolean') {
return false;
}
if (binding.bindings !== undefined && !isArrayOf(binding.bindings, isAppBinding)) {
return false;
}
if (binding.form !== undefined && !isAppForm(binding.form)) {
return false;
}
if (binding.submit !== undefined && !isAppCall(binding.submit)) {
return false;
}
return true;
}

View file

@ -11,6 +11,7 @@ import Permissions from 'react-native-permissions';
import {dismissBottomSheet} from '@screens/navigation';
import {extractFileInfo, lookupMimeType} from '@utils/file';
import {logWarning} from '@utils/log';
import {safeDecodeURIComponent} from '@utils/url';
import type {IntlShape} from 'react-intl';
@ -128,7 +129,7 @@ export default class FilePickerUtil {
const type = file.type || lookupMimeType(uri);
let fileName = file.fileName;
if (type.includes('video/') && uri) {
fileName = decodeURIComponent(uri.split('\\').pop()?.split('/').pop() || '');
fileName = safeDecodeURIComponent(uri.split('\\').pop()?.split('/').pop() || '');
}
if (uri) {

View file

@ -413,7 +413,7 @@ export async function extractFileInfo(files: Array<Asset | DocumentPickerRespons
} else {
const localPath = file.uri || '';
try {
const fileInfo = await getInfoAsync(decodeURIComponent(localPath), {size: true});
const fileInfo = await getInfoAsync(localPath, {size: true});
if ('size' in fileInfo) {
outFile.size = fileInfo.size || 0;
outFile.name = localPath.substring(localPath.lastIndexOf('/') + 1);

View file

@ -29,7 +29,7 @@ export const clampVelocity = (velocity: number, minVelocity: number, maxVelocity
return Math.max(Math.min(velocity, -minVelocity), -maxVelocity);
};
export const fileToGalleryItem = (file: FileInfo, authorId?: string, postProps?: Record<string, any>, lastPictureUpdate = 0): GalleryItemType => {
export const fileToGalleryItem = (file: FileInfo, authorId?: string, postProps?: Record<string, unknown>, lastPictureUpdate = 0): GalleryItemType => {
let type: GalleryItemType['type'] = 'file';
if (isVideo(file)) {
type = 'video';

View file

@ -4,7 +4,14 @@
import {Dimensions} from 'react-native';
import {View} from '@constants';
import {IMAGE_MAX_HEIGHT, IMAGE_MIN_DIMENSION, MAX_GIF_SIZE, VIEWPORT_IMAGE_OFFSET, VIEWPORT_IMAGE_REPLY_OFFSET} from '@constants/image';
import {
IMAGE_MAX_HEIGHT,
IMAGE_MIN_DIMENSION,
MAX_GIF_SIZE,
VIEWPORT_IMAGE_ATTACHMENT_OFFSET,
VIEWPORT_IMAGE_OFFSET,
VIEWPORT_IMAGE_REPLY_OFFSET,
} from '@constants/image';
export const calculateDimensions = (height?: number, width?: number, viewPortWidth = 0, viewPortHeight = 0) => {
'worklet';
@ -47,7 +54,7 @@ export const calculateDimensions = (height?: number, width?: number, viewPortWid
};
};
export function getViewPortWidth(isReplyPost: boolean, tabletOffset = false) {
export function getViewPortWidth(isReplyPost: boolean, tabletOffset = false, imageAttachmentOffset = false) {
const {width, height} = Dimensions.get('window');
let portraitPostWidth = Math.min(width, height) - VIEWPORT_IMAGE_OFFSET;
@ -59,6 +66,10 @@ export function getViewPortWidth(isReplyPost: boolean, tabletOffset = false) {
portraitPostWidth -= VIEWPORT_IMAGE_REPLY_OFFSET;
}
if (imageAttachmentOffset) {
portraitPostWidth -= VIEWPORT_IMAGE_ATTACHMENT_OFFSET;
}
return portraitPostWidth;
}

View file

@ -0,0 +1,195 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {isArrayOf} from './types';
export function getStatusColors(theme: Theme): Dictionary<string> {
return {
good: '#00c100',
warning: '#dede01',
danger: theme.errorTextColor,
default: theme.centerChannelColor,
primary: theme.buttonBg,
success: theme.onlineIndicator,
};
}
export function isMessageAttachmentArray(v: unknown): v is MessageAttachment[] {
return isArrayOf(v, isMessageAttachment);
}
function isPostActionOption(v: unknown): v is PostActionOption {
if (typeof v !== 'object' || !v) {
return false;
}
if ('text' in v && typeof v.text !== 'string') {
return false;
}
if ('value' in v && typeof v.value !== 'string') {
return false;
}
return true;
}
function isPostAction(v: unknown): v is PostAction {
if (typeof v !== 'object' || !v) {
return false;
}
if (!('id' in v)) {
return false;
}
if (typeof v.id !== 'string') {
return false;
}
if (!('name' in v)) {
return false;
}
if (typeof v.name !== 'string') {
return false;
}
if ('type' in v && typeof v.type !== 'string') {
return false;
}
if ('disabled' in v && typeof v.disabled !== 'boolean') {
return false;
}
if ('style' in v && typeof v.style !== 'string') {
return false;
}
if ('data_source' in v && typeof v.data_source !== 'string') {
return false;
}
if ('options' in v && !isArrayOf(v.options, isPostActionOption)) {
return false;
}
if ('default_option' in v && typeof v.default_option !== 'string') {
return false;
}
if ('cookie' in v && typeof v.cookie !== 'string') {
return false;
}
return true;
}
function isMessageAttachmentField(v: unknown) {
if (typeof v !== 'object') {
return false;
}
if (!v) {
return false;
}
if (!('title' in v)) {
return false;
}
if (typeof v.title !== 'string') {
return false;
}
if (!('value' in v)) {
return false;
}
if (typeof v.value === 'object' && v.value && 'toString' in v.value && typeof v.value.toString !== 'function') {
return false;
}
if ('short' in v && typeof v.short !== 'boolean') {
return false;
}
return true;
}
function isMessageAttachment(v: unknown): v is MessageAttachment {
if (typeof v !== 'object' || !v) {
return false;
}
if ('fallback' in v && typeof v.fallback !== 'string') {
return false;
}
// We may consider adding more validation to what color may be
if ('color' in v && typeof v.color !== 'string') {
return false;
}
if ('pretext' in v && typeof v.pretext !== 'string') {
return false;
}
if ('author_name' in v && typeof v.author_name !== 'string') {
return false;
}
// Where it is used, we are calling isUrlSafe. We could consider calling it here
if ('author_link' in v && typeof v.author_link !== 'string') {
return false;
}
// We may need more validation since this is going to be passed to an img src prop
if ('author_icon' in v && typeof v.author_icon !== 'string') {
return false;
}
if ('title' in v && typeof v.title !== 'string') {
return false;
}
// Where it is used, we are calling isUrlSafe. We could consider calling it here
if ('title_link' in v && typeof v.title_link !== 'string') {
return false;
}
if ('text' in v && typeof v.text !== 'string') {
return false;
}
// We may need more validation since this is going to be passed to an img src prop
if ('image_url' in v && typeof v.image_url !== 'string') {
return false;
}
// We may need more validation since this is going to be passed to an img src prop
if ('thumb_url' in v && typeof v.thumb_url !== 'string') {
return false;
}
// We are truncating if the size is more than some constant. We could check this here
if ('footer' in v && typeof v.footer !== 'string') {
return false;
}
// We may need more validation since this is going to be passed to an img src prop
if ('footer_icon' in v && typeof v.footer_icon !== 'string') {
return false;
}
if ('fields' in v && v.fields !== null && !isArrayOf(v.fields, isMessageAttachmentField)) {
return false;
}
if ('actions' in v && !isArrayOf(v.actions, isPostAction)) {
return false;
}
return true;
}

View file

@ -3,7 +3,7 @@
import {Preferences} from '@constants';
import {getStatusColors} from './message_attachment_colors';
import {getStatusColors} from './message_attachment';
describe('getStatusColors', () => {
const mockTheme = Preferences.THEMES.denim;

View file

@ -1,13 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function getStatusColors(theme: Theme): Dictionary<string> {
return {
good: '#00c100',
warning: '#dede01',
danger: theme.errorTextColor,
default: theme.centerChannelColor,
primary: theme.buttonBg,
success: theme.onlineIndicator,
};
}

View file

@ -85,7 +85,7 @@ describe('post utils', () => {
const post = {
props: {
from_webhook: 'true',
},
} as Record<string, unknown>,
} as PostModel;
const result = isFromWebhook(post);
@ -96,7 +96,7 @@ describe('post utils', () => {
const post = {
props: {
from_webhook: 'false',
},
} as Record<string, unknown>,
} as PostModel;
const result = isFromWebhook(post);
@ -149,7 +149,7 @@ describe('post utils', () => {
const post = {
props: {
failed: true,
},
} as Record<string, unknown>,
pendingPostId: 'id',
id: 'id',
updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000,
@ -471,7 +471,7 @@ describe('post utils', () => {
props: {
from_webhook: 'true',
override_username: 'webhook_user',
},
} as Record<string, unknown>,
} as PostModel;
const result = postUserDisplayName(post, undefined, undefined, true);
@ -482,7 +482,7 @@ describe('post utils', () => {
const post = {
props: {
from_webhook: 'false',
},
} as Record<string, unknown>,
} as PostModel;
const author = {
username: 'user1',
@ -497,7 +497,7 @@ describe('post utils', () => {
const post = {
props: {
from_webhook: 'false',
},
} as Record<string, unknown>,
} as PostModel;
const author = {
username: 'user1',

View file

@ -11,6 +11,7 @@ import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE} from '@i18n';
import {getUserById} from '@queries/servers/user';
import {toMilliseconds} from '@utils/datetime';
import {ensureString} from '@utils/types';
import {displayUsername, getUserIdFromChannelName} from '@utils/user';
import type PostModel from '@typings/database/models/servers/post';
@ -36,7 +37,7 @@ export function areConsecutivePosts(post: PostModel, previousPost: PostModel) {
}
export function isFromWebhook(post: PostModel | Post): boolean {
return post.props && post.props.from_webhook === 'true';
return post.props?.from_webhook === 'true';
}
export function isEdited(post: PostModel): boolean {
@ -48,7 +49,7 @@ export function isPostEphemeral(post: PostModel): boolean {
}
export function isPostFailed(post: PostModel): boolean {
return post.props?.failed || ((post.pendingPostId === post.id) && (Date.now() > post.updateAt + POST_TIME_TO_FAIL));
return Boolean(post.props?.failed) || ((post.pendingPostId === post.id) && (Date.now() > post.updateAt + POST_TIME_TO_FAIL));
}
export function isPostPendingOrFailed(post: PostModel): boolean {
@ -64,8 +65,13 @@ export function fromAutoResponder(post: PostModel): boolean {
}
export function postUserDisplayName(post: PostModel, author?: UserModel, teammateNameDisplay?: string, enablePostUsernameOverride = false) {
if (isFromWebhook(post) && post.props?.override_username && enablePostUsernameOverride) {
return post.props.override_username;
const overrideUsername = ensureString(post.props?.override_username);
if (
isFromWebhook(post) &&
enablePostUsernameOverride &&
overrideUsername
) {
return overrideUsername;
}
return displayUsername(author, author?.locale || DEFAULT_LOCALE, teammateNameDisplay, true);

View file

@ -319,7 +319,7 @@ describe('generateCombinedPost', () => {
const result = generateCombinedPost('combined-post-id', systemPosts);
// Ensure all post ids are included in the system_post_ids prop
expect(result.props.system_post_ids).toEqual(['post1', 'post2', 'post3']);
expect(result.props?.system_post_ids).toEqual(['post1', 'post2', 'post3']);
});
it('should include combined messages in the props object', () => {
@ -331,7 +331,7 @@ describe('generateCombinedPost', () => {
const result = generateCombinedPost('combined-post-id', systemPosts);
// Ensure the messages prop includes all individual messages
expect(result.props.messages).toEqual(['Message 1', 'Message 2']);
expect(result.props?.messages).toEqual(['Message 1', 'Message 2']);
});
it('should set the post type to COMBINED_USER_ACTIVITY', () => {
@ -387,7 +387,7 @@ describe('generateCombinedPost', () => {
const result = generateCombinedPost(combinedPostId, systemPosts);
// Extract the post types from the sorted messages in the combined post props
const sortedPostTypes = result.props.user_activity_posts.map((post: PostModel) => post.type);
const sortedPostTypes = (result.props?.user_activity_posts as any).map((post: PostModel) => post.type);
// Expect the post types to be sorted based on their priorities in comparePostTypes
expect(sortedPostTypes).toEqual([
@ -422,9 +422,9 @@ describe('generateCombinedPost', () => {
const result = generateCombinedPost(combinedPostId, systemPosts);
// Ensure user activities are combined for ADD_TO_TEAM
expect(result.props.user_activity_posts.length).toBe(2);
expect(result.props.user_activity_posts[0].props.addedUserId).toBe('user1');
expect(result.props.user_activity_posts[1].props.addedUserId).toBe('user2');
expect((result.props?.user_activity_posts as any).length).toBe(2);
expect((result.props?.user_activity_posts as any)[0].props.addedUserId).toBe('user1');
expect((result.props?.user_activity_posts as any)[1].props.addedUserId).toBe('user2');
});
it('should combine user activity for REMOVE_FROM_CHANNEL posts correctly', () => {
@ -451,9 +451,9 @@ describe('generateCombinedPost', () => {
const result = generateCombinedPost(combinedPostId, systemPosts);
// Ensure user activities are combined for REMOVE_FROM_CHANNEL
expect(result.props.user_activity_posts.length).toBe(2);
expect(result.props.user_activity_posts[0].props.removedUserId).toBe('user3');
expect(result.props.user_activity_posts[1].props.removedUserId).toBe('user4');
expect((result.props?.user_activity_posts as any).length).toBe(2);
expect((result.props?.user_activity_posts as any)[0].props.removedUserId).toBe('user3');
expect((result.props?.user_activity_posts as any)[1].props.removedUserId).toBe('user4');
});
it('should handle empty systemPosts gracefully', () => {

View file

@ -6,6 +6,7 @@ import moment from 'moment-timezone';
import {Post} from '@constants';
import {toMilliseconds} from '@utils/datetime';
import {isFromWebhook} from '@utils/post';
import {ensureString, isArrayOf, isStringArray} from '@utils/types';
import type {PostList, PostWithPrevAndNext} from '@typings/components/post_list';
import type PostModel from '@typings/database/models/servers/post';
@ -154,9 +155,11 @@ function isJoinLeavePostForUsername(post: PostModel, currentUsername: string): b
return false;
}
if (post.props.user_activity_posts) {
// We can be more lax with the types here because the recursive function only checks
// whether it is an array, or comparison with strings, so it should be safe enough.
if (Array.isArray(post.props.user_activity_posts)) {
for (const childPost of post.props.user_activity_posts as PostModel[]) {
if (isJoinLeavePostForUsername(childPost, currentUsername)) {
if (childPost && isJoinLeavePostForUsername(childPost, currentUsername)) {
// If any of the contained posts are for this user, the client will
// need to figure out how to render the post
return true;
@ -264,8 +267,8 @@ function combineUserActivitySystemPost(systemPosts: PostModel[]) {
postType === Post.POST_TYPES.ADD_TO_CHANNEL ||
postType === Post.POST_TYPES.REMOVE_FROM_CHANNEL
) {
const userId = post.props.addedUserId || post.props.removedUserId;
const username = post.props.addedUsername || post.props.removedUsername;
const userId = ensureString(post.props?.addedUserId) || ensureString(post.props?.removedUserId);
const username = ensureString(post.props?.addedUsername) || ensureString(post.props?.removedUsername);
if (combinedPostType) {
if (Array.isArray(combinedPostType[post.userId])) {
throw new Error('Invalid Post activity data');
@ -384,3 +387,55 @@ export function shouldFilterJoinLeavePost(post: PostModel, showJoinLeave: boolea
// Don't filter out join/leave messages about the current user
return !isJoinLeavePostForUsername(post, currentUsername);
}
export type MessageData = {
actorId?: string;
postType: string;
userIds: string[];
}
function isMessageData(v: unknown): v is MessageData {
if (typeof v !== 'object' || !v) {
return false;
}
if (('actorId' in v) && typeof v.actorId !== 'string') {
return false;
}
if (!('postType' in v) || typeof v.postType !== 'string') {
return false;
}
if (!('userIds' in v) || !isStringArray(v.userIds)) {
return false;
}
return true;
}
export type UserActivityProp = {
allUserIds: string[];
allUsernames: string[];
messageData: MessageData[];
}
export function isUserActivityProp(v: unknown): v is UserActivityProp {
if (typeof v !== 'object' || !v) {
return false;
}
if (!('allUserIds' in v) || !isStringArray(v.allUserIds)) {
return false;
}
if (!('allUsernames' in v) || !isStringArray(v.allUsernames)) {
return false;
}
if (!('messageData' in v) || !isArrayOf(v.messageData, isMessageData)) {
return false;
}
return true;
}

42
app/utils/types.ts Normal file
View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function isArrayOf<T>(v: unknown, check: (e: unknown) => boolean): v is T[] {
if (!Array.isArray(v)) {
return false;
}
return v.every(check);
}
export function isStringArray(v: unknown): v is string[] {
return isArrayOf(v, (e) => typeof e === 'string');
}
export function isRecordOf<T>(v: unknown, check: (e: unknown) => boolean): v is Record<string, T> {
if (typeof v !== 'object' || !v) {
return false;
}
if (!(Object.keys(v).every((k) => typeof k === 'string'))) {
return false;
}
if (!(Object.values(v).every(check))) {
return false;
}
return true;
}
export function ensureString(v: unknown): string {
return typeof v === 'string' ? v : '';
}
export function ensureNumber(v: unknown): number {
return typeof v === 'number' ? v : 0;
}
export function secureGetFromRecord<T>(v: Record<string, T> | undefined, key: string) {
return typeof v === 'object' && v && Object.prototype.hasOwnProperty.call(v, key) ? v[key] : undefined;
}

View file

@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {safeDecodeURIComponent} from './index';
describe('safeDecodeURIComponent', () => {
test('should decode a valid URI component', () => {
const encoded = 'Hello%20World';
const decoded = safeDecodeURIComponent(encoded);
expect(decoded).toBe('Hello World');
});
test('should return the input if it is not a valid URI component', () => {
const invalidEncoded = '%E0%A4%A';
const result = safeDecodeURIComponent(invalidEncoded);
expect(result).toBe(invalidEncoded);
});
test('should decode a complex URI component', () => {
const encoded = 'Hello%20World%21%20How%20are%20you%3F';
const decoded = safeDecodeURIComponent(encoded);
expect(decoded).toBe('Hello World! How are you?');
});
test('should return empty string if input is empty', () => {
const encoded = '';
const decoded = safeDecodeURIComponent(encoded);
expect(decoded).toBe('');
});
});

View file

@ -252,3 +252,11 @@ export function extractFilenameFromUrl(url: string) {
const uri = urlParse(url);
return uri.pathname.split('/').pop();
}
export function safeDecodeURIComponent(v: string) {
try {
return decodeURIComponent(v);
} catch {
return v;
}
}

View file

@ -454,6 +454,8 @@
"login.username": "Username",
"markdown.latex.error": "Latex render error",
"markdown.max_nodes.error": "This message is too long to by shown fully on a mobile device. Please view it on desktop or contact an admin to increase this limit.",
"markdown.parse_error": "An error occurred while parsing this text",
"markdown.render_error": "An error occurred while rendering this text",
"mentions.empty.paragraph": "You'll see messages here when someone mentions you or uses terms you're monitoring.",
"mentions.empty.title": "No Mentions yet",
"mobile.about.appVersion": "App Version: {version} (Build {number})",

View file

@ -1,4 +1,4 @@
Requires Mattermost Server v9.5.0+. Older servers may not be able to connect or have unexpected behavior.
Requires Mattermost Server v9.11.0+. Older servers may not be able to connect or have unexpected behavior.
-------

View file

@ -1,4 +1,4 @@
This version is compatible with Mattermost servers v9.5.0+.
This version is compatible with Mattermost servers v9.11.0+.
Please see [changelog](https://docs.mattermost.com/administration/mobile-changelog.html) for full release notes. If you're interested in helping beta test upcoming versions before they are released, please see our [documentation](https://github.com/mattermost/mattermost-mobile#testing).

View file

@ -1984,7 +1984,7 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 572;
CURRENT_PROJECT_VERSION = 582;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;
HEADER_SEARCH_PATHS = "$(inherited)";
@ -2026,7 +2026,7 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 572;
CURRENT_PROJECT_VERSION = 582;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;
HEADER_SEARCH_PATHS = "$(inherited)";
@ -2169,7 +2169,7 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 572;
CURRENT_PROJECT_VERSION = 582;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
GCC_C_LANGUAGE_STANDARD = gnu11;
@ -2219,7 +2219,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 572;
CURRENT_PROJECT_VERSION = 582;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
GCC_C_LANGUAGE_STANDARD = gnu11;

View file

@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.22.0</string>
<string>2.23.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -37,7 +37,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>572</string>
<string>582</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View file

@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2.22.0</string>
<string>2.23.0</string>
<key>CFBundleVersion</key>
<string>572</string>
<string>582</string>
<key>UIAppFonts</key>
<array>
<string>OpenSans-Bold.ttf</string>

View file

@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2.22.0</string>
<string>2.23.0</string>
<key>CFBundleVersion</key>
<string>572</string>
<string>582</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View file

@ -1162,7 +1162,7 @@ PODS:
- Yoga
- react-native-netinfo (11.3.2):
- React-Core
- react-native-network-client (1.7.2):
- react-native-network-client (1.7.3):
- Alamofire (~> 5.9.1)
- DoubleConversion
- glog
@ -2043,7 +2043,7 @@ SPEC CHECKSUMS:
react-native-emm: ecab44d78fb1cc7d7b7901914f48fb6309c46ff2
react-native-image-picker: c3afe5472ef870d98a4b28415fc0b928161ee5f7
react-native-netinfo: 076df4f9b07f6670acf4ce9a75aac8d34c2e2ccc
react-native-network-client: 5173c47230b5f497cdef469cba8e6e1b3df687eb
react-native-network-client: 82ef7c000c5fd3bfd1ad9e78cd920af33040f9f3
react-native-notifications: 4601a5a8db4ced6ae7cfc43b44d35fe437ac50c4
react-native-paste-input: 011a9916ef3acf809a7da122847c61ca0f93a60e
react-native-performance: ff93f8af3b2ee9519fd7879896aa9b8b8272691d

View file

@ -6,7 +6,7 @@ import React
@objc private var hasRegisteredLoad = false
deinit {
DispatchQueue.main.async {
DispatchQueue.main.sync {
guard let w = UIApplication.shared.delegate?.window, let window = w else { return }
window.removeObserver(self, forKeyPath: "frame")
}

296
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "mattermost-mobile",
"version": "2.22.0",
"version": "2.23.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mattermost-mobile",
"version": "2.21.0",
"version": "2.22.0",
"hasInstallScript": true,
"license": "Apache 2.0",
"dependencies": {
@ -17,12 +17,12 @@
"@formatjs/intl-numberformat": "8.10.3",
"@formatjs/intl-pluralrules": "5.2.14",
"@gorhom/bottom-sheet": "4.6.4",
"@mattermost/calls": "github:mattermost/calls-common#07607cf603f1e3f0c86ae248b2332e8da17f9cf8",
"@mattermost/calls": "github:mattermost/calls-common#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f",
"@mattermost/compass-icons": "0.1.45",
"@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard",
"@mattermost/keyboard-tracker": "file:./libraries/@mattermost/keyboard-tracker",
"@mattermost/react-native-emm": "1.5.0",
"@mattermost/react-native-network-client": "1.7.2",
"@mattermost/react-native-network-client": "1.7.3",
"@mattermost/react-native-paste-input": "0.8.0",
"@mattermost/react-native-turbo-log": "0.4.0",
"@mattermost/rnshare": "file:./libraries/@mattermost/rnshare",
@ -42,7 +42,7 @@
"@stream-io/flat-list-mvcp": "0.10.3",
"@voximplant/react-native-foreground-service": "3.0.2",
"base-64": "1.0.0",
"commonmark": "npm:@mattermost/commonmark@0.30.1-3",
"commonmark": "npm:@mattermost/commonmark@0.30.1-4",
"commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#81b5d27509652bae50b4b510ede777dd3bd923cf",
"deep-equal": "2.2.3",
"deepmerge": "4.3.1",
@ -4533,26 +4533,12 @@
"integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3"
"cross-spawn": "^7.0.6"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@expo/spawn-async/node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@expo/vector-icons": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-14.0.2.tgz",
@ -5851,11 +5837,11 @@
"node_modules/@mattermost/calls": {
"name": "@mattermost/calls-common",
"version": "0.27.2",
"resolved": "git+ssh://git@github.com/mattermost/calls-common.git#07607cf603f1e3f0c86ae248b2332e8da17f9cf8",
"integrity": "sha512-aZIYwtgRVj8tdWJUPtAr4TPNa6tNM3lnRkC74CkSsFCpwZ1miwov0B4TLYG4Srj1rTgUqUwG3gQz4o5JHJ2fuQ==",
"resolved": "git+ssh://git@github.com/mattermost/calls-common.git#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f",
"integrity": "sha512-qtMUUGHrl6VOGgjyQ+Ft4YddWo5RV8MDK8zSaYRU/1MiiY/zqq5kW6nvuzMB2S0xAvPwYlcFEBfmM4QZrReD6w==",
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"fflate": "^0.8.2"
"@msgpack/msgpack": "3.0.0-beta2",
"fflate": "0.8.2"
}
},
"node_modules/@mattermost/calls/node_modules/@msgpack/msgpack": {
@ -5908,9 +5894,9 @@
}
},
"node_modules/@mattermost/react-native-network-client": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-network-client/-/react-native-network-client-1.7.2.tgz",
"integrity": "sha512-UaFhUaQkmb6Hq2LOyutiwL0HeMVHmhBBpgojLi83zXXXB5OgNWDgqbDBo6Y986c5TwhCy1/LxENdHBc+mI4RZA==",
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-network-client/-/react-native-network-client-1.7.3.tgz",
"integrity": "sha512-gJSQ4Prf5jcbPlxVylhEtBGafSBYZSat+T21LIDHksGOfeY+jvPL3p8dS+cq+0D1AOHQ3tHNBkZ7RaY6IdUadw==",
"license": "MIT",
"dependencies": {
"validator": "13.12.0",
@ -12057,9 +12043,9 @@
},
"node_modules/commonmark": {
"name": "@mattermost/commonmark",
"version": "0.30.1-3",
"resolved": "https://registry.npmjs.org/@mattermost/commonmark/-/commonmark-0.30.1-3.tgz",
"integrity": "sha512-Kjsl/sZmb6R6PtpPVIifPfqBMrKs7z6Tukb1TJl/S0LfC5uNici3yol4waGxhGsJuvCF2kZqStwVQP7ieUbAgw==",
"version": "0.30.1-4",
"resolved": "https://registry.npmjs.org/@mattermost/commonmark/-/commonmark-0.30.1-4.tgz",
"integrity": "sha512-7O1xQfP3ILHf/+wjrxwF0rqowuvqQ7EY+SJOWsDaV+VXdj+lmft9yxkL0QPNYALinKFdPEj1gFtgR+pi4w8lzQ==",
"dependencies": {
"entities": "~3.0.1",
"mdurl": "~1.0.1",
@ -12360,6 +12346,20 @@
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
@ -12669,29 +12669,13 @@
"node": ">=6"
}
},
"node_modules/default-gateway/node_modules/cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
"integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
"license": "MIT",
"dependencies": {
"nice-try": "^1.0.4",
"path-key": "^2.0.1",
"semver": "^5.5.0",
"shebang-command": "^1.2.0",
"which": "^1.2.9"
},
"engines": {
"node": ">=4.8"
}
},
"node_modules/default-gateway/node_modules/execa": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
"integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^6.0.0",
"cross-spawn": "^7.0.6",
"get-stream": "^4.0.0",
"is-stream": "^1.1.0",
"npm-run-path": "^2.0.0",
@ -12745,48 +12729,6 @@
"node": ">=4"
}
},
"node_modules/default-gateway/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/default-gateway/node_modules/shebang-command": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
"integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/default-gateway/node_modules/shebang-regex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
"integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/default-gateway/node_modules/which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"which": "bin/which"
}
},
"node_modules/defaults": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
@ -13510,7 +13452,7 @@
"@ungap/structured-clone": "^1.2.0",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.2",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"doctrine": "^3.0.0",
"escape-string-regexp": "^4.0.0",
@ -14026,20 +13968,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"node_modules/eslint/node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/eslint/node_modules/eslint-scope": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@ -14340,7 +14268,7 @@
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"dependencies": {
"cross-spawn": "^7.0.3",
"cross-spawn": "^7.0.6",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
@ -14357,19 +14285,6 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/execa/node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/exit": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@ -15153,7 +15068,7 @@
"integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.0",
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
@ -15163,20 +15078,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/foreground-child/node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/foreground-child/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@ -20964,11 +20865,6 @@
"integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==",
"license": "MIT"
},
"node_modules/nice-try": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="
},
"node_modules/nocache": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/nocache/-/nocache-3.0.4.tgz",
@ -21461,27 +21357,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/os-locale/node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"peer": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/os-locale/node_modules/execa": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz",
"integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==",
"peer": true,
"dependencies": {
"cross-spawn": "^7.0.0",
"cross-spawn": "^7.0.6",
"get-stream": "^5.0.0",
"human-signals": "^1.1.1",
"is-stream": "^2.0.0",
@ -21741,21 +21623,7 @@
"license": "0BSD",
"dependencies": {
"ansi-escapes": "^4.3.2",
"cross-spawn": "^7.0.3"
}
},
"node_modules/password-prompt/node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
"cross-spawn": "^7.0.6"
}
},
"node_modules/patch-package": {
@ -21767,7 +21635,7 @@
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"cross-spawn": "^7.0.6",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^9.0.0",
"json-stable-stringify": "^1.0.2",
@ -21837,20 +21705,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"node_modules/patch-package/node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/patch-package/node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@ -27392,29 +27246,13 @@
"node": ">=8"
}
},
"node_modules/username/node_modules/cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
"integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
"peer": true,
"dependencies": {
"nice-try": "^1.0.4",
"path-key": "^2.0.1",
"semver": "^5.5.0",
"shebang-command": "^1.2.0",
"which": "^1.2.9"
},
"engines": {
"node": ">=4.8"
}
},
"node_modules/username/node_modules/execa": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
"integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
"peer": true,
"dependencies": {
"cross-spawn": "^6.0.0",
"cross-spawn": "^7.0.6",
"get-stream": "^4.0.0",
"is-stream": "^1.1.0",
"npm-run-path": "^2.0.0",
@ -27482,48 +27320,6 @@
"node": ">=4"
}
},
"node_modules/username/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"peer": true,
"bin": {
"semver": "bin/semver"
}
},
"node_modules/username/node_modules/shebang-command": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
"integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==",
"peer": true,
"dependencies": {
"shebang-regex": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/username/node_modules/shebang-regex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
"integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/username/node_modules/which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"peer": true,
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"which": "bin/which"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -27711,7 +27507,7 @@
"@webpack-cli/serve": "^1.7.0",
"colorette": "^2.0.14",
"commander": "^7.0.0",
"cross-spawn": "^7.0.3",
"cross-spawn": "^7.0.6",
"fastest-levenshtein": "^1.0.12",
"import-local": "^3.0.2",
"interpret": "^2.2.0",
@ -27761,20 +27557,6 @@
"node": ">= 10"
}
},
"node_modules/webpack-cli/node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/webpack-merge": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "mattermost-mobile",
"version": "2.22.0",
"version": "2.23.0",
"description": "Mattermost Mobile with React Native",
"repository": "git@github.com:mattermost/mattermost-mobile.git",
"author": "Mattermost, Inc.",
@ -18,12 +18,12 @@
"@formatjs/intl-numberformat": "8.10.3",
"@formatjs/intl-pluralrules": "5.2.14",
"@gorhom/bottom-sheet": "4.6.4",
"@mattermost/calls": "github:mattermost/calls-common#07607cf603f1e3f0c86ae248b2332e8da17f9cf8",
"@mattermost/calls": "github:mattermost/calls-common#030ff7c0a37ee9b0ccc5fae0b2ea9c0e1c08473f",
"@mattermost/compass-icons": "0.1.45",
"@mattermost/hardware-keyboard": "file:./libraries/@mattermost/hardware-keyboard",
"@mattermost/keyboard-tracker": "file:./libraries/@mattermost/keyboard-tracker",
"@mattermost/react-native-emm": "1.5.0",
"@mattermost/react-native-network-client": "1.7.2",
"@mattermost/react-native-network-client": "1.7.3",
"@mattermost/react-native-paste-input": "0.8.0",
"@mattermost/react-native-turbo-log": "0.4.0",
"@mattermost/rnshare": "file:./libraries/@mattermost/rnshare",
@ -43,7 +43,7 @@
"@stream-io/flat-list-mvcp": "0.10.3",
"@voximplant/react-native-foreground-service": "3.0.2",
"base-64": "1.0.0",
"commonmark": "npm:@mattermost/commonmark@0.30.1-3",
"commonmark": "npm:@mattermost/commonmark@0.30.1-4",
"commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#81b5d27509652bae50b4b510ede777dd3bd923cf",
"deep-equal": "2.2.3",
"deepmerge": "4.3.1",

View file

@ -22,7 +22,7 @@ type FileInfo = {
uri?: string;
user_id: string;
width: number;
postProps?: Record<string, any>;
postProps?: Record<string, unknown>;
};
type FilesState = {

View file

@ -71,7 +71,7 @@ type Post = {
message_source?: string;
type: PostType;
participants?: null | UserProfile[]|string[];
props: Record<string, any>;
props: Record<string, unknown> | undefined;
hashtags: string;
pending_post_id: string;
reply_count: number;

View file

@ -71,7 +71,7 @@ declare class PostModel extends Model {
userId: string;
/** props : Additional attributes for this props */
props: any;
props: Record<string, unknown> | null;
/** drafts : Every draft associated with this Post */
drafts: Query<DraftModel>;

View file

@ -76,7 +76,7 @@ export type GalleryItemType = {
authorId?: string;
size?: number;
postId?: string;
postProps?: Record<string, any> & {captions?: Caption[]};
postProps?: Record<string, unknown> & {captions?: Caption[]};
};
export type GalleryAction = 'none' | 'downloading' | 'copying' | 'sharing' | 'opening' | 'external';