From c3dc4c699e0f9333effe007532c5a66ad8e85903 Mon Sep 17 00:00:00 2001 From: "unified-ci-app[bot]" <121569378+unified-ci-app[bot]@users.noreply.github.com> Date: Fri, 12 Jul 2024 08:38:50 -0400 Subject: [PATCH 01/67] Bump app build number to 535 (#8082) Co-authored-by: runner --- android/app/build.gradle | 2 +- ios/Mattermost.xcodeproj/project.pbxproj | 8 ++++---- ios/Mattermost/Info.plist | 2 +- ios/MattermostShare/Info.plist | 2 +- ios/NotificationService/Info.plist | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index ea4542d7fd1..a3c7e755932 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,7 +111,7 @@ android { applicationId "com.mattermost.rnbeta" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 532 + versionCode 535 versionName "2.18.0" testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' diff --git a/ios/Mattermost.xcodeproj/project.pbxproj b/ios/Mattermost.xcodeproj/project.pbxproj index 4d64845c56a..77a2f8a1832 100644 --- a/ios/Mattermost.xcodeproj/project.pbxproj +++ b/ios/Mattermost.xcodeproj/project.pbxproj @@ -1980,7 +1980,7 @@ CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 532; + CURRENT_PROJECT_VERSION = 535; DEVELOPMENT_TEAM = UQ8HT4Q2XM; ENABLE_BITCODE = NO; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2022,7 +2022,7 @@ CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 532; + CURRENT_PROJECT_VERSION = 535; DEVELOPMENT_TEAM = UQ8HT4Q2XM; ENABLE_BITCODE = NO; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2165,7 +2165,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 532; + CURRENT_PROJECT_VERSION = 535; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = UQ8HT4Q2XM; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2215,7 +2215,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 532; + CURRENT_PROJECT_VERSION = 535; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = UQ8HT4Q2XM; GCC_C_LANGUAGE_STANDARD = gnu11; diff --git a/ios/Mattermost/Info.plist b/ios/Mattermost/Info.plist index f5ceb9f032d..280ca096903 100644 --- a/ios/Mattermost/Info.plist +++ b/ios/Mattermost/Info.plist @@ -37,7 +37,7 @@ CFBundleVersion - 532 + 535 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/ios/MattermostShare/Info.plist b/ios/MattermostShare/Info.plist index 288f25fdf93..28d154810f2 100644 --- a/ios/MattermostShare/Info.plist +++ b/ios/MattermostShare/Info.plist @@ -21,7 +21,7 @@ CFBundleShortVersionString 2.18.0 CFBundleVersion - 532 + 535 UIAppFonts OpenSans-Bold.ttf diff --git a/ios/NotificationService/Info.plist b/ios/NotificationService/Info.plist index 5bb26020114..747fa32d03c 100644 --- a/ios/NotificationService/Info.plist +++ b/ios/NotificationService/Info.plist @@ -21,7 +21,7 @@ CFBundleShortVersionString 2.18.0 CFBundleVersion - 532 + 535 NSExtension NSExtensionPointIdentifier From 8fb0b32d89c061499471b73e4639d966163d04b5 Mon Sep 17 00:00:00 2001 From: Saturnino Abril Date: Fri, 12 Jul 2024 23:37:31 +0800 Subject: [PATCH 02/67] fix: saving e2e test report in Zephyr (#8080) --- detox/save_report.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/detox/save_report.js b/detox/save_report.js index 8845d5b22a4..0b59d8829be 100644 --- a/detox/save_report.js +++ b/detox/save_report.js @@ -139,9 +139,12 @@ const saveReport = async () => { // Create or use an existing test cycle let testCycle = {}; - if (ZEPHYR_ENABLE === true) { + if (ZEPHYR_ENABLE === 'true') { const {start, end} = summary.stats; testCycle = ZEPHYR_CYCLE_KEY ? {key: ZEPHYR_CYCLE_KEY} : await createTestCycle(start, end); + if (!testCycle?.key) { + console.log('Failed to create test cycle'); + } } // Send test report to "QA: Mobile Test Automation Report" channel via webhook From 3c6d5aa4ae3e0dfd616a7da09d5a0c06a287a604 Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Mon, 15 Jul 2024 04:52:44 -0400 Subject: [PATCH 03/67] Add tests for actions/local/post (#8083) --- app/actions/local/post.test.ts | 338 +++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 app/actions/local/post.test.ts diff --git a/app/actions/local/post.test.ts b/app/actions/local/post.test.ts new file mode 100644 index 00000000000..79b221b6ba2 --- /dev/null +++ b/app/actions/local/post.test.ts @@ -0,0 +1,338 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ActionType, Post} from '@app/constants'; +import {COMBINED_USER_ACTIVITY} from '@app/utils/post_list'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import TestHelper from '@test/test_helper'; + +import { + sendAddToChannelEphemeralPost, + sendEphemeralPost, + removePost, + markPostAsDeleted, + storePostsForChannel, + getPosts, + addPostAcknowledgement, + removePostAcknowledgement, + deletePosts, + getUsersCountFromMentions, +} from './post'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type UserModel from '@typings/database/models/servers/user'; + +const serverUrl = 'baseHandler.test.com'; +let operator: ServerDataOperator; + +let mockGenerateId: jest.Mock; +jest.mock('@utils/general', () => { + const original = jest.requireActual('@utils/general'); + mockGenerateId = jest.fn(() => 'testpostid'); + return { + ...original, + generateId: mockGenerateId, + }; +}); + +const channelId = 'channelid1'; +const user: UserProfile = { + id: 'userid', + username: 'username', + roles: '', +} as UserProfile; + +beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; +}); + +afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); +}); + +describe('sendAddToChannelEphemeralPost', () => { + it('handle not found database', async () => { + const {posts, error} = await sendAddToChannelEphemeralPost('foo', {} as UserModel, ['username2'], ['added username2'], channelId, ''); + expect(posts).toBeUndefined(); + expect(error).toBeTruthy(); + }); + + it('base case', async () => { + const users = await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + + const {posts, error} = await sendAddToChannelEphemeralPost(serverUrl, users[0], ['username2'], ['added username2'], channelId, ''); + expect(error).toBeUndefined(); + expect(posts).toBeDefined(); + expect(posts?.length).toBe(1); + expect(posts![0].message).toBe('added username2'); + }); +}); + +describe('sendEphemeralPost', () => { + it('handle not found database', async () => { + const {post, error} = await sendEphemeralPost('foo', 'message', channelId, '', user.id); + expect(post).toBeUndefined(); + expect(error).toBeTruthy(); + }); + + it('handle no channel', async () => { + const {post, error} = await sendEphemeralPost(serverUrl, 'newmessage', '', '', user.id); + expect(post).toBeUndefined(); + expect(error).toBeTruthy(); + }); + + it('handle no user', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'useridcurrent'}], prepareRecordsOnly: false}); + + const {post, error} = await sendEphemeralPost(serverUrl, 'newmessage', channelId, ''); + expect(error).toBeUndefined(); + expect(post).toBeDefined(); + expect(post?.user_id).toBe('useridcurrent'); + }); + + it('base case', async () => { + const {post, error} = await sendEphemeralPost(serverUrl, 'newmessage', channelId, '', user.id); + expect(error).toBeUndefined(); + expect(post).toBeDefined(); + expect(post?.message).toBe('newmessage'); + }); +}); + +describe('removePost', () => { + const post = {...TestHelper.fakePost(channelId), id: 'postid'}; + + it('handle not found database', async () => { + const {post: rPost, error} = await removePost('foo', post); + expect(rPost).toBeUndefined(); + expect(error).toBeTruthy(); + }); + + it('base case', async () => { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const {post: rPost, error} = await removePost(serverUrl, post); + expect(error).toBeUndefined(); + expect(rPost).toBeDefined(); + }); + + it('base case - system message', async () => { + const systemPost = {...TestHelper.fakePost(channelId), id: `${COMBINED_USER_ACTIVITY}id1_id2`, type: Post.POST_TYPES.COMBINED_USER_ACTIVITY as PostType, props: {system_post_ids: ['id1']}}; + + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id, 'id1'], + posts: [systemPost, {...TestHelper.fakePost(channelId), id: 'id1'}], + prepareRecordsOnly: false, + }); + + const {post: rPost, error} = await removePost(serverUrl, systemPost); + expect(error).toBeUndefined(); + expect(rPost).toBeDefined(); + }); +}); + +describe('markPostAsDeleted', () => { + const post = TestHelper.fakePost(channelId); + + it('handle not found database', async () => { + const {model, error} = await markPostAsDeleted('foo', post); + expect(model).toBeUndefined(); + expect(error).toBeTruthy(); + }); + + it('handle no post', async () => { + const {model, error} = await markPostAsDeleted(serverUrl, post, false); + expect(model).toBeUndefined(); + expect(error).toBeTruthy(); + }); + + it('base case', async () => { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const {model, error} = await markPostAsDeleted(serverUrl, post, false); + expect(error).toBeUndefined(); + expect(model).toBeDefined(); + expect(model?.deleteAt).toBeGreaterThan(0); + }); +}); + +describe('storePostsForChannel', () => { + const post = TestHelper.fakePost(channelId); + post.user_id = user.id; + const teamId = 'tId1'; + const channel: Channel = { + id: channelId, + team_id: teamId, + total_msg_count: 0, + } as Channel; + const channelMember: ChannelMembership = { + id: 'id', + channel_id: channelId, + msg_count: 0, + } as ChannelMembership; + + it('handle not found database', async () => { + const {models, error} = await storePostsForChannel('foo', channelId, [post], [post.id], '', ActionType.POSTS.RECEIVED_IN_CHANNEL, [user], false); + expect(models).toBeUndefined(); + expect(error).toBeTruthy(); + }); + + it('base case', async () => { + await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false}); + await operator.handleConfigs({ + configs: [ + {id: 'CollapsedThreads', value: 'default_on'}, + {id: 'FeatureFlagCollapsedThreads', value: 'true'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + + const {models, error} = await storePostsForChannel(serverUrl, channelId, [post], [post.id], '', ActionType.POSTS.RECEIVED_IN_CHANNEL, [user], false); + expect(error).toBeUndefined(); + expect(models).toBeDefined(); + expect(models?.length).toBe(5); // Post, PostsInChannel, User, MyChannel, Thread + }); +}); + +describe('getPosts', () => { + const post = TestHelper.fakePost(channelId); + + it('handle not found database', async () => { + const posts = await getPosts('foo', [post.id]); + expect(posts).toBeDefined(); + expect(posts?.length).toBe(0); + }); + + it('base case', async () => { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const posts = await getPosts(serverUrl, [post.id]); + expect(posts).toBeDefined(); + expect(posts.length).toBe(1); + }); +}); + +describe('addPostAcknowledgement', () => { + const post = TestHelper.fakePost(channelId); + + it('handle not found database', async () => { + const {model, error} = await addPostAcknowledgement('foo', post.id, user.id, 123, false); + expect(model).toBeUndefined(); + expect(error).toBeTruthy(); + }); + + it('handle no post', async () => { + const {model, error} = await addPostAcknowledgement(serverUrl, post.id, user.id, 123, false); + expect(error).toBeDefined(); + expect(model).toBeUndefined(); + }); + + it('handle already acked', async () => { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [{...post, metadata: {acknowledgements: [{user_id: user.id, post_id: post.id, acknowledged_at: 1}]}}], + prepareRecordsOnly: false, + }); + + const {model, error} = await addPostAcknowledgement(serverUrl, post.id, user.id, 123, false); + expect(error).toBeDefined(); + expect(error).toBe(false); + expect(model).toBeUndefined(); + }); + + it('base case', async () => { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const {model, error} = await addPostAcknowledgement(serverUrl, post.id, user.id, 123, false); + expect(error).toBeUndefined(); + expect(model).toBeDefined(); + }); +}); + +describe('removePostAcknowledgement', () => { + const post = TestHelper.fakePost(channelId); + + it('handle not found database', async () => { + const {model, error} = await removePostAcknowledgement('foo', post.id, user.id, false); + expect(model).toBeUndefined(); + expect(error).toBeTruthy(); + }); + + it('handle no post', async () => { + const {model, error} = await removePostAcknowledgement(serverUrl, post.id, user.id, false); + expect(error).toBeDefined(); + expect(model).toBeUndefined(); + }); + + it('base case', async () => { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [{...post, metadata: {acknowledgements: [{user_id: user.id, post_id: post.id, acknowledged_at: 1}]}}], + prepareRecordsOnly: false, + }); + + const {model, error} = await removePostAcknowledgement(serverUrl, post.id, user.id, false); + expect(error).toBeUndefined(); + expect(model).toBeDefined(); + }); +}); + +describe('deletePosts', () => { + const post = TestHelper.fakePost(channelId); + + it('handle not found database', async () => { + const {error} = await deletePosts('foo', [post.id]); + expect(error).toBeTruthy(); + }); + + it('base case', async () => { + await operator.handlePosts({ + actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, + order: [post.id], + posts: [post], + prepareRecordsOnly: false, + }); + + const {error} = await deletePosts(serverUrl, [post.id, 'id2']); + expect(error).toBeDefined(); + }); +}); + +describe('getUsersCountFromMentions', () => { + it('handle not found database', async () => { + const num = await getUsersCountFromMentions('foo', []); + expect(num).toBe(0); + }); + + it('base case', async () => { + await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + + const num = await getUsersCountFromMentions(serverUrl, [user.username]); + expect(num).toBe(1); + }); +}); From 58b50b1a5b65603680b89128cd23b352c323ba68 Mon Sep 17 00:00:00 2001 From: Saturnino Abril Date: Mon, 15 Jul 2024 16:53:32 +0800 Subject: [PATCH 04/67] test: app/actions/app/global.ts (#8087) --- app/actions/app/global.test.ts | 182 +++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 app/actions/app/global.test.ts diff --git a/app/actions/app/global.test.ts b/app/actions/app/global.test.ts new file mode 100644 index 00000000000..8a42c8e7e6e --- /dev/null +++ b/app/actions/app/global.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Tutorial} from '@constants'; +import DatabaseManager from '@database/manager'; +import { + getDeviceToken, + getDontAskForReview, + getFirstLaunch, + getLastAskedForReview, + getOnboardingViewed, + getLastViewedChannelIdAndServer, + getLastViewedThreadIdAndServer, + getPushDisabledInServerAcknowledged, + queryGlobalValue, +} from '@queries/app/global'; + +import { + storeGlobal, + storeDeviceToken, + storeOnboardingViewedValue, + storeMultiServerTutorial, + storeProfileLongPressTutorial, + storeSkinEmojiSelectorTutorial, + storeDontAskForReview, + storeLastAskForReview, + storeFirstLaunch, + storeLastViewedChannelIdAndServer, + storeLastViewedThreadIdAndServer, + removeLastViewedChannelIdAndServer, + removeLastViewedThreadIdAndServer, + storePushDisabledInServerAcknowledged, + removePushDisabledInServerAcknowledged, +} from './global'; + +const serverUrl = 'server.test.com'; + +jest.mock('react-native-keychain', () => { + const original = jest.requireActual('react-native-keychain'); + return { + ...original, + getAllInternetPasswordServers: jest.fn(() => Promise.resolve([serverUrl])), + }; +}); + +describe('/app/actions/app/global', () => { + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + test('storeDeviceToken', async () => { + let storedValue = await getDeviceToken(); + expect(storedValue).toBe(''); + + const inputValue = 'new-token'; + await storeDeviceToken(inputValue); + + storedValue = await getDeviceToken(); + expect(storedValue).toBe(inputValue); + }); + + test('storeOnboardingViewedValue', async () => { + let storedValue = await getOnboardingViewed(); + expect(storedValue).toBe(false); + + await storeOnboardingViewedValue(); + storedValue = await getOnboardingViewed(); + expect(storedValue).toBe(true); + + await storeOnboardingViewedValue(false); + storedValue = await getOnboardingViewed(); + expect(storedValue).toBe(false); + }); + + test('storeMultiServerTutorial', async () => { + let records = await queryGlobalValue(Tutorial.MULTI_SERVER)?.fetch(); + expect(records?.[0]?.value).toBeUndefined(); + + await storeMultiServerTutorial(); + records = await queryGlobalValue(Tutorial.MULTI_SERVER)?.fetch(); + expect(records?.[0]?.value).toBe(true); + }); + + test('storeProfileLongPressTutorial', async () => { + let records = await queryGlobalValue(Tutorial.PROFILE_LONG_PRESS)?.fetch(); + expect(records?.[0]?.value).toBeUndefined(); + + await storeProfileLongPressTutorial(); + records = await queryGlobalValue(Tutorial.PROFILE_LONG_PRESS)?.fetch(); + expect(records?.[0]?.value).toBe(true); + }); + + test('storeSkinEmojiSelectorTutorial', async () => { + let records = await queryGlobalValue(Tutorial.EMOJI_SKIN_SELECTOR)?.fetch(); + expect(records?.[0]?.value).toBeUndefined(); + + await storeSkinEmojiSelectorTutorial(); + records = await queryGlobalValue(Tutorial.EMOJI_SKIN_SELECTOR)?.fetch(); + expect(records?.[0]?.value).toBe(true); + }); + + test('storeDontAskForReview', async () => { + let storedValue = await getDontAskForReview(); + expect(storedValue).toBe(false); + + await storeDontAskForReview(); + storedValue = await getDontAskForReview(); + expect(storedValue).toBe(true); + }); + + test('storeLastAskForReview', async () => { + let storedValue = await getLastAskedForReview(); + expect(storedValue).toBe(0); + + await storeLastAskForReview(); + storedValue = await getLastAskedForReview(); + expect(storedValue).toBeCloseTo(Date.now(), -5); + }); + + test('storeFirstLaunch', async () => { + let storedValue = await getFirstLaunch(); + expect(storedValue).toBe(0); + + await storeFirstLaunch(); + storedValue = await getFirstLaunch(); + expect(storedValue).toBeCloseTo(Date.now(), -5); + }); + + test('LastViewedChannelIdAndServer', async () => { + let storedValue = await getLastViewedChannelIdAndServer(); + expect(storedValue).toBeUndefined(); + + await storeLastViewedChannelIdAndServer('channel-id-1'); + storedValue = await getLastViewedChannelIdAndServer(); + expect(storedValue).toMatchObject({channel_id: 'channel-id-1', server_url: serverUrl}); + + await removeLastViewedChannelIdAndServer(); + storedValue = await getLastViewedChannelIdAndServer(); + expect(storedValue).toBeUndefined(); + }); + + test('LastViewedThreadIdAndServer', async () => { + let storedValue = await getLastViewedThreadIdAndServer(); + expect(storedValue).toBeUndefined(); + + await storeLastViewedThreadIdAndServer('thread-id-1'); + storedValue = await getLastViewedThreadIdAndServer(); + expect(storedValue).toMatchObject({thread_id: 'thread-id-1', server_url: serverUrl}); + + await removeLastViewedThreadIdAndServer(); + storedValue = await getLastViewedThreadIdAndServer(); + expect(storedValue).toBeUndefined(); + }); + + test('PushDisabledInServerAcknowledged', async () => { + let storedValue = await getPushDisabledInServerAcknowledged(serverUrl); + expect(storedValue).toBe(false); + + await storePushDisabledInServerAcknowledged(serverUrl); + storedValue = await getPushDisabledInServerAcknowledged(serverUrl); + expect(storedValue).toBe(true); + + await removePushDisabledInServerAcknowledged(serverUrl); + storedValue = await getPushDisabledInServerAcknowledged(serverUrl); + expect(storedValue).toBe(false); + }); + + test('storeGlobal catch error', async () => { + delete DatabaseManager.appDatabase; + + const response = await storeGlobal('key', ''); + + expect(response).toMatchObject({error: expect.any(Error)}); + + // @ts-expect-error testing error message + expect(response.error.message).toBe('App database not found'); + }); +}); From e4d8d1177eb183a1aab08c6d40a1ad1193540c61 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Mon, 15 Jul 2024 16:55:23 +0800 Subject: [PATCH 05/67] add file utils unit tests (#8079) --- app/utils/file/index.test.ts | 277 +++++++++++++++++++++++++++++++++++ app/utils/file/index.ts | 6 +- 2 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 app/utils/file/index.test.ts diff --git a/app/utils/file/index.test.ts b/app/utils/file/index.test.ts new file mode 100644 index 00000000000..aa47390272f --- /dev/null +++ b/app/utils/file/index.test.ts @@ -0,0 +1,277 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getInfoAsync, deleteAsync} from 'expo-file-system'; +import {createIntl} from 'react-intl'; +import {Platform} from 'react-native'; +import Permissions from 'react-native-permissions'; + +import {getTranslations} from '@i18n'; +import {logError} from '@utils/log'; +import {urlSafeBase64Encode} from '@utils/security'; + +import {deleteFileCache, deleteFileCacheByDir, deleteV1Data, extractFileInfo, fileExists, fileMaxWarning, fileSizeWarning, filterFileExtensions, getAllFilesInCachesDirectory, getAllowedServerMaxFileSize, getExtensionFromContentDisposition, getExtensionFromMime, getFileType, getFormattedFileSize, getLocalFilePathFromFile, hasWriteStoragePermission, isDocument, isGif, isImage, isVideo, lookupMimeType, uploadDisabledWarning} from '.'; + +jest.mock('expo-file-system'); +jest.mock('react-native', () => { + const RN = jest.requireActual('react-native'); + return { + Platform: { + ...RN.Platform, + OS: 'ios', + }, + Alert: {alert: jest.fn()}, + Linking: {openSettings: jest.fn()}, + NativeModules: { + ...RN.NativeModules, + RNUtils: { + getConstants: () => ({ + appGroupIdentifier: 'group.mattermost.rnbeta', + appGroupSharedDirectory: { + sharedDirectory: '', + databasePath: '', + }, + }), + addListener: jest.fn(), + removeListeners: jest.fn(), + isRunningInSplitView: jest.fn().mockReturnValue({isSplit: false, isTablet: false}), + + getDeliveredNotifications: jest.fn().mockResolvedValue([]), + removeChannelNotifications: jest.fn().mockImplementation(), + removeThreadNotifications: jest.fn().mockImplementation(), + removeServerNotifications: jest.fn().mockImplementation(), + }, + }, + }; +}); +jest.mock('react-native-permissions', () => ({ + check: jest.fn(), + request: jest.fn(), + RESULTS: { + GRANTED: 'granted', + DENIED: 'denied', + BLOCKED: 'blocked', + }, + PERMISSIONS: {ANDROID: {WRITE_EXTERNAL_STORAGE: 'WRITE_EXTERNAL_STORAGE'}}, +})); +jest.mock('@utils/log', () => ({logError: jest.fn()})); +jest.mock('@utils/mattermost_managed', () => ({ + getIOSAppGroupDetails: () => ({appGroupSharedDirectory: 'appGroupSharedDirectory'}), + deleteEntitiesFile: jest.fn(), +})); +jest.mock('@utils/security', () => ({urlSafeBase64Encode: (url: string) => btoa(url)})); + +describe('Image utils', () => { + const intl = createIntl({locale: 'en', messages: getTranslations('en')}); + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('filterFileExtensions', () => { + it('should return correct filter for each type', () => { + expect(filterFileExtensions('ALL')).toBe(''); + expect(filterFileExtensions('AUDIO')).toEqual(['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac', 'ogg'].map((e) => `ext:${e}`).join(' ')); + expect(filterFileExtensions('CODE')).toEqual([ + 'as', 'applescript', 'osascript', 'scpt', 'bash', 'sh', 'zsh', 'clj', 'boot', 'cl2', 'cljc', 'cljs', + 'cljs.hl', 'cljscm', 'cljx', 'hic', 'coffee', '_coffee', 'cake', 'cjsx', 'cson', 'iced', 'cpp', 'c', 'cc', 'h', 'c++', + 'h++', 'hpp', 'cs', 'csharp', 'css', 'd', 'di', 'dart', 'delphi', 'dpr', 'dfm', 'pas', 'pascal', 'freepascal', 'lazarus', + 'lpr', 'lfm', 'diff', 'django', 'jinja', 'dockerfile', 'docker', 'erl', 'f90', 'f95', 'fsharp', 'fs', 'gcode', 'nc', 'go', + 'groovy', 'handlebars', 'hbs', 'html.hbs', 'html.handlebars', 'hs', 'hx', 'java', 'jsp', 'js', 'jsx', 'json', 'jl', 'kt', + 'ktm', 'kts', 'less', 'lisp', 'lua', 'mk', 'mak', 'md', 'mkdown', 'mkd', 'matlab', 'm', 'mm', 'objc', 'obj-c', 'ml', 'perl', + 'pl', 'php', 'php3', 'php4', 'php5', 'php6', 'ps', 'ps1', 'pp', 'py', 'gyp', 'r', 'ruby', 'rb', 'gemspec', 'podspec', 'thor', + 'irb', 'rs', 'scala', 'scm', 'sld', 'scss', 'st', 'sql', 'swift', 'ts', 'tex', 'vbnet', 'vb', 'bas', 'vbs', 'v', 'veo', 'xml', + 'html', 'xhtml', 'rss', 'atom', 'xsl', 'plist', 'yaml'].map((e) => `ext:${e}`).join(' ')); + expect(filterFileExtensions('DOCUMENTS')).toEqual(['doc', 'docx', 'odt', 'pdf', 'txt', 'rtf'].map((e) => `ext:${e}`).join(' ')); + expect(filterFileExtensions('IMAGES')).toEqual(['jpg', 'gif', 'bmp', 'png', 'jpeg', 'tiff', 'tif', 'svg', 'psd', 'xcf'].map((e) => `ext:${e}`).join(' ')); + expect(filterFileExtensions('PRESENTATIONS')).toEqual(['ppt', 'pptx', 'odp'].map((e) => `ext:${e}`).join(' ')); + expect(filterFileExtensions('SPREADSHEETS')).toEqual(['xls', 'xlsx', 'csv', 'ods'].map((e) => `ext:${e}`).join(' ')); + expect(filterFileExtensions('VIDEOS')).toEqual(['mp4', 'avi', 'webm', 'mkv', 'wmv', 'mpg', 'mov', 'flv', 'ogm', 'mpeg'].map((e) => `ext:${e}`).join(' ')); + expect(filterFileExtensions()).toBe(''); + }); + }); + + describe('deleteV1Data', () => { + it('should delete V1 data', async () => { + await deleteV1Data(); + expect(deleteAsync).toHaveBeenCalled(); + Platform.OS = 'android'; + await deleteV1Data(); + expect(deleteAsync).toHaveBeenCalled(); + Platform.OS = 'ios'; + }); + }); + + describe('deleteFileCache', () => { + it('should delete file cache', async () => { + await deleteFileCache('http://server.com'); + expect(deleteAsync).toHaveBeenCalled(); + }); + }); + + describe('deleteFileCacheByDir', () => { + it('should delete file cache by dir', async () => { + await deleteFileCacheByDir('someDir'); + expect(deleteAsync).toHaveBeenCalled(); + }); + }); + + describe('lookupMimeType', () => { + it('should return correct mime type', () => { + expect(lookupMimeType('file.txt')).toBe('text/plain'); + expect(lookupMimeType('file.jpg')).toBe('image/jpeg'); + expect(lookupMimeType('file.era')).toBe('application/octet-stream'); + }); + }); + + describe('getExtensionFromMime', () => { + it('should return correct extension from mime type', () => { + expect(getExtensionFromMime('application/json')).toBe('json'); + expect(getExtensionFromMime('image/png')).toBe('png'); + expect(getExtensionFromMime('video/mp4')).toBe('mp4'); + }); + }); + + describe('getExtensionFromContentDisposition', () => { + it('should return correct extension from content disposition', () => { + expect(getExtensionFromContentDisposition('inline;filename="file.txt";')).toBe('txt'); + expect(getExtensionFromContentDisposition('inline;filename="file.jpg";')).toBe('jpg'); + expect(getExtensionFromContentDisposition('inline;')).toBe(null); + }); + }); + + describe('getAllowedServerMaxFileSize', () => { + it('should return correct max file size', () => { + expect(getAllowedServerMaxFileSize({MaxFileSize: '10485760'} as ClientConfig)).toBe(10485760); + expect(getAllowedServerMaxFileSize({MaxFileSize: '10485a60'} as ClientConfig)).toBe(10485); + expect(getAllowedServerMaxFileSize({MaxFileSize: ''} as ClientConfig)).toBe(50 * 1024 * 1024); + }); + }); + + describe('isGif', () => { + it('should correctly identify gif files', () => { + expect(isGif({name: 'file.gif', mimeType: 'image/gif'} as unknown as FileInfo)).toBe(true); + expect(isGif({name: 'file.png', mimeType: 'image/gif'} as unknown as FileInfo)).toBe(true); + expect(isGif({name: 'file.png', mimeType: 'image/png'} as unknown as FileInfo)).toBe(false); + expect(isGif()).toBe(false); + }); + }); + + describe('isImage', () => { + it('should correctly identify image files', () => { + expect(isImage({name: 'file.png', mimeType: 'image/png'} as unknown as FileInfo)).toBe(true); + expect(isImage({name: 'file.jpg', mimeType: 'image/jpeg'} as unknown as FileInfo)).toBe(true); + expect(isImage({name: 'file.png', mimeType: 'text/plain'} as unknown as FileInfo)).toBe(false); + expect(isImage({name: 'file.png', extension: '.png'} as unknown as FileInfo)).toBe(true); + }); + }); + + describe('isDocument', () => { + it('should correctly identify document files', () => { + expect(isDocument({name: 'file.pdf', mimeType: 'application/pdf'} as unknown as FileInfo)).toBe(true); + expect(isDocument({name: 'file.doc', mimeType: 'application/vnd.apple.pages'} as unknown as FileInfo)).toBe(true); + expect(isDocument({name: 'file.mp4', mimeType: 'video/mp4'} as unknown as FileInfo)).toBe(false); + expect(isDocument({name: 'file.doc', mimeType: 'application/msword'} as unknown as FileInfo)).toBe(true); + }); + }); + + describe('isVideo', () => { + it('should correctly identify video files', () => { + expect(isVideo({name: 'file.mp4', mimeType: 'video/mp4'} as unknown as FileInfo)).toBe(true); + expect(isVideo({name: 'file.mov', mimeType: 'video/quicktime'} as unknown as FileInfo)).toBe(true); + expect(isVideo({name: 'file.mkv', mimeType: 'video/x-matroska'} as unknown as FileInfo)).toBe(false); + }); + }); + + describe('getFormattedFileSize', () => { + it('should return correct formatted file size', () => { + expect(getFormattedFileSize(102)).toBe('102 B'); + expect(getFormattedFileSize(1024)).toBe('1024 B'); + expect(getFormattedFileSize(1025)).toBe('1 KB'); + expect(getFormattedFileSize(10 * 1024 * 1024)).toBe('10 MB'); + expect(getFormattedFileSize(10 * 1024 * 1024 * 1024)).toBe('10 GB'); + expect(getFormattedFileSize(10 * 1024 * 1024 * 1024 * 1024)).toBe('10 TB'); + }); + }); + + describe('getFileType', () => { + it('should return correct file type', () => { + expect(getFileType({extension: 'png'} as unknown as FileInfo)).toBe('image'); + expect(getFileType({extension: 'mp4'} as unknown as FileInfo)).toBe('video'); + expect(getFileType({extension: 'pdf'} as unknown as FileInfo)).toBe('pdf'); + expect(getFileType({extension: 'arr'} as unknown as FileInfo)).toBe('other'); + expect(getFileType({} as unknown as FileInfo)).toBe('other'); + }); + }); + + describe('getLocalFilePathFromFile', () => { + it('should return correct local file path from file', () => { + expect(getLocalFilePathFromFile('http://server.com', {id: 'someid', name: 'image.png', extension: 'png'} as unknown as FileInfo)).toBe(`file://test-cache-directory/${urlSafeBase64Encode('http://server.com')}/image-someid.png`); + expect(getLocalFilePathFromFile('http://server.com', {id: 'someid', name: 'image.png'} as unknown as FileInfo)).toBe(`file://test-cache-directory/${urlSafeBase64Encode('http://server.com')}/image-someid.png`); + expect(getLocalFilePathFromFile('http://server.com', {id: 'someid', extension: 'png'} as unknown as FileInfo)).toBe(`file://test-cache-directory/${urlSafeBase64Encode('http://server.com')}/someid.png`); + expect(getLocalFilePathFromFile('http://server.com', {id: 'someid'} as unknown as FileInfo)).toBe(`file://test-cache-directory/${urlSafeBase64Encode('http://server.com')}/someid`); + expect(() => getLocalFilePathFromFile('http://server.com', {extension: 'png'} as unknown as FileInfo)).toThrow('File path could not be set'); + }); + }); + + describe('extractFileInfo', () => { + it('should extract file info correctly', async () => { + const files = [{uri: 'file://somefile', fileSize: 12345, fileName: 'file.png', type: 'image/png'}]; + let result = await extractFileInfo([]); + expect(result).toEqual([]); + result = await extractFileInfo([{fileName: 'file.png'}]); + expect(result).toEqual([]); + expect(logError).toHaveBeenCalled(); + result = await extractFileInfo(files); + expect(result).toEqual(expect.any(Array)); + result = await extractFileInfo([{uri: 'file://somefile', size: 12345, fileName: 'file.png', type: 'image/png'}]); + expect(result).toEqual(expect.any(Array)); + }); + }); + + describe('fileSizeWarning', () => { + it('should return correct file size warning', () => { + const msg = fileSizeWarning(intl, 10485760); + expect(msg).toBe('Files must be less than 10 MB'); + }); + }); + + describe('fileMaxWarning', () => { + it('should return correct file max warning', () => { + const msg = fileMaxWarning(intl, 10); + expect(msg).toBe('Uploads limited to 10 files maximum.'); + }); + }); + + describe('uploadDisabledWarning', () => { + it('should return correct upload disabled warning', () => { + const msg = uploadDisabledWarning(intl); + expect(msg).toBe('File uploads from mobile are disabled.'); + }); + }); + + describe('fileExists', () => { + it('should check if file exists', async () => { + // @ts-expect-error type def + getInfoAsync.mockResolvedValue({exists: true}); + const exists = await fileExists('somePath'); + expect(exists).toBe(true); + }); + }); + + describe('hasWriteStoragePermission', () => { + it('should check write storage permission', async () => { + // @ts-expect-error type def + Permissions.check.mockResolvedValue(Permissions.RESULTS.GRANTED); + const result = await hasWriteStoragePermission(intl); + expect(result).toBe(true); + }); + }); + + describe('getAllFilesInCachesDirectory', () => { + it('should get all files in caches directory', async () => { + const result = await getAllFilesInCachesDirectory('http://server.com'); + expect(result.files).toEqual(expect.any(Array)); + }); + }); +}); + diff --git a/app/utils/file/index.ts b/app/utils/file/index.ts index b75113583df..b780d95373d 100644 --- a/app/utils/file/index.ts +++ b/app/utils/file/index.ts @@ -380,11 +380,11 @@ export function getLocalFilePathFromFile(serverUrl: string, file: FileInfo | Fil } } - return `${cacheDirectory}/${server}/${filename}-${fileIdPath}.${extension}`; + return `${cacheDirectory}${server}/${filename}-${fileIdPath}.${extension}`; } else if (file?.id && hasValidExtension) { - return `${cacheDirectory}/${server}/${fileIdPath}.${file.extension}`; + return `${cacheDirectory}${server}/${fileIdPath}.${file.extension}`; } else if (file?.id) { - return `${cacheDirectory}/${server}/${fileIdPath}`; + return `${cacheDirectory}${server}/${fileIdPath}`; } } From d02e39b1f39424d1281d7c7bab1305f955904b3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20V=C3=A9lez?= Date: Mon, 15 Jul 2024 12:55:33 +0200 Subject: [PATCH 06/67] MM-56933 - password dont disable autocomplete (#8057) --- app/screens/login/form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/screens/login/form.tsx b/app/screens/login/form.tsx index ac33fc61da6..025d13a4852 100644 --- a/app/screens/login/form.tsx +++ b/app/screens/login/form.tsx @@ -331,7 +331,7 @@ const LoginForm = ({config, extra, serverDisplayName, launchError, launchType, l disableFullscreenUI={true} enablesReturnKeyAutomatically={true} error={error} - keyboardType='default' + keyboardType={isPasswordVisible ? 'visible-password' : 'default'} label={intl.formatMessage({id: 'login.password', defaultMessage: 'Password'})} onChangeText={onPasswordChange} onSubmitEditing={onLogin} From cdaae87017f8b538a276b25d42d10d8f64ac5f06 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Mon, 15 Jul 2024 17:44:25 +0530 Subject: [PATCH 07/67] Add unit test cases for reactions.ts (#8084) --- app/actions/local/reactions.test.ts | 129 ++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 app/actions/local/reactions.test.ts diff --git a/app/actions/local/reactions.test.ts b/app/actions/local/reactions.test.ts new file mode 100644 index 00000000000..ff08b5f52eb --- /dev/null +++ b/app/actions/local/reactions.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import DatabaseManager from '@database/manager'; +import * as recentReactionsQueries from '@queries/servers/system'; +import * as emojiHelpers from '@utils/emoji/helpers'; +import * as logUtils from '@utils/log'; + +import {addRecentReaction} from './reactions'; + +import type ServerDataOperator from '@app/database/operator/server_data_operator'; + +jest.mock('@database/manager'); +jest.mock('@queries/servers/system'); +jest.mock('@utils/emoji/helpers'); +jest.mock('@utils/log'); + +describe('addRecentReaction', () => { + let operator: ServerDataOperator; + const serverUrl = 'baseHandler.test.com'; + + beforeEach(async () => { + jest.clearAllMocks(); + + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; + + (recentReactionsQueries.getRecentReactions as jest.Mock).mockResolvedValue([]); + (emojiHelpers.getEmojiFirstAlias as jest.Mock).mockImplementation((emoji) => emoji); + + operator.handleSystem = jest.fn(); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should return an empty array if emojiNames is empty', async () => { + const result = await addRecentReaction(serverUrl, []); + expect(result).toEqual([]); + }); + + it('should add new emoji to the beginning of the list', async () => { + (recentReactionsQueries.getRecentReactions as jest.Mock).mockResolvedValue([':water:']); + const emojiNames = [':air:', ':fire:']; + await addRecentReaction(serverUrl, emojiNames); + + expect(operator.handleSystem).toHaveBeenCalledWith({ + systems: [{ + id: 'recentReactions', + value: JSON.stringify([':fire:', ':air:', ':water:']), + }], + prepareRecordsOnly: false, + }); + }); + + it('should move existing emoji to the beginning of the list', async () => { + (recentReactionsQueries.getRecentReactions as jest.Mock).mockResolvedValue([':water:', ':fire:']); + const emojiNames = [':air:', ':fire:']; + await addRecentReaction(serverUrl, emojiNames); + + expect(operator.handleSystem).toHaveBeenCalledWith({ + systems: [{ + id: 'recentReactions', + value: JSON.stringify([':fire:', ':air:', ':water:']), + }], + prepareRecordsOnly: false, + }); + }); + + it('should limit the list to MAXIMUM_RECENT_EMOJI', async () => { + const longEmojiList = Array.from({length: 40}, (_, i) => `emoji${i}`); + (recentReactionsQueries.getRecentReactions as jest.Mock).mockResolvedValue(longEmojiList); + await addRecentReaction(serverUrl, ['newEmoji']); + + const handleSystemCall = (operator.handleSystem as jest.Mock).mock.calls[0][0]; + const savedEmojis = JSON.parse(handleSystemCall.systems[0].value); + expect(savedEmojis.length).toBe(27); + expect(savedEmojis[0]).toBe('newEmoji'); + }); + + it('should use getEmojiFirstAlias for each emoji', async () => { + (emojiHelpers.getEmojiFirstAlias as jest.Mock).mockImplementation((emoji) => { + if (emoji === ':air:') { + return ':wind:'; + } + if (emoji === ':fire:') { + return ':flame:'; + } + return emoji; + }); + + const emojiNames = [':air:', ':fire:']; + await addRecentReaction(serverUrl, emojiNames); + + expect(emojiHelpers.getEmojiFirstAlias).toHaveBeenCalledTimes(2); + expect(emojiHelpers.getEmojiFirstAlias).toHaveBeenCalledWith(':air:'); + expect(emojiHelpers.getEmojiFirstAlias).toHaveBeenCalledWith(':fire:'); + expect(operator.handleSystem).toHaveBeenCalledWith({ + systems: [{ + id: 'recentReactions', + value: JSON.stringify([':flame:', ':wind:']), + }], + prepareRecordsOnly: false, + }); + }); + + it('should handle errors and log them', async () => { + const unregisteredServerUrl = 'unregistered.test.com'; + const failedError = new Error(`${unregisteredServerUrl} database not found`); + + const result = await addRecentReaction(unregisteredServerUrl, [':air:']); + + expect(logUtils.logError).toHaveBeenCalledWith('Failed addRecentReaction', failedError); + expect(result).toEqual({error: failedError}); + }); + + it('should handle prepareRecordsOnly=true flag', async () => { + const emojiNames = [':air:']; + await addRecentReaction(serverUrl, emojiNames, true); + + expect(operator.handleSystem).toHaveBeenCalledWith({ + systems: [{ + id: 'recentReactions', + value: JSON.stringify([':air:']), + }], + prepareRecordsOnly: true, + }); + }); +}); From 62a694b8cac11fe64daf5fc99c10462daa40540b Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 15 Jul 2024 14:27:54 +0200 Subject: [PATCH 08/67] Translations update from Mattermost Weblate (#8089) Automatic Merge --- assets/base/i18n/de.json | 1 + assets/base/i18n/en_AU.json | 11 +++++++++++ assets/base/i18n/ja.json | 1 + assets/base/i18n/nl.json | 1 + assets/base/i18n/pl.json | 11 +++++++++++ assets/base/i18n/ru.json | 1 + assets/base/i18n/zh-CN.json | 11 +++++++++++ 7 files changed, 37 insertions(+) diff --git a/assets/base/i18n/de.json b/assets/base/i18n/de.json index bf1a1c35e64..2df91a93c3c 100644 --- a/assets/base/i18n/de.json +++ b/assets/base/i18n/de.json @@ -1080,6 +1080,7 @@ "suggestion.mention.groups": "Gruppenerwähnungen", "suggestion.mention.here": "Benachrichtigt jeden in diesem Kanal", "suggestion.mention.morechannels": "Andere Kanäle", + "suggestion.mention.nonmembers": "Nicht im Kanal", "suggestion.mention.special": "Spezielle Erwähnungen", "suggestion.mention.users": "Benutzer", "suggestion.search.direct": "Direktnachricht", diff --git a/assets/base/i18n/en_AU.json b/assets/base/i18n/en_AU.json index 3617466ef69..7b05ebdfb83 100644 --- a/assets/base/i18n/en_AU.json +++ b/assets/base/i18n/en_AU.json @@ -796,6 +796,16 @@ "notification_settings.auto_responder.footer.message": "Set a custom message that is automatically sent in response to direct messages, such as an out of office or holiday reply. Enabling this setting changes your status to Out of Office and disables notifications.", "notification_settings.auto_responder.message": "Message", "notification_settings.auto_responder.to.enable": "Enable automatic replies", + "notification_settings.call_notification": "Call Notifications", + "notification_settings.calls": "Call Notifications", + "notification_settings.calls.callsInfo": "Note: silent mode must be off to hear the ringtone preview.", + "notification_settings.calls.calm": "Calm", + "notification_settings.calls.cheerful": "Cheerful", + "notification_settings.calls.dynamic": "Dynamic", + "notification_settings.calls.enable_sound": "Notification sound for incoming calls", + "notification_settings.calls.urgent": "Urgent", + "notification_settings.calls_off": "Off", + "notification_settings.calls_on": "On", "notification_settings.email": "Email Notifications", "notification_settings.email.crt.emailInfo": "When enabled, any reply to a thread you're following will send an email notification", "notification_settings.email.crt.send": "Thread reply notifications", @@ -1070,6 +1080,7 @@ "suggestion.mention.groups": "Group Mentions", "suggestion.mention.here": "Notifies everyone online in this channel", "suggestion.mention.morechannels": "Other Channels", + "suggestion.mention.nonmembers": "Not in Channel", "suggestion.mention.special": "Special Mentions", "suggestion.mention.users": "Users", "suggestion.search.direct": "Direct Messages", diff --git a/assets/base/i18n/ja.json b/assets/base/i18n/ja.json index 8074f74cc5f..5336311cb5c 100644 --- a/assets/base/i18n/ja.json +++ b/assets/base/i18n/ja.json @@ -1080,6 +1080,7 @@ "suggestion.mention.groups": "グループメンション", "suggestion.mention.here": "このチャンネルの現在オンラインの人に通知", "suggestion.mention.morechannels": "他のチャンネル", + "suggestion.mention.nonmembers": "チャンネルにいません", "suggestion.mention.special": "特殊なメンション", "suggestion.mention.users": "ユーザー", "suggestion.search.direct": "ダイレクトメッセージ", diff --git a/assets/base/i18n/nl.json b/assets/base/i18n/nl.json index 420c6473ba9..4fecd55b50c 100644 --- a/assets/base/i18n/nl.json +++ b/assets/base/i18n/nl.json @@ -1080,6 +1080,7 @@ "suggestion.mention.groups": "Groepsvermeldingen", "suggestion.mention.here": "Verwittig iedereen in dit kanaal", "suggestion.mention.morechannels": "Andere Kanalen", + "suggestion.mention.nonmembers": "Niet in kanaal", "suggestion.mention.special": "Speciale Vermeldingen", "suggestion.mention.users": "Gebruikers", "suggestion.search.direct": "Privé bericht", diff --git a/assets/base/i18n/pl.json b/assets/base/i18n/pl.json index 18d298bab25..d38438c245b 100644 --- a/assets/base/i18n/pl.json +++ b/assets/base/i18n/pl.json @@ -796,6 +796,16 @@ "notification_settings.auto_responder.footer.message": "Ustaw niestandardową wiadomość, która jest automatycznie wysyłana w odpowiedzi na wiadomości bezpośrednie, takie jak spoza biura lub z urlopu. Włączenie tego ustawienia powoduje zmianę statusu użytkownika na Poza biurem i wyłączy powiadomienia.", "notification_settings.auto_responder.message": "Wiadomość", "notification_settings.auto_responder.to.enable": "Włącz automatyczne odpowiedzi", + "notification_settings.call_notification": "Powiadomienia o połączeniach", + "notification_settings.calls": "Powiadomienia o połączeniach", + "notification_settings.calls.callsInfo": "Uwaga: aby usłyszeć podgląd dzwonka, tryb cichy musi być wyłączony.", + "notification_settings.calls.calm": "Spokój", + "notification_settings.calls.cheerful": "Wesoły", + "notification_settings.calls.dynamic": "Dynamiczny", + "notification_settings.calls.enable_sound": "Dźwięk powiadomienia dla połączeń przychodzących", + "notification_settings.calls.urgent": "Pilne", + "notification_settings.calls_off": "Wył", + "notification_settings.calls_on": "Wł", "notification_settings.email": "Powiadomienia Email", "notification_settings.email.crt.emailInfo": "Po włączeniu każda odpowiedź na wątek, który obserwujesz, wyśle powiadomienie e-mail", "notification_settings.email.crt.send": "Powiadomienia o odpowiedziach w wątku", @@ -1070,6 +1080,7 @@ "suggestion.mention.groups": "Group Mentions", "suggestion.mention.here": "Powiadamia wszystkich obecnie dostępnych na kanale", "suggestion.mention.morechannels": "Inne kanały", + "suggestion.mention.nonmembers": "Nie na Kanale", "suggestion.mention.special": "Specjalne wzmianki", "suggestion.mention.users": "Użytkownicy", "suggestion.search.direct": "Wiadomości bezpośrednie", diff --git a/assets/base/i18n/ru.json b/assets/base/i18n/ru.json index 13f12c827de..9a42ac01e04 100644 --- a/assets/base/i18n/ru.json +++ b/assets/base/i18n/ru.json @@ -1080,6 +1080,7 @@ "suggestion.mention.groups": "Групповые упоминания", "suggestion.mention.here": "Уведомляет всех кто онлайн на канале", "suggestion.mention.morechannels": "Другие каналы", + "suggestion.mention.nonmembers": "Не в канале", "suggestion.mention.special": "Особые упоминания", "suggestion.mention.users": "Пользователи", "suggestion.search.direct": "Личные сообщения", diff --git a/assets/base/i18n/zh-CN.json b/assets/base/i18n/zh-CN.json index 31701f8375e..7ded581219e 100644 --- a/assets/base/i18n/zh-CN.json +++ b/assets/base/i18n/zh-CN.json @@ -796,6 +796,16 @@ "notification_settings.auto_responder.footer.message": "设置自定义消息,以用来在离开办公室或者度假时在聊天中自动发出的回复。开启此设定将改变您的状态到“不在办公“状态并且关闭推送通知。", "notification_settings.auto_responder.message": "消息", "notification_settings.auto_responder.to.enable": "开启自动回复", + "notification_settings.call_notification": "通话通知", + "notification_settings.calls": "通话通知", + "notification_settings.calls.callsInfo": "注意:在试听铃声前需要关闭静音模式。", + "notification_settings.calls.calm": "Calm", + "notification_settings.calls.cheerful": "Cheerful", + "notification_settings.calls.dynamic": "Dynamic", + "notification_settings.calls.enable_sound": "来电铃声", + "notification_settings.calls.urgent": "Urgent", + "notification_settings.calls_off": "关", + "notification_settings.calls_on": "开", "notification_settings.email": "邮件通知", "notification_settings.email.crt.emailInfo": "当开启时,任何你关注话题的回复将发送邮件通知", "notification_settings.email.crt.send": "话题回复通知", @@ -1070,6 +1080,7 @@ "suggestion.mention.groups": "群组提及", "suggestion.mention.here": "通知所有在此频道在线的人", "suggestion.mention.morechannels": "其他频道", + "suggestion.mention.nonmembers": "不在频道中", "suggestion.mention.special": "特别提及", "suggestion.mention.users": "用户", "suggestion.search.direct": "私信", From ea8adb5167d221d33625e20b561b703eda160b2f Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Wed, 17 Jul 2024 02:47:59 +0800 Subject: [PATCH 09/67] Fix server list bottom sheet on tablet (#8094) --- app/screens/home/channel_list/servers/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/screens/home/channel_list/servers/index.tsx b/app/screens/home/channel_list/servers/index.tsx index 95fabd958b0..c077117725d 100644 --- a/app/screens/home/channel_list/servers/index.tsx +++ b/app/screens/home/channel_list/servers/index.tsx @@ -135,7 +135,7 @@ const Servers = React.forwardRef((_, ref) => { bottomSheet({ closeButtonId, renderContent, - footerComponent: AddServerButton, + footerComponent: isTablet ? undefined : AddServerButton, snapPoints, theme, title: intl.formatMessage({id: 'your.servers', defaultMessage: 'Your servers'}), From b330847d95a4c2090de3f210cb44ea591d799682 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Thu, 18 Jul 2024 09:10:28 +0800 Subject: [PATCH 10/67] Channel Bookmarks (#7817) --- app/actions/local/channel_bookmark.ts | 55 +++ app/actions/local/file.ts | 9 + app/actions/remote/channel.ts | 3 + app/actions/remote/channel_bookmark.ts | 100 +++++ app/actions/remote/file.ts | 5 +- app/actions/remote/search.ts | 4 +- app/actions/websocket/event.ts | 298 ++++++++++++++ app/actions/websocket/index.ts | 376 +----------------- app/client/rest/base.ts | 8 + app/client/rest/channel_bookmark.ts | 68 ++++ app/client/rest/channels.ts | 20 +- app/client/rest/files.ts | 15 +- app/client/rest/index.ts | 3 + app/client/websocket/index.ts | 4 + app/components/button/index.tsx | 19 +- .../channel_bookmarks/add_bookmark.tsx | 163 ++++++++ .../add_bookmark_options.tsx | 60 +++ .../channel_bookmarks/bookmark_type.tsx | 61 +++ .../channel_bookmark/bookmark_details.tsx | 75 ++++ .../channel_bookmark/bookmark_document.tsx | 77 ++++ .../channel_bookmark/bookmark_icon.tsx | 68 ++++ .../channel_bookmark/bookmark_options.tsx | 259 ++++++++++++ .../channel_bookmark/channel_bookmark.tsx | 169 ++++++++ .../channel_bookmark/index.tsx | 32 ++ .../channel_bookmarks/channel_bookmarks.tsx | 179 +++++++++ app/components/channel_bookmarks/index.ts | 29 ++ app/components/document/index.tsx | 168 ++++++++ app/components/files/document_file.tsx | 157 +------- app/components/files/file.tsx | 22 +- app/components/files/file_icon.tsx | 2 +- app/components/files/file_info.tsx | 11 +- app/components/files/image_file.tsx | 11 +- .../floating_text_input_label/index.tsx | 2 +- .../post_list/more_messages/more_messages.tsx | 4 +- app/constants/database.ts | 1 + app/constants/permissions.ts | 6 + app/constants/screens.ts | 5 + app/constants/snack_bar.ts | 8 + app/constants/view.ts | 1 + app/constants/websocket.ts | 4 + app/database/manager/__mocks__/index.ts | 4 +- app/database/manager/index.ts | 4 +- app/database/migration/server/index.ts | 28 +- app/database/models/server/channel.ts | 8 + .../models/server/channel_bookmark.ts | 115 ++++++ app/database/models/server/file.ts | 5 +- app/database/models/server/index.ts | 3 +- app/database/models/server/user.ts | 4 + .../operator/base_data_operator/index.ts | 16 +- .../handlers/channel.test.ts | 6 +- .../server_data_operator/handlers/channel.ts | 94 ++++- .../handlers/index.test.ts | 56 ++- .../server_data_operator/handlers/index.ts | 59 ++- .../server_data_operator/handlers/post.ts | 46 +-- .../transformers/channel.ts | 43 ++ .../transformers/general.test.ts | 35 ++ .../transformers/general.ts | 37 ++ .../transformers/post.test.ts | 34 -- .../server_data_operator/transformers/post.ts | 37 -- app/database/schema/server/index.ts | 8 +- .../server/table_schemas/channel_bookmark.ts | 28 ++ .../schema/server/table_schemas/index.ts | 3 +- app/database/schema/server/test.ts | 40 +- app/hooks/files.ts | 54 ++- app/managers/websocket_manager.ts | 5 +- .../components/floating_call_container.tsx | 6 +- app/products/calls/hooks.ts | 13 +- app/queries/servers/channel.ts | 69 +++- app/queries/servers/channel_bookmark.ts | 117 ++++++ app/queries/servers/entry.ts | 2 + app/queries/servers/file.ts | 8 + app/screens/channel/channel.tsx | 6 +- app/screens/channel/header/bookmarks.tsx | 74 ++++ app/screens/channel/header/header.tsx | 17 +- app/screens/channel/header/index.ts | 13 +- app/screens/channel/index.tsx | 21 +- .../components/bookmark_detail.tsx | 140 +++++++ .../bookmark_file/bookmark_file.tsx | 360 +++++++++++++++++ .../components/bookmark_file/index.ts | 19 + .../components/bookmark_link.tsx | 133 +++++++ app/screens/channel_bookmark/index.tsx | 323 +++++++++++++++ app/screens/channel_info/channel_info.tsx | 34 +- app/screens/channel_info/index.ts | 21 +- app/screens/edit_profile/components/field.tsx | 2 - app/screens/emoji_picker/index.tsx | 6 +- app/screens/emoji_picker/picker/picker.tsx | 6 +- .../emoji_picker/picker/sections/index.tsx | 78 +++- .../picker/sections/section_header.tsx | 2 +- .../home/channel_list/channel_list.test.tsx | 6 + app/screens/index.tsx | 6 + app/screens/navigation.ts | 5 +- app/screens/overlay/index.tsx | 14 + app/utils/categories.test.ts | 12 +- app/utils/file/file_picker/index.ts | 10 +- app/utils/file/index.ts | 2 +- app/utils/gallery/index.ts | 8 +- app/utils/opengraph.ts | 161 ++++++++ app/utils/url/index.ts | 14 +- assets/base/i18n/en.json | 28 ++ package-lock.json | 48 +++ package.json | 1 + .../content_view/link_preview/index.tsx | 2 +- share_extension/open_graph/index.ts | 123 ------ types/api/channels.d.ts | 29 ++ types/api/config.d.ts | 1 + types/api/files.d.ts | 2 +- types/database/database.ts | 4 + types/database/models/servers/channel.ts | 4 + .../models/servers/channel_bookmark.ts | 74 ++++ types/database/raw_values.d.ts | 1 + types/screens/emoji_selector.d.ts | 1 + types/screens/gallery.ts | 4 +- 112 files changed, 4473 insertions(+), 890 deletions(-) create mode 100644 app/actions/local/channel_bookmark.ts create mode 100644 app/actions/remote/channel_bookmark.ts create mode 100644 app/actions/websocket/event.ts create mode 100644 app/client/rest/channel_bookmark.ts create mode 100644 app/components/channel_bookmarks/add_bookmark.tsx create mode 100644 app/components/channel_bookmarks/add_bookmark_options.tsx create mode 100644 app/components/channel_bookmarks/bookmark_type.tsx create mode 100644 app/components/channel_bookmarks/channel_bookmark/bookmark_details.tsx create mode 100644 app/components/channel_bookmarks/channel_bookmark/bookmark_document.tsx create mode 100644 app/components/channel_bookmarks/channel_bookmark/bookmark_icon.tsx create mode 100644 app/components/channel_bookmarks/channel_bookmark/bookmark_options.tsx create mode 100644 app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx create mode 100644 app/components/channel_bookmarks/channel_bookmark/index.tsx create mode 100644 app/components/channel_bookmarks/channel_bookmarks.tsx create mode 100644 app/components/channel_bookmarks/index.ts create mode 100644 app/components/document/index.tsx create mode 100644 app/database/models/server/channel_bookmark.ts create mode 100644 app/database/schema/server/table_schemas/channel_bookmark.ts create mode 100644 app/queries/servers/channel_bookmark.ts create mode 100644 app/screens/channel/header/bookmarks.tsx create mode 100644 app/screens/channel_bookmark/components/bookmark_detail.tsx create mode 100644 app/screens/channel_bookmark/components/bookmark_file/bookmark_file.tsx create mode 100644 app/screens/channel_bookmark/components/bookmark_file/index.ts create mode 100644 app/screens/channel_bookmark/components/bookmark_link.tsx create mode 100644 app/screens/channel_bookmark/index.tsx create mode 100644 app/screens/overlay/index.tsx delete mode 100644 share_extension/open_graph/index.ts create mode 100644 types/database/models/servers/channel_bookmark.ts diff --git a/app/actions/local/channel_bookmark.ts b/app/actions/local/channel_bookmark.ts new file mode 100644 index 00000000000..ff2479756f0 --- /dev/null +++ b/app/actions/local/channel_bookmark.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import {getMyChannel} from '@queries/servers/channel'; +import {logError} from '@utils/log'; + +async function handleBookmarks(serverUrl: string, bookmarks: ChannelBookmarkWithFileInfo[], prepareRecordsOnly = false) { + if (!bookmarks.length) { + return {models: undefined}; + } + + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const myChannel = await getMyChannel(database, bookmarks[0].channel_id); + if (!myChannel) { + return {models: undefined}; + } + + const models = await operator.handleChannelBookmark({bookmarks, prepareRecordsOnly}); + return {models}; +} + +export async function handleBookmarkAddedOrDeleted(serverUrl: string, msg: WebSocketMessage, prepareRecordsOnly = false) { + try { + const bookmark: ChannelBookmarkWithFileInfo = JSON.parse(msg.data.bookmark); + return handleBookmarks(serverUrl, [bookmark], prepareRecordsOnly); + } catch (error) { + logError('cannot handle bookmark added websocket event', error); + return {error}; + } +} + +export async function handleBookmarkEdited(serverUrl: string, msg: WebSocketMessage, prepareRecordsOnly = false) { + try { + const edited: UpdateChannelBookmarkResponse = JSON.parse(msg.data.bookmarks); + const bookmarks = [edited.updated]; + if (edited.deleted) { + bookmarks.push(edited.deleted); + } + return handleBookmarks(serverUrl, bookmarks, prepareRecordsOnly); + } catch (error) { + logError('cannot handle bookmark updated websocket event', error); + return {error}; + } +} + +export async function handleBookmarkSorted(serverUrl: string, msg: WebSocketMessage, prepareRecordsOnly = false) { + try { + const bookmarks: ChannelBookmarkWithFileInfo[] = JSON.parse(msg.data.bookmarks); + return handleBookmarks(serverUrl, bookmarks, prepareRecordsOnly); + } catch (error) { + logError('cannot handle bookmark sorted websocket event', error); + return {error}; + } +} diff --git a/app/actions/local/file.ts b/app/actions/local/file.ts index 649ef9666d0..a4fdba9a781 100644 --- a/app/actions/local/file.ts +++ b/app/actions/local/file.ts @@ -34,3 +34,12 @@ export const updateLocalFilePath = async (serverUrl: string, fileId: string, loc } }; +export const getLocalFileInfo = async (serverUrl: string, fileId: string) => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const file = await getFileById(database, fileId); + return {file}; + } catch (error) { + return {error}; + } +}; diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 3d0e36bad34..6b64322a164 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -31,6 +31,7 @@ import {logDebug, logError, logInfo} from '@utils/log'; import {showMuteChannelSnackbar} from '@utils/snack_bar'; import {displayGroupMessageName, displayUsername} from '@utils/user'; +import {fetchChannelBookmarks} from './channel_bookmark'; import {fetchGroupsForChannelIfConstrained} from './groups'; import {fetchPostsForChannel} from './post'; import {openChannelIfNeeded, savePreference} from './preference'; @@ -98,6 +99,7 @@ export async function fetchChannelMembersByIds(serverUrl: string, channelId: str return {error}; } } + export async function updateChannelMemberSchemeRoles(serverUrl: string, channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean, fetchOnly = false) { try { const client = NetworkManager.getClient(serverUrl); @@ -1049,6 +1051,7 @@ export async function switchToChannelById(serverUrl: string, channelId: string, DeviceEventEmitter.emit(Events.CHANNEL_SWITCH, true); fetchPostsForChannel(serverUrl, channelId); + fetchChannelBookmarks(serverUrl, channelId); await switchToChannel(serverUrl, channelId, teamId, skipLastUnread); openChannelIfNeeded(serverUrl, channelId); markChannelAsRead(serverUrl, channelId); diff --git a/app/actions/remote/channel_bookmark.ts b/app/actions/remote/channel_bookmark.ts new file mode 100644 index 00000000000..825295ad5b8 --- /dev/null +++ b/app/actions/remote/channel_bookmark.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; +import websocketManager from '@managers/websocket_manager'; +import {getBookmarksSince, getChannelBookmarkById} from '@queries/servers/channel_bookmark'; +import {getConfigValue} from '@queries/servers/system'; +import {getFullErrorMessage} from '@utils/errors'; +import {logError} from '@utils/log'; + +import {forceLogoutIfNecessary} from './session'; + +export async function fetchChannelBookmarks(serverUrl: string, channelId: string, fetchOnly = false) { + try { + const client = NetworkManager.getClient(serverUrl); + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + + const bookmarksEnabled = (await getConfigValue(database, 'FeatureFlagChannelBookmarks')) === 'true'; + if (!bookmarksEnabled) { + return {bookmarks: []}; + } + + const since = await getBookmarksSince(database, channelId); + const bookmarks = await client.getChannelBookmarksForChannel(channelId, since); + + if (!fetchOnly && bookmarks.length) { + await operator.handleChannelBookmark({bookmarks, prepareRecordsOnly: false}); + } + + return {bookmarks}; + } catch (error) { + logError('error on fetchChannelBookmarks', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +} + +export async function createChannelBookmark(serverUrl: string, channelId: string, bookmark: ChannelBookmark, fetchOnly = false) { + try { + const client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const ws = websocketManager.getClient(serverUrl); + + const created = await client.createChannelBookmark(channelId, bookmark, ws?.getConnectionId()); + if (!fetchOnly) { + await operator.handleChannelBookmark({bookmarks: [created], prepareRecordsOnly: false}); + } + return {bookmark: created}; + } catch (error) { + logError('error on createChannelBookmark', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +} + +export async function editChannelBookmark(serverUrl: string, bookmark: ChannelBookmark, fetchOnly = false) { + try { + const client = NetworkManager.getClient(serverUrl); + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const ws = websocketManager.getClient(serverUrl); + + const result = await client.updateChannelBookmark(bookmark.channel_id, bookmark, ws?.getConnectionId()); + const bookmarks = [result.updated]; + if (result.deleted) { + bookmarks.push(result.deleted); + } + if (!fetchOnly) { + await operator.handleChannelBookmark({bookmarks, prepareRecordsOnly: false}); + } + return {bookmarks: result}; + } catch (error) { + logError('error on editChannelBookmark', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +} + +export async function deleteChannelBookmark(serverUrl: string, channelId: string, bookmarkId: string, fetchOnly = false) { + try { + const client = NetworkManager.getClient(serverUrl); + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const ws = websocketManager.getClient(serverUrl); + + const result = await client.deleteChannelBookmark(channelId, bookmarkId, ws?.getConnectionId()); + + const bookmark = await getChannelBookmarkById(database, bookmarkId); + if (bookmark && !fetchOnly) { + const b = bookmark.toApi(); + b.delete_at = Date.now(); + await operator.handleChannelBookmark({bookmarks: [b], prepareRecordsOnly: false}); + } + + return {bookmarks: result}; + } catch (error) { + logError('error on deleteChannelBookmark', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +} diff --git a/app/actions/remote/file.ts b/app/actions/remote/file.ts index 14461f10878..0a4fefb9809 100644 --- a/app/actions/remote/file.ts +++ b/app/actions/remote/file.ts @@ -23,16 +23,17 @@ export const downloadProfileImage = (serverUrl: string, userId: string, lastPict export const uploadFile = ( serverUrl: string, - file: FileInfo, + file: FileInfo | ExtractedFileInfo, channelId: string, onProgress: (fractionCompleted: number, bytesRead?: number | null | undefined) => void = () => {/*Do Nothing*/}, onComplete: (response: ClientResponse) => void = () => {/*Do Nothing*/}, onError: (response: ClientResponseError) => void = () => {/*Do Nothing*/}, skipBytes = 0, + isBookmark = false, ) => { try { const client = NetworkManager.getClient(serverUrl); - return {cancel: client.uploadPostAttachment(file, channelId, onProgress, onComplete, onError, skipBytes)}; + return {cancel: client.uploadAttachment(file, channelId, onProgress, onComplete, onError, skipBytes, isBookmark)}; } catch (error) { logDebug('error on uploadFile', getFullErrorMessage(error)); return {error}; diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts index 86ab63a8cb4..42b81e7997b 100644 --- a/app/actions/remote/search.ts +++ b/app/actions/remote/search.ts @@ -153,7 +153,9 @@ export const searchFiles = async (serverUrl: string, teamId: string, params: Fil return acc; }, {}); files.forEach((f) => { - f.postProps = idToPost[f.post_id]?.props; + if (f.post_id) { + f.postProps = idToPost[f.post_id]?.props; + } }); return {files, channels}; } catch (error) { diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts new file mode 100644 index 00000000000..fa4aedd86f3 --- /dev/null +++ b/app/actions/websocket/event.ts @@ -0,0 +1,298 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as bookmark from '@actions/local/channel_bookmark'; +import * as calls from '@calls/connection/websocket_event_handlers'; +import {WebsocketEvents} from '@constants'; + +import * as category from './category'; +import * as channel from './channel'; +import * as group from './group'; +import {handleOpenDialogEvent} from './integrations'; +import * as posts from './posts'; +import * as preferences from './preferences'; +import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions'; +import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles'; +import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system'; +import * as teams from './teams'; +import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads'; +import {handleUserUpdatedEvent, handleUserTypingEvent, handleStatusChangedEvent} from './users'; + +export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMessage) { + switch (msg.event) { + case WebsocketEvents.POSTED: + case WebsocketEvents.EPHEMERAL_MESSAGE: + posts.handleNewPostEvent(serverUrl, msg); + break; + case WebsocketEvents.POST_EDITED: + posts.handlePostEdited(serverUrl, msg); + break; + case WebsocketEvents.POST_DELETED: + posts.handlePostDeleted(serverUrl, msg); + break; + case WebsocketEvents.POST_UNREAD: + posts.handlePostUnread(serverUrl, msg); + break; + case WebsocketEvents.POST_ACKNOWLEDGEMENT_ADDED: + posts.handlePostAcknowledgementAdded(serverUrl, msg); + break; + case WebsocketEvents.POST_ACKNOWLEDGEMENT_REMOVED: + posts.handlePostAcknowledgementRemoved(serverUrl, msg); + break; + + case WebsocketEvents.LEAVE_TEAM: + teams.handleLeaveTeamEvent(serverUrl, msg); + break; + case WebsocketEvents.UPDATE_TEAM: + teams.handleUpdateTeamEvent(serverUrl, msg); + break; + case WebsocketEvents.ADDED_TO_TEAM: + teams.handleUserAddedToTeamEvent(serverUrl, msg); + break; + case WebsocketEvents.DELETE_TEAM: + teams.handleTeamArchived(serverUrl, msg); + break; + case WebsocketEvents.RESTORE_TEAM: + teams.handleTeamRestored(serverUrl, msg); + break; + + case WebsocketEvents.USER_ADDED: + channel.handleUserAddedToChannelEvent(serverUrl, msg); + break; + case WebsocketEvents.USER_REMOVED: + channel.handleUserRemovedFromChannelEvent(serverUrl, msg); + break; + case WebsocketEvents.USER_UPDATED: + handleUserUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.ROLE_UPDATED: + handleRoleUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.USER_ROLE_UPDATED: + handleUserRoleUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.MEMBERROLE_UPDATED: + handleTeamMemberRoleUpdatedEvent(serverUrl, msg); + break; + + case WebsocketEvents.CATEGORY_CREATED: + category.handleCategoryCreatedEvent(serverUrl, msg); + break; + case WebsocketEvents.CATEGORY_UPDATED: + category.handleCategoryUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.CATEGORY_ORDER_UPDATED: + category.handleCategoryOrderUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.CATEGORY_DELETED: + category.handleCategoryDeletedEvent(serverUrl, msg); + break; + + case WebsocketEvents.CHANNEL_CREATED: + channel.handleChannelCreatedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_DELETED: + channel.handleChannelDeletedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_UNARCHIVED: + channel.handleChannelUnarchiveEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_UPDATED: + channel.handleChannelUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_CONVERTED: + channel.handleChannelConvertedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_VIEWED: + channel.handleChannelViewedEvent(serverUrl, msg); + break; + case WebsocketEvents.MULTIPLE_CHANNELS_VIEWED: + channel.handleMultipleChannelsViewedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_MEMBER_UPDATED: + channel.handleChannelMemberUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_SCHEME_UPDATED: + // Do nothing, handled by CHANNEL_UPDATED due to changes in the channel scheme. + break; + case WebsocketEvents.DIRECT_ADDED: + case WebsocketEvents.GROUP_ADDED: + channel.handleDirectAddedEvent(serverUrl, msg); + break; + + case WebsocketEvents.PREFERENCE_CHANGED: + preferences.handlePreferenceChangedEvent(serverUrl, msg); + break; + case WebsocketEvents.PREFERENCES_CHANGED: + preferences.handlePreferencesChangedEvent(serverUrl, msg); + break; + case WebsocketEvents.PREFERENCES_DELETED: + preferences.handlePreferencesDeletedEvent(serverUrl, msg); + break; + + case WebsocketEvents.STATUS_CHANGED: + handleStatusChangedEvent(serverUrl, msg); + break; + case WebsocketEvents.TYPING: + handleUserTypingEvent(serverUrl, msg); + break; + + case WebsocketEvents.REACTION_ADDED: + handleReactionAddedToPostEvent(serverUrl, msg); + break; + case WebsocketEvents.REACTION_REMOVED: + handleReactionRemovedFromPostEvent(serverUrl, msg); + break; + case WebsocketEvents.EMOJI_ADDED: + handleAddCustomEmoji(serverUrl, msg); + break; + + case WebsocketEvents.LICENSE_CHANGED: + handleLicenseChangedEvent(serverUrl, msg); + break; + case WebsocketEvents.CONFIG_CHANGED: + handleConfigChangedEvent(serverUrl, msg); + break; + + case WebsocketEvents.OPEN_DIALOG: + handleOpenDialogEvent(serverUrl, msg); + break; + case WebsocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: + break; + + case WebsocketEvents.THREAD_UPDATED: + handleThreadUpdatedEvent(serverUrl, msg); + break; + case WebsocketEvents.THREAD_READ_CHANGED: + handleThreadReadChangedEvent(serverUrl, msg); + break; + case WebsocketEvents.THREAD_FOLLOW_CHANGED: + handleThreadFollowChangedEvent(serverUrl, msg); + break; + + // Calls ws events: + case WebsocketEvents.CALLS_CHANNEL_ENABLED: + calls.handleCallChannelEnabled(serverUrl, msg); + break; + case WebsocketEvents.CALLS_CHANNEL_DISABLED: + calls.handleCallChannelDisabled(serverUrl, msg); + break; + + // DEPRECATED in favour of user_joined (since v0.21.0) + case WebsocketEvents.CALLS_USER_CONNECTED: + calls.handleCallUserConnected(serverUrl, msg); + break; + + // DEPRECATED in favour of user_left (since v0.21.0) + case WebsocketEvents.CALLS_USER_DISCONNECTED: + calls.handleCallUserDisconnected(serverUrl, msg); + break; + + case WebsocketEvents.CALLS_USER_JOINED: + calls.handleCallUserJoined(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_LEFT: + calls.handleCallUserLeft(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_MUTED: + calls.handleCallUserMuted(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_UNMUTED: + calls.handleCallUserUnmuted(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_VOICE_ON: + calls.handleCallUserVoiceOn(msg); + break; + case WebsocketEvents.CALLS_USER_VOICE_OFF: + calls.handleCallUserVoiceOff(msg); + break; + case WebsocketEvents.CALLS_CALL_START: + calls.handleCallStarted(serverUrl, msg); + break; + case WebsocketEvents.CALLS_SCREEN_ON: + calls.handleCallScreenOn(serverUrl, msg); + break; + case WebsocketEvents.CALLS_SCREEN_OFF: + calls.handleCallScreenOff(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_RAISE_HAND: + calls.handleCallUserRaiseHand(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_UNRAISE_HAND: + calls.handleCallUserUnraiseHand(serverUrl, msg); + break; + case WebsocketEvents.CALLS_CALL_END: + calls.handleCallEnded(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_REACTED: + calls.handleCallUserReacted(serverUrl, msg); + break; + + // DEPRECATED in favour of CALLS_JOB_STATE (since v2.15.0) + case WebsocketEvents.CALLS_RECORDING_STATE: + calls.handleCallRecordingState(serverUrl, msg); + break; + case WebsocketEvents.CALLS_JOB_STATE: + calls.handleCallJobState(serverUrl, msg); + break; + case WebsocketEvents.CALLS_HOST_CHANGED: + calls.handleCallHostChanged(serverUrl, msg); + break; + case WebsocketEvents.CALLS_USER_DISMISSED_NOTIFICATION: + calls.handleUserDismissedNotification(serverUrl, msg); + break; + case WebsocketEvents.CALLS_CAPTION: + calls.handleCallCaption(serverUrl, msg); + break; + case WebsocketEvents.CALLS_HOST_MUTE: + calls.handleHostMute(serverUrl, msg); + break; + case WebsocketEvents.CALLS_HOST_LOWER_HAND: + calls.handleHostLowerHand(serverUrl, msg); + break; + case WebsocketEvents.CALLS_HOST_REMOVED: + calls.handleHostRemoved(serverUrl, msg); + break; + case WebsocketEvents.CALLS_CALL_STATE: + calls.handleCallState(serverUrl, msg); + break; + case WebsocketEvents.GROUP_RECEIVED: + group.handleGroupReceivedEvent(serverUrl, msg); + break; + case WebsocketEvents.GROUP_MEMBER_ADD: + group.handleGroupMemberAddEvent(serverUrl, msg); + break; + case WebsocketEvents.GROUP_MEMBER_DELETE: + group.handleGroupMemberDeleteEvent(serverUrl, msg); + break; + case WebsocketEvents.GROUP_ASSOCIATED_TO_TEAM: + group.handleGroupTeamAssociatedEvent(serverUrl, msg); + break; + case WebsocketEvents.GROUP_DISSOCIATED_TO_TEAM: + group.handleGroupTeamDissociateEvent(serverUrl, msg); + break; + case WebsocketEvents.GROUP_ASSOCIATED_TO_CHANNEL: + break; + case WebsocketEvents.GROUP_DISSOCIATED_TO_CHANNEL: + break; + + // Plugins + case WebsocketEvents.PLUGIN_STATUSES_CHANGED: + case WebsocketEvents.PLUGIN_ENABLED: + case WebsocketEvents.PLUGIN_DISABLED: + // Do nothing, this event doesn't need logic in the mobile app + break; + + // bookmarks + case WebsocketEvents.CHANNEL_BOOKMARK_CREATED: + case WebsocketEvents.CHANNEL_BOOKMARK_DELETED: + bookmark.handleBookmarkAddedOrDeleted(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_BOOKMARK_UPDATED: + bookmark.handleBookmarkEdited(serverUrl, msg); + break; + case WebsocketEvents.CHANNEL_BOOKMARK_SORTED: + bookmark.handleBookmarkSorted(serverUrl, msg); + break; + } +} diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 96d173242fb..0c233c6dd5b 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -14,36 +14,8 @@ import {fetchPostsForChannel, fetchPostThread} from '@actions/remote/post'; import {openAllUnreadChannels} from '@actions/remote/preference'; import {autoUpdateTimezone} from '@actions/remote/user'; import {loadConfigAndCalls} from '@calls/actions/calls'; -import { - handleCallCaption, - handleCallChannelDisabled, - handleCallChannelEnabled, - handleCallEnded, - handleCallHostChanged, - handleCallJobState, - handleCallRecordingState, - handleCallScreenOff, - handleCallScreenOn, - handleCallStarted, - handleCallState, - handleCallUserConnected, - handleCallUserDisconnected, - handleCallUserJoined, - handleCallUserLeft, - handleCallUserMuted, - handleCallUserRaiseHand, - handleCallUserReacted, - handleCallUserUnmuted, - handleCallUserUnraiseHand, - handleCallUserVoiceOff, - handleCallUserVoiceOn, - handleHostLowerHand, - handleHostMute, - handleHostRemoved, - handleUserDismissedNotification, -} from '@calls/connection/websocket_event_handlers'; import {isSupportedServerCalls} from '@calls/utils'; -import {Screens, WebsocketEvents} from '@constants'; +import {Screens} from '@constants'; import DatabaseManager from '@database/manager'; import AppsManager from '@managers/apps_manager'; import {getActiveServerUrl} from '@queries/app/servers'; @@ -64,58 +36,6 @@ import {setTeamLoading} from '@store/team_load_store'; import {isTablet} from '@utils/helpers'; import {logDebug, logInfo} from '@utils/log'; -import { - handleCategoryCreatedEvent, - handleCategoryDeletedEvent, - handleCategoryOrderUpdatedEvent, - handleCategoryUpdatedEvent, -} from './category'; -import { - handleChannelConvertedEvent, handleChannelCreatedEvent, - handleChannelDeletedEvent, - handleChannelMemberUpdatedEvent, - handleChannelUnarchiveEvent, - handleChannelUpdatedEvent, - handleChannelViewedEvent, - handleMultipleChannelsViewedEvent, - handleDirectAddedEvent, - handleUserAddedToChannelEvent, - handleUserRemovedFromChannelEvent, -} from './channel'; -import { - handleGroupMemberAddEvent, - handleGroupMemberDeleteEvent, - handleGroupReceivedEvent, - handleGroupTeamAssociatedEvent, - handleGroupTeamDissociateEvent, -} from './group'; -import {handleOpenDialogEvent} from './integrations'; -import { - handleNewPostEvent, - handlePostAcknowledgementAdded, - handlePostAcknowledgementRemoved, - handlePostDeleted, - handlePostEdited, - handlePostUnread, -} from './posts'; -import { - handlePreferenceChangedEvent, - handlePreferencesChangedEvent, - handlePreferencesDeletedEvent, -} from './preferences'; -import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions'; -import {handleUserRoleUpdatedEvent, handleTeamMemberRoleUpdatedEvent, handleRoleUpdatedEvent} from './roles'; -import {handleLicenseChangedEvent, handleConfigChangedEvent} from './system'; -import { - handleLeaveTeamEvent, - handleUserAddedToTeamEvent, - handleUpdateTeamEvent, - handleTeamArchived, - handleTeamRestored, -} from './teams'; -import {handleThreadUpdatedEvent, handleThreadReadChangedEvent, handleThreadFollowChangedEvent} from './threads'; -import {handleUserUpdatedEvent, handleUserTypingEvent, handleStatusChangedEvent} from './users'; - export async function handleFirstConnect(serverUrl: string) { registerDeviceToken(serverUrl); autoUpdateTimezone(serverUrl); @@ -192,300 +112,6 @@ async function doReconnect(serverUrl: string) { return undefined; } -export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { - switch (msg.event) { - case WebsocketEvents.POSTED: - case WebsocketEvents.EPHEMERAL_MESSAGE: - handleNewPostEvent(serverUrl, msg); - break; - - case WebsocketEvents.POST_EDITED: - handlePostEdited(serverUrl, msg); - break; - - case WebsocketEvents.POST_DELETED: - handlePostDeleted(serverUrl, msg); - break; - - case WebsocketEvents.POST_UNREAD: - handlePostUnread(serverUrl, msg); - break; - - case WebsocketEvents.POST_ACKNOWLEDGEMENT_ADDED: - handlePostAcknowledgementAdded(serverUrl, msg); - break; - case WebsocketEvents.POST_ACKNOWLEDGEMENT_REMOVED: - handlePostAcknowledgementRemoved(serverUrl, msg); - break; - - case WebsocketEvents.LEAVE_TEAM: - handleLeaveTeamEvent(serverUrl, msg); - break; - case WebsocketEvents.UPDATE_TEAM: - handleUpdateTeamEvent(serverUrl, msg); - break; - case WebsocketEvents.ADDED_TO_TEAM: - handleUserAddedToTeamEvent(serverUrl, msg); - break; - - case WebsocketEvents.USER_ADDED: - handleUserAddedToChannelEvent(serverUrl, msg); - break; - case WebsocketEvents.USER_REMOVED: - handleUserRemovedFromChannelEvent(serverUrl, msg); - break; - case WebsocketEvents.USER_UPDATED: - handleUserUpdatedEvent(serverUrl, msg); - break; - case WebsocketEvents.ROLE_UPDATED: - handleRoleUpdatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.USER_ROLE_UPDATED: - handleUserRoleUpdatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.MEMBERROLE_UPDATED: - handleTeamMemberRoleUpdatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CATEGORY_CREATED: - handleCategoryCreatedEvent(serverUrl, msg); - break; - case WebsocketEvents.CATEGORY_UPDATED: - handleCategoryUpdatedEvent(serverUrl, msg); - break; - case WebsocketEvents.CATEGORY_ORDER_UPDATED: - handleCategoryOrderUpdatedEvent(serverUrl, msg); - break; - case WebsocketEvents.CATEGORY_DELETED: - handleCategoryDeletedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_CREATED: - handleChannelCreatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_DELETED: - handleChannelDeletedEvent(serverUrl, msg); - break; - case WebsocketEvents.CHANNEL_UNARCHIVED: - handleChannelUnarchiveEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_UPDATED: - handleChannelUpdatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_CONVERTED: - handleChannelConvertedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_VIEWED: - handleChannelViewedEvent(serverUrl, msg); - break; - - case WebsocketEvents.MULTIPLE_CHANNELS_VIEWED: - handleMultipleChannelsViewedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_MEMBER_UPDATED: - handleChannelMemberUpdatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CHANNEL_SCHEME_UPDATED: - // Do nothing, handled by CHANNEL_UPDATED due to changes in the channel scheme. - break; - - case WebsocketEvents.DIRECT_ADDED: - case WebsocketEvents.GROUP_ADDED: - handleDirectAddedEvent(serverUrl, msg); - break; - - case WebsocketEvents.PREFERENCE_CHANGED: - handlePreferenceChangedEvent(serverUrl, msg); - break; - - case WebsocketEvents.PREFERENCES_CHANGED: - handlePreferencesChangedEvent(serverUrl, msg); - break; - - case WebsocketEvents.PREFERENCES_DELETED: - handlePreferencesDeletedEvent(serverUrl, msg); - break; - - case WebsocketEvents.STATUS_CHANGED: - handleStatusChangedEvent(serverUrl, msg); - break; - case WebsocketEvents.TYPING: - handleUserTypingEvent(serverUrl, msg); - break; - - case WebsocketEvents.REACTION_ADDED: - handleReactionAddedToPostEvent(serverUrl, msg); - break; - - case WebsocketEvents.REACTION_REMOVED: - handleReactionRemovedFromPostEvent(serverUrl, msg); - break; - - case WebsocketEvents.EMOJI_ADDED: - handleAddCustomEmoji(serverUrl, msg); - break; - - case WebsocketEvents.LICENSE_CHANGED: - handleLicenseChangedEvent(serverUrl, msg); - break; - - case WebsocketEvents.CONFIG_CHANGED: - handleConfigChangedEvent(serverUrl, msg); - break; - - case WebsocketEvents.OPEN_DIALOG: - handleOpenDialogEvent(serverUrl, msg); - break; - - case WebsocketEvents.DELETE_TEAM: - handleTeamArchived(serverUrl, msg); - break; - - case WebsocketEvents.RESTORE_TEAM: - handleTeamRestored(serverUrl, msg); - break; - - case WebsocketEvents.THREAD_UPDATED: - handleThreadUpdatedEvent(serverUrl, msg); - break; - - case WebsocketEvents.THREAD_READ_CHANGED: - handleThreadReadChangedEvent(serverUrl, msg); - break; - - case WebsocketEvents.THREAD_FOLLOW_CHANGED: - handleThreadFollowChangedEvent(serverUrl, msg); - break; - - case WebsocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: - break; - - // return dispatch(handleRefreshAppsBindings()); - - // Calls ws events: - case WebsocketEvents.CALLS_CHANNEL_ENABLED: - handleCallChannelEnabled(serverUrl, msg); - break; - case WebsocketEvents.CALLS_CHANNEL_DISABLED: - handleCallChannelDisabled(serverUrl, msg); - break; - - // DEPRECATED in favour of user_joined (since v0.21.0) - case WebsocketEvents.CALLS_USER_CONNECTED: - handleCallUserConnected(serverUrl, msg); - break; - - // DEPRECATED in favour of user_left (since v0.21.0) - case WebsocketEvents.CALLS_USER_DISCONNECTED: - handleCallUserDisconnected(serverUrl, msg); - break; - - case WebsocketEvents.CALLS_USER_JOINED: - handleCallUserJoined(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_LEFT: - handleCallUserLeft(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_MUTED: - handleCallUserMuted(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_UNMUTED: - handleCallUserUnmuted(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_VOICE_ON: - handleCallUserVoiceOn(msg); - break; - case WebsocketEvents.CALLS_USER_VOICE_OFF: - handleCallUserVoiceOff(msg); - break; - case WebsocketEvents.CALLS_CALL_START: - handleCallStarted(serverUrl, msg); - break; - case WebsocketEvents.CALLS_SCREEN_ON: - handleCallScreenOn(serverUrl, msg); - break; - case WebsocketEvents.CALLS_SCREEN_OFF: - handleCallScreenOff(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_RAISE_HAND: - handleCallUserRaiseHand(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_UNRAISE_HAND: - handleCallUserUnraiseHand(serverUrl, msg); - break; - case WebsocketEvents.CALLS_CALL_END: - handleCallEnded(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_REACTED: - handleCallUserReacted(serverUrl, msg); - break; - - // DEPRECATED in favour of CALLS_JOB_STATE (since v2.15.0) - case WebsocketEvents.CALLS_RECORDING_STATE: - handleCallRecordingState(serverUrl, msg); - break; - case WebsocketEvents.CALLS_JOB_STATE: - handleCallJobState(serverUrl, msg); - break; - case WebsocketEvents.CALLS_HOST_CHANGED: - handleCallHostChanged(serverUrl, msg); - break; - case WebsocketEvents.CALLS_USER_DISMISSED_NOTIFICATION: - handleUserDismissedNotification(serverUrl, msg); - break; - case WebsocketEvents.CALLS_CAPTION: - handleCallCaption(serverUrl, msg); - break; - case WebsocketEvents.CALLS_HOST_MUTE: - handleHostMute(serverUrl, msg); - break; - case WebsocketEvents.CALLS_HOST_LOWER_HAND: - handleHostLowerHand(serverUrl, msg); - break; - case WebsocketEvents.CALLS_HOST_REMOVED: - handleHostRemoved(serverUrl, msg); - break; - case WebsocketEvents.CALLS_CALL_STATE: - handleCallState(serverUrl, msg); - break; - - case WebsocketEvents.GROUP_RECEIVED: - handleGroupReceivedEvent(serverUrl, msg); - break; - case WebsocketEvents.GROUP_MEMBER_ADD: - handleGroupMemberAddEvent(serverUrl, msg); - break; - case WebsocketEvents.GROUP_MEMBER_DELETE: - handleGroupMemberDeleteEvent(serverUrl, msg); - break; - case WebsocketEvents.GROUP_ASSOCIATED_TO_TEAM: - handleGroupTeamAssociatedEvent(serverUrl, msg); - break; - case WebsocketEvents.GROUP_DISSOCIATED_TO_TEAM: - handleGroupTeamDissociateEvent(serverUrl, msg); - break; - case WebsocketEvents.GROUP_ASSOCIATED_TO_CHANNEL: - break; - case WebsocketEvents.GROUP_DISSOCIATED_TO_CHANNEL: - break; - - // Plugins - case WebsocketEvents.PLUGIN_STATUSES_CHANGED: - case WebsocketEvents.PLUGIN_ENABLED: - case WebsocketEvents.PLUGIN_DISABLED: - // Do nothing, this event doesn't need logic in the mobile app - break; - } -} - async function fetchPostDataIfNeeded(serverUrl: string) { try { const isActiveServer = (await getActiveServerUrl()) === serverUrl; diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index 5259b9361dc..0240d3f6761 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -131,6 +131,14 @@ export default class ClientBase { return `${this.getChannelsRoute()}/${channelId}`; } + getChannelBookmarksRoute(channelId: string) { + return `${this.getChannelRoute(channelId)}/bookmarks`; + } + + getChannelBookmarkRoute(channelId: string, bookmarkId: string) { + return `${this.getChannelBookmarksRoute(channelId)}/${bookmarkId}`; + } + getSharedChannelsRoute() { return `${this.urlVersion}/sharedchannels`; } diff --git a/app/client/rest/channel_bookmark.ts b/app/client/rest/channel_bookmark.ts new file mode 100644 index 00000000000..07dd0a27e7a --- /dev/null +++ b/app/client/rest/channel_bookmark.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {buildQueryString} from '@utils/helpers'; + +import type ClientBase from './base'; + +export interface ClientChannelBookmarksMix { + createChannelBookmark(channelId: string, bookmark: ChannelBookmark, connectionId?: string): Promise; + updateChannelBookmark(channelId: string, bookmark: ChannelBookmark, connectionId?: string): Promise; + updateChannelBookmarkSortOrder(channelId: string, bookmarkId: string, newSortOrder: number, connectionId?: string): Promise; + deleteChannelBookmark(channelId: string, bookmarkId: string, connectionId?: string): Promise; + getChannelBookmarksForChannel(channelId: string, since: number): Promise; +} + +const ClientChannelBookmarks = >(superclass: TBase) => class extends superclass { + createChannelBookmark = async (channelId: string, bookmark: ChannelBookmark, connectionId = '') => { + return this.doFetch( + this.getChannelBookmarksRoute(channelId), + { + method: 'post', + body: bookmark, + headers: {'Connection-Id': connectionId}, + }, + ); + }; + + updateChannelBookmark(channelId: string, bookmark: ChannelBookmark, connectionId?: string) { + return this.doFetch( + this.getChannelBookmarkRoute(channelId, bookmark.id), + { + method: 'patch', + body: bookmark, + headers: {'Connection-Id': connectionId}, + }, + ); + } + + updateChannelBookmarkSortOrder(channelId: string, bookmarkId: string, newSortOrder: number, connectionId?: string) { + return this.doFetch( + `${this.getChannelBookmarkRoute(channelId, bookmarkId)}/sort_order`, + { + method: 'post', + body: newSortOrder, + headers: {'Connection-Id': connectionId}, + }, + ); + } + + deleteChannelBookmark(channelId: string, bookmarkId: string, connectionId?: string) { + return this.doFetch( + this.getChannelBookmarkRoute(channelId, bookmarkId), + { + method: 'delete', + headers: {'Connection-Id': connectionId}, + }, + ); + } + + getChannelBookmarksForChannel(channelId: string, since: number) { + return this.doFetch( + `${this.getChannelBookmarksRoute(channelId)}${buildQueryString({bookmarks_since: since})}`, + {method: 'get'}, + ); + } +}; + +export default ClientChannelBookmarks; diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index b3904b74c94..2d909b80b68 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -132,14 +132,16 @@ const ClientChannels = >(superclass: TBase getChannel = async (channelId: string) => { return this.doFetch( - `${this.getChannelRoute(channelId)}`, + this.getChannelRoute(channelId), {method: 'get'}, ); }; getChannelByName = async (teamId: string, channelName: string, includeDeleted = false) => { return this.doFetch( - `${this.getTeamRoute(teamId)}/channels/name/${channelName}?include_deleted=${includeDeleted}`, + `${this.getTeamRoute(teamId)}/channels/name/${channelName}${buildQueryString({ + include_deleted: includeDeleted, + })}`, {method: 'get'}, ); }; @@ -153,14 +155,20 @@ const ClientChannels = >(superclass: TBase getChannels = async (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { return this.doFetch( - `${this.getTeamRoute(teamId)}/channels${buildQueryString({page, per_page: perPage})}`, + `${this.getTeamRoute(teamId)}/channels${buildQueryString({ + page, + per_page: perPage, + })}`, {method: 'get'}, ); }; getArchivedChannels = async (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { return this.doFetch( - `${this.getTeamRoute(teamId)}/channels/deleted${buildQueryString({page, per_page: perPage})}`, + `${this.getTeamRoute(teamId)}/channels/deleted${buildQueryString({ + page, + per_page: perPage, + })}`, {method: 'get'}, ); }; @@ -172,11 +180,11 @@ const ClientChannels = >(superclass: TBase ); }; - getMyChannels = async (teamId: string, includeDeleted = false, lastDeleteAt = 0) => { + getMyChannels = async (teamId: string, includeDeleted = false, since = 0) => { return this.doFetch( `${this.getUserRoute('me')}/teams/${teamId}/channels${buildQueryString({ include_deleted: includeDeleted, - last_delete_at: lastDeleteAt, + last_delete_at: since, })}`, {method: 'get'}, ); diff --git a/app/client/rest/files.ts b/app/client/rest/files.ts index 036ec582fb2..d899acdf0cc 100644 --- a/app/client/rest/files.ts +++ b/app/client/rest/files.ts @@ -11,13 +11,14 @@ export interface ClientFilesMix { getFileThumbnailUrl: (fileId: string, timestamp: number) => string; getFilePreviewUrl: (fileId: string, timestamp: number) => string; getFilePublicLink: (fileId: string) => Promise<{link: string}>; - uploadPostAttachment: ( - file: FileInfo, + uploadAttachment: ( + file: FileInfo | ExtractedFileInfo, channelId: string, onProgress: (fractionCompleted: number, bytesRead?: number | null | undefined) => void, onComplete: (response: ClientResponse) => void, onError: (response: ClientResponseError) => void, skipBytes?: number, + isBookmark?: boolean, ) => () => void; searchFiles: (teamId: string, terms: string) => Promise; searchFilesWithParams: (teamId: string, FileSearchParams: string) => Promise; @@ -58,15 +59,19 @@ const ClientFiles = >(superclass: TBase) = ); }; - uploadPostAttachment = ( - file: FileInfo, + uploadAttachment = ( + file: FileInfo | ExtractedFileInfo, channelId: string, onProgress: (fractionCompleted: number, bytesRead?: number | null | undefined) => void, onComplete: (response: ClientResponse) => void, onError: (response: ClientResponseError) => void, skipBytes = 0, + isBookmark = false, ) => { - const url = this.getFilesRoute(); + let url = this.getFilesRoute(); + if (isBookmark) { + url = `${url}?bookmark=true`; + } const options: UploadRequestOptions = { skipBytes, method: 'POST', diff --git a/app/client/rest/index.ts b/app/client/rest/index.ts index 382afcbfd73..e023d8ca359 100644 --- a/app/client/rest/index.ts +++ b/app/client/rest/index.ts @@ -8,6 +8,7 @@ import mix from '@utils/mix'; import ClientApps, {type ClientAppsMix} from './apps'; import ClientBase from './base'; import ClientCategories, {type ClientCategoriesMix} from './categories'; +import ClientChannelBookmarks, {type ClientChannelBookmarksMix} from './channel_bookmark'; import ClientChannels, {type ClientChannelsMix} from './channels'; import {DEFAULT_LIMIT_AFTER, DEFAULT_LIMIT_BEFORE, HEADER_X_VERSION_ID} from './constants'; import ClientEmojis, {type ClientEmojisMix} from './emojis'; @@ -29,6 +30,7 @@ interface Client extends ClientBase, ClientAppsMix, ClientCategoriesMix, ClientChannelsMix, + ClientChannelBookmarksMix, ClientEmojisMix, ClientFilesMix, ClientGeneralMix, @@ -49,6 +51,7 @@ class Client extends mix(ClientBase).with( ClientApps, ClientCategories, ClientChannels, + ClientChannelBookmarks, ClientEmojis, ClientFiles, ClientGeneral, diff --git a/app/client/websocket/index.ts b/app/client/websocket/index.ts index 3070b0684c5..181e8ed9fd7 100644 --- a/app/client/websocket/index.ts +++ b/app/client/websocket/index.ts @@ -366,4 +366,8 @@ export default class WebSocketClient { public isConnected(): boolean { return this.conn?.readyState === WebSocketReadyState.OPEN; } + + public getConnectionId(): string { + return this.connectionId; + } } diff --git a/app/components/button/index.tsx b/app/components/button/index.tsx index 51f3059407e..0d9aa5f6836 100644 --- a/app/components/button/index.tsx +++ b/app/components/button/index.tsx @@ -3,10 +3,11 @@ import {Button as ElementButton} from '@rneui/base'; import React, {useMemo, type ReactNode} from 'react'; -import {type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle} from 'react-native'; +import {type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle, type Insets} from 'react-native'; import CompassIcon from '@components/compass_icon'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; +import {changeOpacity} from '@utils/theme'; type ConditionalProps = | {iconName: string; iconSize: number} | {iconName?: never; iconSize?: never} @@ -22,6 +23,8 @@ type Props = ConditionalProps & { onPress: () => void; text: string; iconComponent?: ReactNode; + disabled?: boolean; + hitSlop?: Insets; } const styles = StyleSheet.create({ @@ -43,6 +46,8 @@ const Button = ({ iconName, iconSize, iconComponent, + disabled, + hitSlop, }: Props) => { const bgStyle = useMemo(() => [ buttonBackgroundStyle(theme, size, emphasis, buttonType, buttonState), @@ -63,6 +68,14 @@ const Button = ({ [iconSize], ); + let buttonContainerStyle = StyleSheet.flatten(bgStyle); + if (disabled) { + buttonContainerStyle = { + ...buttonContainerStyle, + backgroundColor: changeOpacity(buttonContainerStyle.backgroundColor! as string, 0.4), + }; + } + let icon: ReactNode; if (iconComponent) { @@ -80,9 +93,11 @@ const Button = ({ return ( {icon} diff --git a/app/components/channel_bookmarks/add_bookmark.tsx b/app/components/channel_bookmarks/add_bookmark.tsx new file mode 100644 index 00000000000..2a71f4a6895 --- /dev/null +++ b/app/components/channel_bookmarks/add_bookmark.tsx @@ -0,0 +1,163 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {Alert, View, type Insets} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import Button from '@components/button'; +import {ITEM_HEIGHT} from '@components/option_item'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {TITLE_HEIGHT} from '@screens/bottom_sheet'; +import {bottomSheet, showModal} from '@screens/navigation'; +import {bottomSheetSnapPoint} from '@utils/helpers'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import CompassIcon from '../compass_icon'; + +import AddBookmarkOptions from './add_bookmark_options'; + +type Props = { + bookmarksCount: number; + canUploadFiles: boolean; + channelId: string; + currentUserId: string; + showLarge: boolean; +} + +const MAX_BOOKMARKS_PER_CHANNEL = 50; +const hitSlop: Insets = {top: 10, bottom: 10, left: 10, right: 10}; + +const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ + container: { + alignSelf: 'flex-start', + marginBottom: 16, + }, + largeButton: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + height: 32, + paddingHorizontal: 8, + paddingVertical: 0, + borderRadius: 16, + }, + largeButtonText: { + color: theme.centerChannelColor, + lineHeight: undefined, + marginTop: undefined, + ...typography('Body', 100, 'SemiBold'), + paddingRight: 6, + }, + largeButtonIcon: { + color: changeOpacity(theme.centerChannelColor, 0.56), + paddingRight: 4, + paddingTop: 3, + }, + smallButton: { + backgroundColor: undefined, + paddingHorizontal: undefined, + paddingVertical: undefined, + alignItems: 'flex-end', + justifyContent: 'center', + margin: undefined, + top: 3, + right: 0, + }, + smallButtonText: { + color: theme.centerChannelColor, + lineHeight: undefined, + marginTop: undefined, + ...typography('Body', 100, 'SemiBold'), + marginRight: undefined, + padding: undefined, + }, + smallButtonIcon: { + color: changeOpacity(theme.centerChannelColor, 0.56), + }, +})); + +const AddBookmark = ({bookmarksCount, channelId, currentUserId, canUploadFiles, showLarge}: Props) => { + const theme = useTheme(); + const {formatMessage} = useIntl(); + const {bottom} = useSafeAreaInsets(); + const styles = getStyleSheet(theme); + + const onPress = useCallback(() => { + if (bookmarksCount >= MAX_BOOKMARKS_PER_CHANNEL) { + Alert.alert( + formatMessage({id: 'channel_info.add_bookmark', defaultMessage: 'Add a bookmark'}), + formatMessage({ + id: 'channel_info.add_bookmark.max_reached', + defaultMessage: 'This channel has reached the maximum number of bookmarks ({count}).', + }, {count: MAX_BOOKMARKS_PER_CHANNEL}), + ); + return; + } + + if (!canUploadFiles) { + const title = formatMessage({id: 'screens.channel_bookmark_add', defaultMessage: 'Add a bookmark'}); + const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + const closeButtonId = 'close-channel-bookmark-add'; + + const options = { + topBar: { + leftButtons: [{ + id: closeButtonId, + icon: closeButton, + testID: 'close.channel_bookmark_add.button', + }], + }, + }; + showModal(Screens.CHANNEL_BOOKMARK, title, { + channelId, + closeButtonId, + type: 'link', + ownerId: currentUserId, + }, options); + return; + } + + const renderContent = () => ( + + ); + + bottomSheet({ + title: formatMessage({id: 'channel_info.add_bookmark', defaultMessage: 'Add a bookmark'}), + renderContent, + snapPoints: [1, bottomSheetSnapPoint(1, (2 * ITEM_HEIGHT), bottom) + TITLE_HEIGHT], + theme, + closeButtonId: 'close-channel-quick-actions', + }); + }, [bottom, bookmarksCount, canUploadFiles, currentUserId, channelId, theme]); + + const button = ( + + + ); +}; + +export default BookmarkDocument; diff --git a/app/components/channel_bookmarks/channel_bookmark/bookmark_icon.tsx b/app/components/channel_bookmarks/channel_bookmark/bookmark_icon.tsx new file mode 100644 index 00000000000..ef107052639 --- /dev/null +++ b/app/components/channel_bookmarks/channel_bookmark/bookmark_icon.tsx @@ -0,0 +1,68 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Image, type ImageStyle} from 'expo-image'; +import React, {useState, useCallback} from 'react'; +import {type StyleProp, type TextStyle, type ViewStyle} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import Emoji from '@components/emoji'; +import FileIcon from '@components/files/file_icon'; +import {useTheme} from '@context/theme'; + +type Props = { + emoji?: string; + emojiSize: number; + emojiStyle?: StyleProp; + file?: FileInfo | ExtractedFileInfo; + iconSize: number; + imageStyle?: StyleProp; + imageUrl?: string; + genericStyle: StyleProp; +} + +const BookmarkIcon = ({emoji, emojiSize, emojiStyle, file, genericStyle, iconSize, imageStyle, imageUrl}: Props) => { + const theme = useTheme(); + const [hasImageError, setHasImageError] = useState(false); + + const handleImageError = useCallback(() => { + setHasImageError(true); + }, []); + + if (file && !emoji && !hasImageError) { + return ( + + ); + } else if (imageUrl && !emoji && !hasImageError) { + return ( + + ); + } else if (emoji) { + return ( + + ); + } + + return ( + + ); +}; + +export default BookmarkIcon; diff --git a/app/components/channel_bookmarks/channel_bookmark/bookmark_options.tsx b/app/components/channel_bookmarks/channel_bookmark/bookmark_options.tsx new file mode 100644 index 00000000000..ae96992bb54 --- /dev/null +++ b/app/components/channel_bookmarks/channel_bookmark/bookmark_options.tsx @@ -0,0 +1,259 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Clipboard from '@react-native-clipboard/clipboard'; +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {Alert, Text, View} from 'react-native'; +import Share from 'react-native-share'; + +import {deleteChannelBookmark} from '@actions/remote/channel_bookmark'; +import {fetchPublicLink} from '@actions/remote/file'; +import CompassIcon from '@components/compass_icon'; +import OptionItem from '@components/option_item'; +import {Screens} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import DownloadWithAction from '@screens/gallery/footer/download_with_action'; +import {dismissBottomSheet, showModal, showOverlay} from '@screens/navigation'; +import {getFullErrorMessage} from '@utils/errors'; +import {isImage, isVideo} from '@utils/file'; +import {showSnackBar} from '@utils/snack_bar'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; +import type FileModel from '@typings/database/models/servers/file'; +import type {GalleryAction, GalleryFileType, GalleryItemType} from '@typings/screens/gallery'; + +type Props = { + bookmark: ChannelBookmarkModel; + canCopyPublicLink: boolean; + canDeleteBookmarks: boolean; + canDownloadFiles: boolean; + canEditBookmarks: boolean; + file?: FileModel; + setAction: React.Dispatch>; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + flex: {flex: 1}, + header: { + marginBottom: 12, + }, + headerText: { + color: theme.centerChannelColor, + ...typography('Heading', 600, 'SemiBold'), + }, +})); + +const ChannelBookmarkOptions = ({ + bookmark, + canCopyPublicLink, + canDeleteBookmarks, + canDownloadFiles, + canEditBookmarks, + file, + setAction, +}: Props) => { + const serverUrl = useServerUrl(); + const theme = useTheme(); + const isTablet = useIsTablet(); + const intl = useIntl(); + const styles = getStyleSheet(theme); + const canShare = canDownloadFiles || bookmark.type === 'link'; + + const isVideoFile = useMemo(() => isVideo(file), [file]); + const isImageFile = useMemo(() => isImage(file), [file]); + const galleryItem = useMemo(() => { + if (file) { + const fileInfo = file.toFileInfo(bookmark.ownerId); + let type: GalleryFileType = 'file'; + if (isImageFile) { + type = 'image'; + } else if (isVideoFile) { + type = 'video'; + } + const item: GalleryItemType = { + ...fileInfo, + id: fileInfo.id!, + type, + lastPictureUpdate: 0, + uri: '', + }; + + return item; + } + return null; + }, [bookmark, file]); + + const handleDelete = useCallback(async () => { + const {error} = await deleteChannelBookmark(serverUrl, bookmark.channelId, bookmark.id); + if (error) { + Alert.alert( + intl.formatMessage({id: 'channel_bookmark.delete.failed_title', defaultMessage: 'Error deleting bookmark'}), + intl.formatMessage({id: 'channel_bookmark.delete.failed_detail', defaultMessage: 'Details: {error}'}, { + error: getFullErrorMessage(error), + }), + ); + return; + } + + await dismissBottomSheet(); + }, [bookmark, intl, serverUrl]); + + const onCopy = useCallback(async () => { + await dismissBottomSheet(); + + if (bookmark.type === 'link' && bookmark.linkUrl) { + Clipboard.setString(bookmark.linkUrl); + showSnackBar({barType: 'LINK_COPIED'}); + return; + } + + try { + const publicLink = await fetchPublicLink(serverUrl, bookmark.fileId!); + if ('link' in publicLink) { + Clipboard.setString(publicLink.link); + showSnackBar({barType: 'LINK_COPIED'}); + } else { + showSnackBar({barType: 'LINK_COPY_FAILED'}); + } + } catch { + showSnackBar({barType: 'LINK_COPY_FAILED'}); + } + }, [bookmark, serverUrl]); + + const onDelete = useCallback(async () => { + Alert.alert( + intl.formatMessage({id: 'channel_bookmark.delete.confirm_title', defaultMessage: 'Delete bookmark'}), + intl.formatMessage({id: 'channel_bookmark.delete.confirm', defaultMessage: 'You sure want to delete the bookmark {displayName}?'}, { + displayName: bookmark.displayName, + }), + [{ + text: intl.formatMessage({id: 'channel_bookmark.delete.yes', defaultMessage: 'Yes'}), + style: 'destructive', + isPreferred: true, + onPress: handleDelete, + }, { + text: intl.formatMessage({id: 'channel_bookmark.add.file_cancel', defaultMessage: 'Cancel'}), + style: 'cancel', + }], + ); + }, [bookmark, handleDelete]); + + const onEdit = useCallback(async () => { + await dismissBottomSheet(); + + const title = intl.formatMessage({id: 'screens.channel_bookmark_edit', defaultMessage: 'Edit bookmark'}); + const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + const closeButtonId = 'close-channel_bookmark_edit'; + + const options = { + topBar: { + leftButtons: [{ + id: closeButtonId, + icon: closeButton, + testID: 'close.channel_bookmark_edit.button', + }], + }, + }; + + showModal(Screens.CHANNEL_BOOKMARK, title, { + bookmark: bookmark.toApi(), + canDeleteBookmarks, + channelId: bookmark.channelId, + closeButtonId, + file: file?.toFileInfo(bookmark.ownerId), + ownerId: bookmark.ownerId, + type: bookmark.type, + }, options); + }, [bookmark, canDeleteBookmarks, file, intl, theme]); + + const onShare = useCallback(async () => { + await dismissBottomSheet(); + + if (bookmark.type === 'file') { + if (file) { + setAction('sharing'); + showOverlay(Screens.GENERIC_OVERLAY, { + children: ( + + ), + }, {}, bookmark.id); + } + return; + } + + if (bookmark.type === 'link') { + const title = bookmark.displayName; + const url = bookmark.linkUrl!; + Share.open({ + title, + message: title, + url, + showAppsToView: true, + }).catch(() => { + // do nothing + }); + } + }, [bookmark, file, serverUrl, setAction]); + + return ( + <> + {!isTablet && ( + + + {bookmark.displayName} + + + )} + + {canEditBookmarks && + + } + {canCopyPublicLink && + + } + {canShare && + + } + {canDeleteBookmarks && + + } + + + ); +}; + +export default ChannelBookmarkOptions; diff --git a/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx b/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx new file mode 100644 index 00000000000..db6f07a3f94 --- /dev/null +++ b/app/components/channel_bookmarks/channel_bookmark/channel_bookmark.tsx @@ -0,0 +1,169 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useManagedConfig} from '@mattermost/react-native-emm'; +import {Button} from '@rneui/base'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {useIntl, type IntlShape} from 'react-intl'; +import {Alert, StyleSheet} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {ITEM_HEIGHT} from '@components/option_item'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useGalleryItem} from '@hooks/gallery'; +import {TITLE_HEIGHT} from '@screens/bottom_sheet'; +import {bottomSheet, dismissOverlay} from '@screens/navigation'; +import {handleDeepLink, matchDeepLink} from '@utils/deep_link'; +import {isDocument} from '@utils/file'; +import {bottomSheetSnapPoint} from '@utils/helpers'; +import {normalizeProtocol, tryOpenURL} from '@utils/url'; + +import BookmarkDetails from './bookmark_details'; +import BookmarkDocument from './bookmark_document'; +import ChannelBookmarkOptions from './bookmark_options'; + +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; +import type FileModel from '@typings/database/models/servers/file'; +import type {GalleryAction} from '@typings/screens/gallery'; + +type Props = { + bookmark: ChannelBookmarkModel; + canDeleteBookmarks: boolean; + canDownloadFiles: boolean; + canEditBookmarks: boolean; + file?: FileModel; + galleryIdentifier: string; + index?: number; + onPress?: (index: number) => void; + publicLinkEnabled: boolean; + siteURL: string; +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + paddingVertical: 6, + height: 48, + }, + button: {backgroundColor: 'transparent'}, +}); + +const openLink = async (href: string, serverUrl: string, siteURL: string, intl: IntlShape) => { + const url = normalizeProtocol(href); + if (!url) { + return; + } + + const match = matchDeepLink(url, serverUrl, siteURL); + + const onLinkError = () => { + Alert.alert( + intl.formatMessage({ + id: 'mobile.link.error.title', + defaultMessage: 'Error', + }), + intl.formatMessage({ + id: 'mobile.link.error.text', + defaultMessage: 'Unable to open the link.', + }), + ); + }; + + if (match) { + const {error} = await handleDeepLink(match.url, intl); + if (error) { + tryOpenURL(match.url, onLinkError); + } + } else { + tryOpenURL(url, onLinkError); + } +}; + +const ChannelBookmark = ({ + bookmark, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, + file, galleryIdentifier, index, onPress, publicLinkEnabled, siteURL, +}: Props) => { + const theme = useTheme(); + const managedConfig = useManagedConfig(); + const serverUrl = useServerUrl(); + const intl = useIntl(); + const {bottom} = useSafeAreaInsets(); + const [action, setAction] = useState('none'); + const isDocumentFile = useMemo(() => isDocument(file), [file]); + const canCopyPublicLink = Boolean((bookmark.type === 'link' || (file?.id && publicLinkEnabled)) && managedConfig.copyAndPasteProtection !== 'true'); + + const handlePress = useCallback(() => { + if (bookmark.linkUrl) { + openLink(bookmark.linkUrl, siteURL, serverUrl, intl); + return; + } + + onPress?.(index || 0); + }, [bookmark, index, intl, onPress, serverUrl, siteURL]); + + const handleLongPress = useCallback(() => { + const canShare = canDownloadFiles || bookmark.type === 'link'; + const count = [canCopyPublicLink, canDeleteBookmarks, canShare, canEditBookmarks]. + filter((e) => e).length; + + const renderContent = () => ( + + ); + + bottomSheet({ + title: bookmark.displayName, + renderContent, + snapPoints: [1, bottomSheetSnapPoint(1, (count * ITEM_HEIGHT), bottom) + TITLE_HEIGHT], + theme, + closeButtonId: 'close-channel-bookmark-actions', + }); + }, [bookmark, bottom, canCopyPublicLink, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, file, theme]); + + const {onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index || 0, handlePress); + + useEffect(() => { + if (action === 'none' && bookmark.id) { + dismissOverlay(bookmark.id); + } + }, [action]); + + if (isDocumentFile) { + return ( + + ); + } + + return ( + + ); +}; + +export default ChannelBookmark; diff --git a/app/components/channel_bookmarks/channel_bookmark/index.tsx b/app/components/channel_bookmarks/channel_bookmark/index.tsx new file mode 100644 index 00000000000..81833f8d90b --- /dev/null +++ b/app/components/channel_bookmarks/channel_bookmark/index.tsx @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {switchMap} from 'rxjs'; + +import {observeFileById} from '@queries/servers/file'; +import {observeConfigValue} from '@queries/servers/system'; + +import ChannelBookmark from './channel_bookmark'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; + +type Props = WithDatabaseArgs & { + bookmark: ChannelBookmarkModel; +} + +const enhanced = withObservables([], ({bookmark, database}: Props) => { + const observed = bookmark.observe(); + const file = observed.pipe( + switchMap((b) => observeFileById(database, b.fileId || '')), + ); + + return { + bookmark: observed, + file, + siteURL: observeConfigValue(database, 'SiteURL'), + }; +}); + +export default withDatabase(enhanced(ChannelBookmark)); diff --git a/app/components/channel_bookmarks/channel_bookmarks.tsx b/app/components/channel_bookmarks/channel_bookmarks.tsx new file mode 100644 index 00000000000..2812c3cc065 --- /dev/null +++ b/app/components/channel_bookmarks/channel_bookmarks.tsx @@ -0,0 +1,179 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {LinearGradient} from 'expo-linear-gradient'; +import React, {useCallback, useMemo, useState} from 'react'; +import {FlatList, View, type ListRenderItemInfo, type NativeSyntheticEvent, type NativeScrollEvent} from 'react-native'; +import Animated from 'react-native-reanimated'; + +import {GalleryInit} from '@context/gallery'; +import {useTheme} from '@context/theme'; +import {useChannelBookmarkFiles} from '@hooks/files'; +import ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; +import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import AddBookmark from './add_bookmark'; +import ChannelBookmark from './channel_bookmark'; + +type Props = { + bookmarks: ChannelBookmarkModel[]; + canAddBookmarks: boolean; + canDeleteBookmarks: boolean; + canDownloadFiles: boolean; + canEditBookmarks: boolean; + canUploadFiles: boolean; + channelId: string; + currentUserId: string; + publicLinkEnabled: boolean; + showInInfo: boolean; + separator?: boolean; +} + +const GRADIENT_LOCATIONS = [0, 0.64, 1]; +const SCROLL_OFFSET = 10; + +const isCloseToBottom = ({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) => { + return layoutMeasurement.width + contentOffset.x <= contentSize.width - SCROLL_OFFSET; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flexDirection: 'row', + }, + separator: { + height: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + marginTop: 8, + marginBottom: 24, + }, + emptyItemSeparator: { + width: 20, + }, + addContainer: { + width: 40, + alignContent: 'center', + }, + gradient: { + height: 48, + width: 68, + position: 'absolute', + right: 0, + }, + channelView: { + paddingHorizontal: 8, + }, +})); + +const ChannelBookmarks = ({ + bookmarks, canAddBookmarks, canDeleteBookmarks, canDownloadFiles, canEditBookmarks, canUploadFiles, + channelId, currentUserId, publicLinkEnabled, showInInfo, separator = true, +}: Props) => { + const galleryIdentifier = `${channelId}-bookmarks`; + const theme = useTheme(); + const styles = getStyleSheet(theme); + const files = useChannelBookmarkFiles(bookmarks, publicLinkEnabled); + const [allowEndFade, setAllowEndFade] = useState(true); + + const attachmentIndex = useCallback((fileId: string) => { + return files.findIndex((file) => file.id === fileId) || 0; + }, [files]); + + const handlePreviewPress = useCallback(preventDoubleTap((idx: number) => { + if (files.length) { + const items = files.map((f) => fileToGalleryItem(f, f.user_id)); + openGalleryAtIndex(galleryIdentifier, idx, items); + } + }), [files]); + + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + return ( + + ); + }, [ + attachmentIndex, bookmarks, canDownloadFiles, canDeleteBookmarks, canEditBookmarks, + handlePreviewPress, publicLinkEnabled, + ]); + + const renderItemSeparator = useCallback(() => (), []); + + const onScrolled = useCallback((e: NativeSyntheticEvent) => { + setAllowEndFade(isCloseToBottom(e.nativeEvent)); + }, []); + + const gradientColors = useMemo(() => [ + theme.centerChannelBg, + changeOpacity(theme.centerChannelBg, 0.6458), + changeOpacity(theme.centerChannelBg, 0), + ], [theme]); + + if (!bookmarks.length && showInInfo && canAddBookmarks) { + return ( + + ); + } + + if (bookmarks.length) { + return ( + + + + + {canAddBookmarks && + + {allowEndFade && + + } + + + } + + {separator && + + } + + + ); + } + + return null; +}; + +export default ChannelBookmarks; diff --git a/app/components/channel_bookmarks/index.ts b/app/components/channel_bookmarks/index.ts new file mode 100644 index 00000000000..1248da1864a --- /dev/null +++ b/app/components/channel_bookmarks/index.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; + +import {observeBookmarks, observeCanDeleteBookmarks, observeCanEditBookmarks} from '@queries/servers/channel_bookmark'; +import {observeCanDownloadFiles, observeCanUploadFiles, observeConfigBooleanValue, observeCurrentUserId} from '@queries/servers/system'; + +import ChannelBookmarks from './channel_bookmarks'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type Props = WithDatabaseArgs & { + channelId: string; +} + +const enhanced = withObservables([], ({channelId, database}: Props) => { + return { + bookmarks: observeBookmarks(database, channelId), + canDeleteBookmarks: observeCanDeleteBookmarks(database, channelId), + canDownloadFiles: observeCanDownloadFiles(database), + canEditBookmarks: observeCanEditBookmarks(database, channelId), + canUploadFiles: observeCanUploadFiles(database), + currentUserId: observeCurrentUserId(database), + publicLinkEnabled: observeConfigBooleanValue(database, 'EnablePublicLink'), + }; +}); + +export default withDatabase(enhanced(ChannelBookmarks)); diff --git a/app/components/document/index.tsx b/app/components/document/index.tsx new file mode 100644 index 00000000000..66755634484 --- /dev/null +++ b/app/components/document/index.tsx @@ -0,0 +1,168 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {deleteAsync} from 'expo-file-system'; +import {forwardRef, useImperativeHandle, useRef, useState, type ReactNode, useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {Platform, StatusBar, type StatusBarStyle} from 'react-native'; +import FileViewer from 'react-native-file-viewer'; +import tinyColor from 'tinycolor2'; + +import {downloadFile} from '@actions/remote/file'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {alertDownloadDocumentDisabled, alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document'; +import {getFullErrorMessage, isErrorWithMessage} from '@utils/errors'; +import {fileExists, getLocalFilePathFromFile} from '@utils/file'; +import {logDebug} from '@utils/log'; + +import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client'; + +export type DocumentRef = { + handlePreviewPress: () => void; +} + +type DocumentProps = { + canDownloadFiles: boolean; + file: FileInfo; + children: ReactNode; + onProgress: (progress: number) => void; +} + +const Document = forwardRef(({canDownloadFiles, children, onProgress, file}: DocumentProps, ref) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + const theme = useTheme(); + const [didCancel, setDidCancel] = useState(false); + const [downloading, setDownloading] = useState(false); + const [preview, setPreview] = useState(false); + const downloadTask = useRef>(); + + const cancelDownload = () => { + setDidCancel(true); + if (downloadTask.current?.cancel) { + downloadTask.current.cancel(); + } + }; + + const downloadAndPreviewFile = useCallback(async () => { + setDidCancel(false); + let path; + let exists = false; + + try { + path = decodeURIComponent(file.localPath || ''); + if (path) { + exists = await fileExists(path); + } + + if (!exists) { + path = getLocalFilePathFromFile(serverUrl, file); + exists = await fileExists(path); + } + + if (exists) { + openDocument(); + } else { + setDownloading(true); + downloadTask.current = downloadFile(serverUrl, file.id!, path!); + downloadTask.current?.progress?.(onProgress); + + await downloadTask.current; + onProgress(1); + openDocument(); + } + } catch (error) { + if (path) { + deleteAsync(path, {idempotent: true}); + } + setDownloading(false); + onProgress(0); + + if (!isErrorWithMessage(error) || error.message !== 'cancelled') { + logDebug('error on downloadAndPreviewFile', getFullErrorMessage(error)); + alertDownloadFailed(intl); + } + } + }, [file, onProgress]); + + const setStatusBarColor = useCallback((style: StatusBarStyle = 'light-content') => { + if (Platform.OS === 'ios') { + if (style) { + StatusBar.setBarStyle(style, true); + } else { + const headerColor = tinyColor(theme.sidebarHeaderBg); + let barStyle: StatusBarStyle = 'light-content'; + if (headerColor.isLight() && Platform.OS === 'ios') { + barStyle = 'dark-content'; + } + StatusBar.setBarStyle(barStyle, true); + } + } + }, [theme]); + + const openDocument = useCallback(async () => { + if (!didCancel && !preview) { + let path = decodeURIComponent(file.localPath || ''); + let exists = false; + if (path) { + exists = await fileExists(path); + } + + if (!exists) { + path = getLocalFilePathFromFile(serverUrl, file); + } + + setPreview(true); + setStatusBarColor('dark-content'); + FileViewer.open(path!.replace('file://', ''), { + displayName: decodeURIComponent(file.name), + onDismiss: onDonePreviewingFile, + showOpenWithDialog: true, + showAppsSuggestions: true, + }).then(() => { + setDownloading(false); + onProgress(0); + }).catch(() => { + alertFailedToOpenDocument(file, intl); + onDonePreviewingFile(); + + if (path) { + deleteAsync(path, {idempotent: true}); + } + }); + } + }, [didCancel, preview, file, onProgress, setStatusBarColor]); + + const handlePreviewPress = useCallback(async () => { + if (!canDownloadFiles) { + alertDownloadDocumentDisabled(intl); + return; + } + + if (downloading) { + onProgress(0); + cancelDownload(); + setDownloading(false); + } else { + downloadAndPreviewFile(); + } + }, [canDownloadFiles, downloadAndPreviewFile, downloading, intl, onProgress, openDocument]); + + const onDonePreviewingFile = () => { + onProgress(0); + setDownloading(false); + setPreview(false); + setStatusBarColor(); + }; + + useImperativeHandle(ref, () => ({ + handlePreviewPress, + }), [handlePreviewPress]); + + return children; +}); + +Document.displayName = 'Document'; + +export default Document; diff --git a/app/components/files/document_file.tsx b/app/components/files/document_file.tsx index 1d5763c1ab1..dbf65bdb7fc 100644 --- a/app/components/files/document_file.tsx +++ b/app/components/files/document_file.tsx @@ -1,34 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {deleteAsync} from 'expo-file-system'; import React, {forwardRef, useImperativeHandle, useRef, useState} from 'react'; -import {useIntl} from 'react-intl'; -import {Platform, StatusBar, type StatusBarStyle, StyleSheet, TouchableOpacity, View} from 'react-native'; -import FileViewer from 'react-native-file-viewer'; -import tinyColor from 'tinycolor2'; +import {StyleSheet, TouchableOpacity, View} from 'react-native'; +import Document, {type DocumentRef} from '@components/document'; import ProgressBar from '@components/progress_bar'; -import {DOWNLOAD_TIMEOUT} from '@constants/network'; -import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import NetworkManager from '@managers/network_manager'; -import {alertDownloadDocumentDisabled, alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document'; -import {getFullErrorMessage, isErrorWithMessage} from '@utils/errors'; -import {fileExists, getLocalFilePathFromFile} from '@utils/file'; -import {logDebug} from '@utils/log'; import FileIcon from './file_icon'; -import type {Client} from '@client/rest'; -import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client'; - -export type DocumentFileRef = { - handlePreviewPress: () => void; -} - type DocumentFileProps = { backgroundColor?: string; + disabled?: boolean; canDownloadFiles: boolean; file: FileInfo; } @@ -43,122 +27,13 @@ const styles = StyleSheet.create({ }, }); -const DocumentFile = forwardRef(({backgroundColor, canDownloadFiles, file}: DocumentFileProps, ref) => { - const intl = useIntl(); - const serverUrl = useServerUrl(); +const DocumentFile = forwardRef(({backgroundColor, canDownloadFiles, disabled = false, file}: DocumentFileProps, ref) => { const theme = useTheme(); - const [didCancel, setDidCancel] = useState(false); - const [downloading, setDownloading] = useState(false); - const [preview, setPreview] = useState(false); const [progress, setProgress] = useState(0); - let client: Client | undefined; - try { - client = NetworkManager.getClient(serverUrl); - } catch { - // do nothing - } - const downloadTask = useRef>(); - - const cancelDownload = () => { - setDidCancel(true); - if (downloadTask.current?.cancel) { - downloadTask.current.cancel(); - } - }; - - const downloadAndPreviewFile = async () => { - setDidCancel(false); - let path; - - try { - path = getLocalFilePathFromFile(serverUrl, file); - const exists = await fileExists(path); - if (exists) { - openDocument(); - } else { - setDownloading(true); - downloadTask.current = client?.apiClient.download(client?.getFileRoute(file.id!), path!.replace('file://', ''), {timeoutInterval: DOWNLOAD_TIMEOUT}); - downloadTask.current?.progress?.(setProgress); - - await downloadTask.current; - setProgress(1); - openDocument(); - } - } catch (error) { - if (path) { - deleteAsync(path, {idempotent: true}); - } - setDownloading(false); - setProgress(0); - - if (!isErrorWithMessage(error) || error.message !== 'cancelled') { - logDebug('error on downloadAndPreviewFile', getFullErrorMessage(error)); - alertDownloadFailed(intl); - } - } - }; + const document = useRef(null); const handlePreviewPress = async () => { - if (!canDownloadFiles) { - alertDownloadDocumentDisabled(intl); - return; - } - - if (downloading && progress < 1) { - cancelDownload(); - } else if (downloading) { - setProgress(0); - setDidCancel(true); - setDownloading(false); - } else { - downloadAndPreviewFile(); - } - }; - - const onDonePreviewingFile = () => { - setProgress(0); - setDownloading(false); - setPreview(false); - setStatusBarColor(); - }; - - const openDocument = () => { - if (!didCancel && !preview) { - const path = getLocalFilePathFromFile(serverUrl, file); - setPreview(true); - setStatusBarColor('dark-content'); - FileViewer.open(path!, { - displayName: file.name, - onDismiss: onDonePreviewingFile, - showOpenWithDialog: true, - showAppsSuggestions: true, - }).then(() => { - setDownloading(false); - setProgress(0); - }).catch(() => { - alertFailedToOpenDocument(file, intl); - onDonePreviewingFile(); - - if (path) { - deleteAsync(path, {idempotent: true}); - } - }); - } - }; - - const setStatusBarColor = (style: StatusBarStyle = 'light-content') => { - if (Platform.OS === 'ios') { - if (style) { - StatusBar.setBarStyle(style, true); - } else { - const headerColor = tinyColor(theme.sidebarHeaderBg); - let barStyle: StatusBarStyle = 'light-content'; - if (headerColor.isLight() && Platform.OS === 'ios') { - barStyle = 'dark-content'; - } - StatusBar.setBarStyle(barStyle, true); - } - } + document.current?.handlePreviewPress(); }; useImperativeHandle(ref, () => ({ @@ -173,13 +48,13 @@ const DocumentFile = forwardRef(({background ); let fileAttachmentComponent = icon; - if (downloading) { + if (progress) { fileAttachmentComponent = ( <> {icon} @@ -188,9 +63,19 @@ const DocumentFile = forwardRef(({background } return ( - - {fileAttachmentComponent} - + + + {fileAttachmentComponent} + + ); }); diff --git a/app/components/files/file.tsx b/app/components/files/file.tsx index 4f3e8efa6c1..bd3ce0d4083 100644 --- a/app/components/files/file.tsx +++ b/app/components/files/file.tsx @@ -11,7 +11,7 @@ import {useGalleryItem} from '@hooks/gallery'; import {isDocument, isImage, isVideo} from '@utils/file'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import DocumentFile, {type DocumentFileRef} from './document_file'; +import DocumentFile from './document_file'; import FileIcon from './file_icon'; import FileInfo from './file_info'; import FileOptionsIcon from './file_options_icon'; @@ -19,6 +19,8 @@ import ImageFile from './image_file'; import ImageFileOverlay from './image_file_overlay'; import VideoFile from './video_file'; +import type {DocumentRef} from '@components/document'; + type FileProps = { canDownloadFiles: boolean; file: FileInfo; @@ -36,6 +38,7 @@ type FileProps = { showDate?: boolean; updateFileForGallery: (idx: number, file: FileInfo) => void; asCard?: boolean; + isPressDisabled?: boolean; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -79,8 +82,9 @@ const File = ({ showDate = false, updateFileForGallery, wrapperWidth = 300, + isPressDisabled = false, }: FileProps) => { - const document = useRef(null); + const document = useRef(null); const theme = useTheme(); const style = getStyleSheet(theme); @@ -101,6 +105,7 @@ const File = ({ const renderCardWithImage = (fileIcon: JSX.Element) => { const fileInfo = ( + + @@ -184,6 +196,7 @@ const File = ({ const fileInfo = ( { }; }); -const FileInfo = ({file, channelName, showDate, onPress}: FileInfoProps) => { +const FileInfo = ({disabled, file, channelName, showDate, onPress}: FileInfoProps) => { const theme = useTheme(); const style = getStyleSheet(theme); + return ( - + - {file.name.trim()} + {decodeURIComponent(file.name.trim())} {channelName && diff --git a/app/components/files/image_file.tsx b/app/components/files/image_file.tsx index 9c95945d28f..c604ef7cb92 100644 --- a/app/components/files/image_file.tsx +++ b/app/components/files/image_file.tsx @@ -5,7 +5,7 @@ import {LinearGradient} from 'expo-linear-gradient'; import React, {useCallback, useMemo, useState} from 'react'; import {StyleSheet, useWindowDimensions, View} from 'react-native'; -import {buildFilePreviewUrl, buildFileThumbnailUrl} from '@actions/remote/file'; +import {buildFilePreviewUrl, buildFileThumbnailUrl, buildFileUrl} from '@actions/remote/file'; import CompassIcon from '@components/compass_icon'; import ProgressiveImage from '@components/progressive_image'; import {useServerUrl} from '@context/server'; @@ -99,12 +99,17 @@ const ImageFile = ({ } else if (file.id) { if (file.mini_preview && file.mime_type) { props.thumbnailUri = `data:${file.mime_type};base64,${file.mini_preview}`; - } else { + } else if (file.has_preview_image) { props.thumbnailUri = buildFileThumbnailUrl(serverUrl, file.id); } - props.imageUri = buildFilePreviewUrl(serverUrl, file.id); + if (file.has_preview_image) { + props.imageUri = buildFilePreviewUrl(serverUrl, file.id); + } else { + props.imageUri = buildFileUrl(serverUrl, file.id, file.update_at); + } props.inViewPort = inViewPort; } + return props; }; diff --git a/app/components/floating_text_input_label/index.tsx b/app/components/floating_text_input_label/index.tsx index 7f8a91b3dd1..69914bc28dd 100644 --- a/app/components/floating_text_input_label/index.tsx +++ b/app/components/floating_text_input_label/index.tsx @@ -59,7 +59,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ maxWidth: 315, }, readOnly: { - backgroundColor: changeOpacity(theme.centerChannelBg, 0.16), + backgroundColor: changeOpacity(theme.centerChannelColor, 0.16), }, smallLabel: { fontSize: 10, diff --git a/app/components/post_list/more_messages/more_messages.tsx b/app/components/post_list/more_messages/more_messages.tsx index e98632e898a..cd18f4cb0e1 100644 --- a/app/components/post_list/more_messages/more_messages.tsx +++ b/app/components/post_list/more_messages/more_messages.tsx @@ -13,6 +13,7 @@ import FormattedText from '@components/formatted_text'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import {Events} from '@constants'; import {useServerUrl} from '@context/server'; +import {useIsTablet} from '@hooks/device'; import useDidUpdate from '@hooks/did_update'; import EphemeralStore from '@store/ephemeral_store'; import {makeStyleSheetFromTheme, hexToHue, changeOpacity} from '@utils/theme'; @@ -116,6 +117,7 @@ const MoreMessages = ({ }: Props) => { const serverUrl = useServerUrl(); const insets = useSafeAreaInsets(); + const isTablet = useIsTablet(); const pressed = useRef(false); const resetting = useRef(false); const initialScroll = useRef(false); @@ -127,7 +129,7 @@ const MoreMessages = ({ const callsAdjustment = useCallsAdjustment(serverUrl, channelId); // The final top: - const adjustedTop = insets.top + callsAdjustment; + const adjustedTop = (isTablet ? 0 : insets.top) + callsAdjustment; const BARS_FACTOR = Math.abs((1) / (HIDDEN_TOP - SHOWN_TOP)); diff --git a/app/constants/database.ts b/app/constants/database.ts index d302c280af7..428eb690d06 100644 --- a/app/constants/database.ts +++ b/app/constants/database.ts @@ -13,6 +13,7 @@ export const MM_TABLES = { CATEGORY: 'Category', CATEGORY_CHANNEL: 'CategoryChannel', CHANNEL: 'Channel', + CHANNEL_BOOKMARK: 'ChannelBookmark', CHANNEL_INFO: 'ChannelInfo', CHANNEL_MEMBERSHIP: 'ChannelMembership', CONFIG: 'Config', diff --git a/app/constants/permissions.ts b/app/constants/permissions.ts index 1b86379574f..22ff91433bf 100644 --- a/app/constants/permissions.ts +++ b/app/constants/permissions.ts @@ -153,4 +153,10 @@ export default { }, MANAGE_BOTS: 'manage_bots', MANAGE_OTHERS_BOTS: 'manage_others_bots', + ADD_BOOKMARK_PUBLIC_CHANNEL: 'add_bookmark_public_channel', + ADD_BOOKMARK_PRIVATE_CHANNEL: 'add_bookmark_private_channel', + EDIT_BOOKMARK_PUBLIC_CHANNEL: 'edit_bookmark_public_channel', + EDIT_BOOKMARK_PRIVATE_CHANNEL: 'edit_bookmark_private_channel', + DELETE_BOOKMARK_PUBLIC_CHANNEL: 'delete_bookmark_public_channel', + DELETE_BOOKMARK_PRIVATE_CHANNEL: 'delete_bookmark_private_channel', }; diff --git a/app/constants/screens.ts b/app/constants/screens.ts index a65396577da..8b4496c958a 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -75,6 +75,8 @@ export const THREAD = 'Thread'; export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton'; export const THREAD_OPTIONS = 'ThreadOptions'; export const USER_PROFILE = 'UserProfile'; +export const CHANNEL_BOOKMARK = 'ChannelBookmarkAddOrEdit'; +export const GENERIC_OVERLAY = 'GenericOverlay'; export default { ABOUT, @@ -87,6 +89,7 @@ export default { CALL_HOST_CONTROLS, CHANNEL, CHANNEL_ADD_MEMBERS, + CHANNEL_BOOKMARK, CHANNEL_FILES, CHANNEL_INFO, CHANNEL_NOTIFICATION_PREFERENCES, @@ -151,6 +154,7 @@ export default { THREAD_FOLLOW_BUTTON, THREAD_OPTIONS, USER_PROFILE, + GENERIC_OVERLAY, } as const; export const MODAL_SCREENS_WITHOUT_BACK = new Set([ @@ -174,6 +178,7 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set([ PERMALINK, REVIEW_APP, SNACK_BAR, + GENERIC_OVERLAY, ]); export const SCREENS_AS_BOTTOM_SHEET = new Set([ diff --git a/app/constants/snack_bar.ts b/app/constants/snack_bar.ts index 57491b93fc2..f36053ab00a 100644 --- a/app/constants/snack_bar.ts +++ b/app/constants/snack_bar.ts @@ -10,6 +10,7 @@ export const SNACK_BAR_TYPE = keyMirror({ FOLLOW_THREAD: null, INFO_COPIED: null, LINK_COPIED: null, + LINK_COPY_FAILED: null, MESSAGE_COPIED: null, MUTE_CHANNEL: null, REMOVE_CHANNEL_USER: null, @@ -65,6 +66,13 @@ export const SNACK_BAR_CONFIG: Record = { canUndo: false, type: MESSAGE_TYPE.SUCCESS, }, + LINK_COPY_FAILED: { + id: t('gallery.copy_link.failed'), + defaultMessage: 'Failed to copy link to clipboard', + iconName: 'link-variant', + canUndo: false, + type: MESSAGE_TYPE.ERROR, + }, MESSAGE_COPIED: { id: t('snack.bar.message.copied'), defaultMessage: 'Text copied to clipboard', diff --git a/app/constants/view.ts b/app/constants/view.ts index 50ee021a0d0..d42ab69148b 100644 --- a/app/constants/view.ts +++ b/app/constants/view.ts @@ -27,6 +27,7 @@ export const CALL_ERROR_BAR_HEIGHT = 52; export const CALL_NOTIFICATION_BAR_HEIGHT = 40; export const ANNOUNCEMENT_BAR_HEIGHT = 40; +export const BOOKMARKS_BAR_HEIGHT = 48; export const HOME_PADDING = { paddingLeft: 18, diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 100773417f0..d4034e213b7 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -100,5 +100,9 @@ const WebsocketEvents = { GROUP_DISSOCIATED_TO_TEAM: 'received_group_not_associated_to_team', GROUP_ASSOCIATED_TO_CHANNEL: 'received_group_associated_to_channel', GROUP_DISSOCIATED_TO_CHANNEL: 'received_group_not_associated_to_channel', + CHANNEL_BOOKMARK_CREATED: 'channel_bookmark_created', + CHANNEL_BOOKMARK_UPDATED: 'channel_bookmark_updated', + CHANNEL_BOOKMARK_SORTED: 'channel_bookmark_sorted', + CHANNEL_BOOKMARK_DELETED: 'channel_bookmark_deleted', }; export default WebsocketEvents; diff --git a/app/database/manager/__mocks__/index.ts b/app/database/manager/__mocks__/index.ts index 8c9d2eada8c..a58b052eaa3 100644 --- a/app/database/manager/__mocks__/index.ts +++ b/app/database/manager/__mocks__/index.ts @@ -11,7 +11,7 @@ import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; import AppDatabaseMigrations from '@database/migration/app'; import ServerDatabaseMigrations from '@database/migration/server'; import {InfoModel, GlobalModel, ServersModel} from '@database/models/app'; -import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, +import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, @@ -47,7 +47,7 @@ class DatabaseManager { constructor() { this.appModels = [InfoModel, GlobalModel, ServersModel]; this.serverModels = [ - CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, + CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, diff --git a/app/database/manager/index.ts b/app/database/manager/index.ts index 8d029910f80..e5edf058b0f 100644 --- a/app/database/manager/index.ts +++ b/app/database/manager/index.ts @@ -12,7 +12,7 @@ import {DatabaseType, MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; import AppDatabaseMigrations from '@database/migration/app'; import ServerDatabaseMigrations from '@database/migration/server'; import {InfoModel, GlobalModel, ServersModel} from '@database/models/app'; -import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, +import {CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, @@ -44,7 +44,7 @@ class DatabaseManager { constructor() { this.appModels = [InfoModel, GlobalModel, ServersModel]; this.serverModels = [ - CategoryModel, CategoryChannelModel, ChannelModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, + CategoryModel, CategoryChannelModel, ChannelModel, ChannelBookmarkModel, ChannelInfoModel, ChannelMembershipModel, ConfigModel, CustomEmojiModel, DraftModel, FileModel, GroupModel, GroupChannelModel, GroupTeamModel, GroupMembershipModel, MyChannelModel, MyChannelSettingsModel, MyTeamModel, PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel, diff --git a/app/database/migration/server/index.ts b/app/database/migration/server/index.ts index 6fc926695c0..73a2a34cd49 100644 --- a/app/database/migration/server/index.ts +++ b/app/database/migration/server/index.ts @@ -4,13 +4,37 @@ // NOTE : To implement migration, please follow this document // https://nozbe.github.io/WatermelonDB/Advanced/Migrations.html -import {addColumns, schemaMigrations} from '@nozbe/watermelondb/Schema/migrations'; +import {addColumns, createTable, schemaMigrations} from '@nozbe/watermelondb/Schema/migrations'; import {MM_TABLES} from '@constants/database'; -const {CHANNEL_INFO, DRAFT, POST} = MM_TABLES.SERVER; +const {CHANNEL_BOOKMARK, CHANNEL_INFO, DRAFT, POST} = MM_TABLES.SERVER; export default schemaMigrations({migrations: [ + { + toVersion: 4, + steps: [ + createTable({ + name: CHANNEL_BOOKMARK, + columns: [ + {name: 'create_at', type: 'number'}, + {name: 'update_at', type: 'number'}, + {name: 'delete_at', type: 'number'}, + {name: 'channel_id', type: 'string', isIndexed: true}, + {name: 'owner_id', type: 'string'}, + {name: 'file_id', type: 'string', isOptional: true}, + {name: 'display_name', type: 'string'}, + {name: 'sort_order', type: 'number'}, + {name: 'link_url', type: 'string', isOptional: true}, + {name: 'image_url', type: 'string', isOptional: true}, + {name: 'emoji', type: 'string', isOptional: true}, + {name: 'type', type: 'string'}, + {name: 'original_id', type: 'string', isOptional: true}, + {name: 'parent_id', type: 'string', isOptional: true}, + ], + }), + ], + }, { toVersion: 3, steps: [ diff --git a/app/database/models/server/channel.ts b/app/database/models/server/channel.ts index 80c40fcfd4d..420e19b5a0b 100644 --- a/app/database/models/server/channel.ts +++ b/app/database/models/server/channel.ts @@ -9,6 +9,7 @@ import {MM_TABLES} from '@constants/database'; import type {Query, Relation} from '@nozbe/watermelondb'; import type CategoryChannelModel from '@typings/database/models/servers/category_channel'; import type ChannelModelInterface from '@typings/database/models/servers/channel'; +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; import type DraftModel from '@typings/database/models/servers/draft'; @@ -21,6 +22,7 @@ import type UserModel from '@typings/database/models/servers/user'; const { CATEGORY_CHANNEL, CHANNEL, + CHANNEL_BOOKMARK, CHANNEL_INFO, CHANNEL_MEMBERSHIP, DRAFT, @@ -41,6 +43,9 @@ export default class ChannelModel extends Model implements ChannelModelInterface /** associations : Describes every relationship to this table. */ static associations: Associations = { + /** A CHANNEL can be associated with multiple CHANNEL_BOOKMARK (relationship is 1:N) */ + [CHANNEL_BOOKMARK]: {type: 'has_many', foreignKey: 'channel_id'}, + /** A CHANNEL can be associated with multiple CHANNEL_MEMBERSHIP (relationship is 1:N) */ [CHANNEL_MEMBERSHIP]: {type: 'has_many', foreignKey: 'channel_id'}, @@ -109,6 +114,9 @@ export default class ChannelModel extends Model implements ChannelModelInterface /** drafts : All drafts for this channel */ @children(DRAFT) drafts!: Query; + /** bookmarks : All bookmarks for this channel */ + @children(CHANNEL_BOOKMARK) bookmarks!: Query; + /** posts : All posts made in that channel */ @children(POST) posts!: Query; diff --git a/app/database/models/server/channel_bookmark.ts b/app/database/models/server/channel_bookmark.ts new file mode 100644 index 00000000000..084b23dae23 --- /dev/null +++ b/app/database/models/server/channel_bookmark.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {field, immutableRelation} from '@nozbe/watermelondb/decorators'; +import Model, {type Associations} from '@nozbe/watermelondb/Model'; + +import {MM_TABLES} from '@constants/database'; + +import type {Relation} from '@nozbe/watermelondb'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type ChannelBookmarkInterface from '@typings/database/models/servers/channel_bookmark'; +import type FileModel from '@typings/database/models/servers/file'; +import type UserModel from '@typings/database/models/servers/user'; + +const { + CHANNEL, + CHANNEL_BOOKMARK, + FILE, + USER, +} = MM_TABLES.SERVER; + +/** + * The Channel model represents a channel in the Mattermost app. + */ +export default class ChannelBookmarkModel extends Model implements ChannelBookmarkInterface { + /** table (name) : Channel */ + static table = CHANNEL_BOOKMARK; + + /** associations : Describes every relationship to this table. */ + static associations: Associations = { + + /** A CHANNEL can be associated to CHANNEL_BOOKMARK (relationship is 1:N) */ + [CHANNEL]: {type: 'belongs_to', key: 'channel_id'}, + + /** A USER can create multiple CHANNEL_BOOKMARK (relationship is 1:N) */ + [USER]: {type: 'belongs_to', key: 'owner_id'}, + + /** A FILE is associated with one CHANNEL_BOOKMARK**/ + [FILE]: {type: 'has_many', foreignKey: 'file_id'}, + + }; + + /** create_at : The creation date for this channel bookmark */ + @field('create_at') createAt!: number; + + /** update_at : The timestamp to when this channel bookmark was last updated on the server */ + @field('update_at') updateAt!: number; + + /** delete_at : The deletion/archived date of this channel bookmark */ + @field('delete_at') deleteAt!: number; + + /** channel_id : The channel to which this bookmarks belongs */ + @field('channel_id') channelId!: string; + + /** owner_id : The user who created this channel bookmark */ + @field('owner_id') ownerId!: string; + + /** file_id : The file attached this channel bookmark */ + @field('file_id') fileId?: string; + + /** display_name : The channel bookmark display name (e.g. Important document ) */ + @field('display_name') displayName!: string; + + /** sort_order : the order in which the bookmark is displayed in the UI. */ + @field('sort_order') sortOrder!: number; + + /** link_url : The channel bookmark url if of type link */ + @field('link_url') linkUrl?: string; + + /** image_url : The channel bookmark image url if of type link (optional) */ + @field('image_url') imageUrl?: string; + + /** emoji : The channel bookmark emoji (optional) */ + @field('emoji') emoji?: string; + + /** type : The channel bookmark type it can be link or file */ + @field('type') type!: ChannelBookmarkType; + + /** original_id : The channel bookmark original identifier before it was edited */ + @field('original_id') originalId?: string; + + /** parent_id : The channel bookmark parent in case is nested */ + @field('parent_id') parentId?: string; + + /** channel : The CHANNEL to which this CHANNEL_BOOKMARK belongs */ + @immutableRelation(CHANNEL, 'channel_id') channel!: Relation; + + /** creator : The USER who created this CHANNEL_BOOKMARK */ + @immutableRelation(USER, 'owner_id') owner!: Relation; + + /** file : The FILE attached to this CHANNEL_BOOKMARK */ + @immutableRelation(FILE, 'file_id') file!: Relation; + + toApi = () => { + const b: ChannelBookmark = { + id: this.id, + create_at: this.createAt, + update_at: this.updateAt, + delete_at: this.deleteAt, + channel_id: this.channelId, + owner_id: this.ownerId, + file_id: this.fileId, + display_name: this.displayName, + sort_order: this.sortOrder, + link_url: this.linkUrl, + image_url: this.imageUrl, + emoji: this.emoji, + type: this.type, + original_id: this.originalId, + parent_id: this.parentId, + }; + + return b; + }; +} diff --git a/app/database/models/server/file.ts b/app/database/models/server/file.ts index 45c62e49707..96ba7fc2aef 100644 --- a/app/database/models/server/file.ts +++ b/app/database/models/server/file.ts @@ -10,7 +10,7 @@ import type {Relation} from '@nozbe/watermelondb'; import type FileModelInterface from '@typings/database/models/servers/file'; import type PostModel from '@typings/database/models/servers/post'; -const {FILE, POST} = MM_TABLES.SERVER; +const {CHANNEL_BOOKMARK, FILE, POST} = MM_TABLES.SERVER; /** * The File model works in pair with the Post model. It hosts information about the files attached to a Post @@ -22,6 +22,9 @@ export default class FileModel extends Model implements FileModelInterface { /** associations : Describes every relationship to this table. */ static associations: Associations = { + /** A CHANNEL_BOOKMARK has a 1:1 relationship with FILE. */ + [CHANNEL_BOOKMARK]: {type: 'has_many', foreignKey: 'file_id'}, + /** A POST has a 1:N relationship with FILE. */ [POST]: {type: 'belongs_to', key: 'post_id'}, }; diff --git a/app/database/models/server/index.ts b/app/database/models/server/index.ts index 8368b53dad2..6ea0df9062e 100644 --- a/app/database/models/server/index.ts +++ b/app/database/models/server/index.ts @@ -3,9 +3,10 @@ export {default as CategoryModel} from './category'; export {default as CategoryChannelModel} from './category_channel'; +export {default as ChannelModel} from './channel'; +export {default as ChannelBookmarkModel} from './channel_bookmark'; export {default as ChannelInfoModel} from './channel_info'; export {default as ChannelMembershipModel} from './channel_membership'; -export {default as ChannelModel} from './channel'; export {default as ConfigModel} from './config'; export {default as CustomEmojiModel} from './custom_emoji'; export {default as DraftModel} from './draft'; diff --git a/app/database/models/server/user.ts b/app/database/models/server/user.ts index c9c22154e5d..1edbaba54a6 100644 --- a/app/database/models/server/user.ts +++ b/app/database/models/server/user.ts @@ -20,6 +20,7 @@ import type {UserMentionKey, HighlightWithoutNotificationKey} from '@typings/glo const { CHANNEL, + CHANNEL_BOOKMARK, CHANNEL_MEMBERSHIP, POST, PREFERENCE, @@ -43,6 +44,9 @@ export default class UserModel extends Model implements UserModelInterface { /** USER has a 1:N relationship with CHANNEL. A user can create multiple channels */ [CHANNEL]: {type: 'has_many', foreignKey: 'creator_id'}, + /** USER has a 1:N relationship with CHANNEL_BOOKMARK. A user can create multiple channels */ + [CHANNEL_BOOKMARK]: {type: 'has_many', foreignKey: 'owner_id'}, + /** USER has a 1:N relationship with CHANNEL_MEMBERSHIP. A user can be part of multiple channels */ [CHANNEL_MEMBERSHIP]: {type: 'has_many', foreignKey: 'user_id'}, diff --git a/app/database/operator/base_data_operator/index.ts b/app/database/operator/base_data_operator/index.ts index aace2f2aa26..1b77b665f53 100644 --- a/app/database/operator/base_data_operator/index.ts +++ b/app/database/operator/base_data_operator/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database, Q} from '@nozbe/watermelondb'; +import {Database, Model, Q} from '@nozbe/watermelondb'; import {OperationType} from '@constants/database'; import { @@ -11,7 +11,6 @@ import { } from '@database/operator/utils/general'; import {logWarning} from '@utils/log'; -import type Model from '@nozbe/watermelondb/Model'; import type { HandleRecordsArgs, OperationArgs, @@ -40,10 +39,11 @@ export default class BaseDataOperator { * the same value. Hence, prior to that we query the database and pick only those values that are 'new' from the 'Raw' array. * @param {ProcessRecordsArgs} inputsArg * @param {RawValue[]} inputsArg.createOrUpdateRawValues + * @param {RawValue[]} inputsArg.deleteRawValues * @param {string} inputsArg.tableName * @param {string} inputsArg.fieldName * @param {(existing: Model, newElement: RawValue) => boolean} inputsArg.buildKeyRecordBy - * @returns {Promise<{ProcessRecordResults}>} + * @returns {Promise<{ProcessRecordResults}>} */ processRecords = async ({createOrUpdateRawValues = [], deleteRawValues = [], tableName, buildKeyRecordBy, fieldName, shouldUpdate}: ProcessRecordsArgs): Promise> => { const getRecords = async (rawValues: RawValue[]) => { @@ -77,7 +77,7 @@ export default class BaseDataOperator { // for create or update flow const createOrUpdateRaws = await getRecords(createOrUpdateRawValues); - const recordsByKeys = createOrUpdateRaws.reduce((result: Record, record) => { + const recordsByKeys = createOrUpdateRaws.reduce((result: Record, record) => { // @ts-expect-error object with string key const key = buildKeyRecordBy?.(record) || record[fieldName]; result[key] = record; @@ -125,9 +125,9 @@ export default class BaseDataOperator { * @param {string} prepareRecord.tableName * @param {RawValue[]} prepareRecord.createRaws * @param {RawValue[]} prepareRecord.updateRaws - * @param {Model[]} prepareRecord.deleteRaws - * @param {(TransformerArgs) => Promise;} transformer - * @returns {Promise} + * @param {T extends Model[]} prepareRecord.deleteRaws + * @param {(TransformerArgs) => Promise;} transformer + * @returns {Promise} */ prepareRecords = async ({tableName, createRaws, deleteRaws, updateRaws, transformer}: OperationArgs): Promise => { if (!this.database) { @@ -210,7 +210,7 @@ export default class BaseDataOperator { * @returns {Promise} */ async handleRecords({buildKeyRecordBy, fieldName, transformer, createOrUpdateRawValues, deleteRawValues = [], tableName, prepareRecordsOnly = true, shouldUpdate}: HandleRecordsArgs, description: string): Promise { - if (!createOrUpdateRawValues.length) { + if (!createOrUpdateRawValues.length && !deleteRawValues.length) { logWarning( `An empty "rawValues" array has been passed to the handleRecords method for tableName ${tableName}`, ); diff --git a/app/database/operator/server_data_operator/handlers/channel.test.ts b/app/database/operator/server_data_operator/handlers/channel.test.ts index d5c3b5df8b9..eccb9fc4724 100644 --- a/app/database/operator/server_data_operator/handlers/channel.test.ts +++ b/app/database/operator/server_data_operator/handlers/channel.test.ts @@ -184,7 +184,7 @@ describe('*** Operator: Channel Handlers tests ***', () => { await operator.handleMyChannel({ channels, myChannels, - prepareRecordsOnly: false, + prepareRecordsOnly: true, }); expect(spyOnHandleRecords).toHaveBeenCalledTimes(1); @@ -192,7 +192,7 @@ describe('*** Operator: Channel Handlers tests ***', () => { fieldName: 'id', createOrUpdateRawValues: myChannels, tableName: 'MyChannel', - prepareRecordsOnly: false, + prepareRecordsOnly: true, buildKeyRecordBy: buildMyChannelKey, transformer: transformMyChannelRecord, }, 'handleMyChannel'); @@ -253,7 +253,7 @@ describe('*** Operator: Channel Handlers tests ***', () => { prepareRecordsOnly: false, }); - expect(updated[0].lastFetchedAt).toBe(123456789); + expect(updated[0]).toHaveProperty('lastFetchedAt', 123456789); }); it('=> HandleChannelMembership: should write to the CHANNEL_MEMBERSHIP table', async () => { diff --git a/app/database/operator/server_data_operator/handlers/channel.ts b/app/database/operator/server_data_operator/handlers/channel.ts index c5b750721f7..b5f7f3cbe4d 100644 --- a/app/database/operator/server_data_operator/handlers/channel.ts +++ b/app/database/operator/server_data_operator/handlers/channel.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database, Q} from '@nozbe/watermelondb'; +import {Database, Model, Q} from '@nozbe/watermelondb'; import {MM_TABLES} from '@constants/database'; import { @@ -9,6 +9,7 @@ import { buildChannelMembershipKey, } from '@database/operator/server_data_operator/comparators'; import { + transformChannelBookmarkRecord, transformChannelInfoRecord, transformChannelMembershipRecord, transformChannelRecord, @@ -17,10 +18,11 @@ import { } from '@database/operator/server_data_operator/transformers/channel'; import {getUniqueRawsBy} from '@database/operator/utils/general'; import {getIsCRTEnabled} from '@queries/servers/thread'; +import ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; import {logWarning} from '@utils/log'; import type ServerDataOperatorBase from '.'; -import type {HandleChannelArgs, HandleChannelInfoArgs, HandleChannelMembershipArgs, HandleMyChannelArgs, HandleMyChannelSettingsArgs} from '@typings/database/database'; +import type {HandleChannelArgs, HandleChannelBookmarkArgs, HandleChannelInfoArgs, HandleChannelMembershipArgs, HandleMyChannelArgs, HandleMyChannelSettingsArgs} from '@typings/database/database'; import type ChannelModel from '@typings/database/models/servers/channel'; import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; @@ -29,6 +31,7 @@ import type MyChannelSettingsModel from '@typings/database/models/servers/my_cha const { CHANNEL, + CHANNEL_BOOKMARK, CHANNEL_INFO, CHANNEL_MEMBERSHIP, MY_CHANNEL, @@ -37,10 +40,11 @@ const { export interface ChannelHandlerMix { handleChannel: ({channels, prepareRecordsOnly}: HandleChannelArgs) => Promise; + handleChannelBookmark: ({bookmarks, prepareRecordsOnly}: HandleChannelBookmarkArgs) => Promise; handleChannelMembership: ({channelMemberships, prepareRecordsOnly}: HandleChannelMembershipArgs) => Promise; handleMyChannelSettings: ({settings, prepareRecordsOnly}: HandleMyChannelSettingsArgs) => Promise; handleChannelInfo: ({channelInfos, prepareRecordsOnly}: HandleChannelInfoArgs) => Promise; - handleMyChannel: ({channels, myChannels, isCRTEnabled, prepareRecordsOnly}: HandleMyChannelArgs) => Promise; + handleMyChannel: ({channels, myChannels, isCRTEnabled, prepareRecordsOnly}: HandleMyChannelArgs) => Promise; } const ChannelHandler = >(superclass: TBase) => class extends superclass { @@ -217,7 +221,7 @@ const ChannelHandler = >(super * @param {boolean} myChannelsArgs.prepareRecordsOnly * @returns {Promise} */ - handleMyChannel = async ({channels, myChannels, isCRTEnabled, prepareRecordsOnly = true}: HandleMyChannelArgs): Promise => { + handleMyChannel = async ({channels, myChannels, isCRTEnabled, prepareRecordsOnly = true}: HandleMyChannelArgs): Promise => { if (!myChannels?.length) { logWarning( 'An empty or undefined "myChannels" array has been passed to the handleMyChannel method', @@ -235,7 +239,6 @@ const ChannelHandler = >(super } const isCRT = isCRTEnabled ?? await getIsCRTEnabled(this.database); - const channelMap = channels.reduce((result: Record, channel) => { result[channel.id] = channel; return result; @@ -353,6 +356,87 @@ const ChannelHandler = >(super tableName: CHANNEL_MEMBERSHIP, }, 'handleChannelMembership'); }; + + /** + * handleChannelMembership: Handler responsible for the Create/Update/Delete operations occurring on the CHANNEL_BOOKMARK table from the 'Server' schema + * @param {HandleChannelBookmarkArgs} channelBookmarkArgs + * @param {ChannelBookmark[]} channelBookmarkArgs.bookmarks + * @param {boolean} channelBookmarkArgs.prepareRecordsOnly + * @returns {Promise} + */ + handleChannelBookmark = async ({bookmarks, prepareRecordsOnly}: HandleChannelBookmarkArgs): Promise => { + if (!bookmarks?.length) { + logWarning( + 'An empty or undefined "bookmarks" array has been passed to the handleChannelBookmark method', + ); + return []; + } + + const uniqueRaws = getUniqueRawsBy({raws: bookmarks, key: 'id'}) as ChannelBookmarkWithFileInfo[]; + const keys = uniqueRaws.map((c) => c.id); + const db: Database = this.database; + const existing = await db.get(CHANNEL_BOOKMARK).query( + Q.where('id', Q.oneOf(keys)), + ).fetch(); + const bookmarkMap = new Map(existing.map((b) => [b.id, b])); + const files: FileInfo[] = []; + const raws = uniqueRaws.reduce<{createOrUpdateRaws: ChannelBookmarkWithFileInfo[]; deleteRaws: ChannelBookmarkWithFileInfo[]}>((res, b) => { + const e = bookmarkMap.get(b.id); + if (!e) { + if (!b.delete_at) { + res.createOrUpdateRaws.push(b); + if (b.file) { + files.push(b.file); + } + } + return res; + } + + if (e.updateAt !== b.update_at) { + res.createOrUpdateRaws.push(b); + if (b.file) { + files.push(b.file); + } + } + + if (b.delete_at) { + res.deleteRaws.push(b); + if (b.file) { + b.file.delete_at = b.delete_at; + files.push(b.file); + } + } + + return res; + }, {createOrUpdateRaws: [], deleteRaws: []}); + + if (!raws.createOrUpdateRaws.length && !raws.deleteRaws.length) { + return []; + } + + const preparedBookmarks = await this.handleRecords({ + fieldName: 'id', + transformer: transformChannelBookmarkRecord, + createOrUpdateRawValues: raws.createOrUpdateRaws, + deleteRawValues: raws.deleteRaws, + tableName: CHANNEL_BOOKMARK, + prepareRecordsOnly: true, + }, 'handleChannelBookmark'); + + const batch: Model[] = [...preparedBookmarks]; + if (files.length) { + const bookmarkFiles = await this.handleFiles({files, prepareRecordsOnly: true}); + if (bookmarkFiles.length) { + batch.push(...bookmarkFiles); + } + } + + if (batch.length && !prepareRecordsOnly) { + await this.batchRecords(batch, 'handleChannelBookmark'); + } + + return batch; + }; }; export default ChannelHandler; diff --git a/app/database/operator/server_data_operator/handlers/index.test.ts b/app/database/operator/server_data_operator/handlers/index.test.ts index 9ce3d4744b9..09b02c8a3e6 100644 --- a/app/database/operator/server_data_operator/handlers/index.test.ts +++ b/app/database/operator/server_data_operator/handlers/index.test.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import DatabaseManager from '@database/manager'; +import {shouldUpdateFileRecord} from '@database/operator/server_data_operator/comparators/files'; import { transformConfigRecord, transformCustomEmojiRecord, @@ -9,7 +10,7 @@ import { transformSystemRecord, } from '@database/operator/server_data_operator/transformers/general'; -import type ServerDataOperator from '..'; +import type ServerDataOperator from '@database/operator/server_data_operator/index'; describe('*** DataOperator: Base Handlers tests ***', () => { let operator: ServerDataOperator; @@ -120,6 +121,59 @@ describe('*** DataOperator: Base Handlers tests ***', () => { }, 'handleConfigs'); }); + it('=> HandleFiles: should write to the FILE table', async () => { + expect.assertions(1); + + const spyOnprocessRecords = jest.spyOn(operator, 'processRecords'); + + const files = [{ + id: 'f1oxe5rtepfs7n3zifb4sso7po', + user_id: '89ertha8xpfsumpucqppy5knao', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + create_at: 1608270920357, + update_at: 1608270920357, + delete_at: 0, + name: '4qtwrg.jpg', + extension: 'jpg', + size: 89208, + mime_type: 'image/jpeg', + width: 500, + height: 656, + has_preview_image: true, + mini_preview: + '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', + }, { + id: 'f1oxe5rtepfs7n3zifb4sso7po', + user_id: 'bookmark', + create_at: 1608270920357, + update_at: 1608270920357, + delete_at: 1608270920357, + name: '4qtwrg.jpg', + extension: 'jpg', + size: 89208, + mime_type: 'image/jpeg', + width: 500, + height: 656, + has_preview_image: true, + mini_preview: + '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', + }, + ]; + + await operator.handleFiles({ + files, + prepareRecordsOnly: false, + }); + + expect(spyOnprocessRecords).toHaveBeenCalledWith({ + fieldName: 'id', + createOrUpdateRawValues: files.filter((f) => !f.delete_at), + deleteRawValues: files.filter((f) => f.delete_at), + tableName: 'File', + shouldUpdate: shouldUpdateFileRecord, + }); + }); + it('=> No table name: should not call execute if tableName is invalid', async () => { expect.assertions(3); diff --git a/app/database/operator/server_data_operator/handlers/index.ts b/app/database/operator/server_data_operator/handlers/index.ts index 15ce6f28259..8da5fbd39e2 100644 --- a/app/database/operator/server_data_operator/handlers/index.ts +++ b/app/database/operator/server_data_operator/handlers/index.ts @@ -3,9 +3,11 @@ import {MM_TABLES} from '@constants/database'; import BaseDataOperator from '@database/operator/base_data_operator'; +import {shouldUpdateFileRecord} from '@database/operator/server_data_operator/comparators/files'; import { transformConfigRecord, transformCustomEmojiRecord, + transformFileRecord, transformRoleRecord, transformSystemRecord, } from '@database/operator/server_data_operator/transformers/general'; @@ -16,13 +18,14 @@ import {sanitizeReactions} from '../../utils/reaction'; import {transformReactionRecord} from '../transformers/reaction'; import type {Model} from '@nozbe/watermelondb'; -import type {HandleConfigArgs, HandleCustomEmojiArgs, HandleReactionsArgs, HandleRoleArgs, HandleSystemArgs, OperationArgs} from '@typings/database/database'; +import type {HandleConfigArgs, HandleCustomEmojiArgs, HandleFilesArgs, HandleReactionsArgs, HandleRoleArgs, HandleSystemArgs, OperationArgs} from '@typings/database/database'; import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; +import type FileModel from '@typings/database/models/servers/file'; import type ReactionModel from '@typings/database/models/servers/reaction'; import type RoleModel from '@typings/database/models/servers/role'; import type SystemModel from '@typings/database/models/servers/system'; -const {SERVER: {CONFIG, CUSTOM_EMOJI, ROLE, SYSTEM, REACTION}} = MM_TABLES; +const {SERVER: {CONFIG, CUSTOM_EMOJI, FILE, ROLE, SYSTEM, REACTION}} = MM_TABLES; export default class ServerDataOperatorBase extends BaseDataOperator { handleRole = async ({roles, prepareRecordsOnly = true}: HandleRoleArgs) => { @@ -151,6 +154,58 @@ export default class ServerDataOperatorBase extends BaseDataOperator { return batchRecords; }; + /** + * handleFiles: Handler responsible for the Create/Update operations occurring on the File table from the 'Server' schema + * @param {HandleFilesArgs} handleFiles + * @param {RawFile[]} handleFiles.files + * @param {boolean} handleFiles.prepareRecordsOnly + * @returns {Promise} + */ + handleFiles = async ({files, prepareRecordsOnly}: HandleFilesArgs): Promise => { + if (!files?.length) { + logWarning( + 'An empty or undefined "files" array has been passed to the handleFiles method', + ); + return []; + } + + const raws = files.reduce<{createOrUpdateFiles: FileInfo[]; deleteFiles: FileInfo[]}>((res, f) => { + if (f.delete_at) { + res.deleteFiles.push(f); + } else { + res.createOrUpdateFiles.push(f); + } + + return res; + }, {createOrUpdateFiles: [], deleteFiles: []}); + + const processedFiles = (await this.processRecords({ + createOrUpdateRawValues: raws.createOrUpdateFiles, + tableName: FILE, + fieldName: 'id', + deleteRawValues: raws.deleteFiles, + shouldUpdate: shouldUpdateFileRecord, + })); + + const preparedFiles = await this.prepareRecords({ + createRaws: processedFiles.createRaws, + updateRaws: processedFiles.updateRaws, + deleteRaws: processedFiles.deleteRaws, + transformer: transformFileRecord, + tableName: FILE, + }); + + if (prepareRecordsOnly) { + return preparedFiles; + } + + if (preparedFiles?.length) { + await this.batchRecords(preparedFiles, 'handleFiles'); + } + + return preparedFiles; + }; + /** * execute: Handles the Create/Update operations on an table. * @param {OperationArgs} execute diff --git a/app/database/operator/server_data_operator/handlers/post.ts b/app/database/operator/server_data_operator/handlers/post.ts index 2b0d92e3e09..56c28571ab8 100644 --- a/app/database/operator/server_data_operator/handlers/post.ts +++ b/app/database/operator/server_data_operator/handlers/post.ts @@ -6,16 +6,15 @@ import {Q} from '@nozbe/watermelondb'; import {ActionType} from '@constants'; import {MM_TABLES} from '@constants/database'; import {buildDraftKey} from '@database/operator/server_data_operator/comparators'; -import {shouldUpdateFileRecord} from '@database/operator/server_data_operator/comparators/files'; import { transformDraftRecord, - transformFileRecord, transformPostInThreadRecord, transformPostRecord, transformPostsInChannelRecord, } from '@database/operator/server_data_operator/transformers/post'; import {getRawRecordPairs, getUniqueRawsBy, getValidRecordsForUpdate} from '@database/operator/utils/general'; import {createPostsChain, getPostListEdges} from '@database/operator/utils/post'; +import FileModel from '@typings/database/models/servers/file'; import {logWarning} from '@utils/log'; import type ServerDataOperatorBase from '.'; @@ -23,7 +22,6 @@ import type Database from '@nozbe/watermelondb/Database'; import type Model from '@nozbe/watermelondb/Model'; import type {HandleDraftArgs, HandleFilesArgs, HandlePostsArgs, RecordPair} from '@typings/database/database'; import type DraftModel from '@typings/database/models/servers/draft'; -import type FileModel from '@typings/database/models/servers/file'; import type PostModel from '@typings/database/models/servers/post'; import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel'; import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; @@ -31,7 +29,6 @@ import type ReactionModel from '@typings/database/models/servers/reaction'; const { DRAFT, - FILE, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, @@ -291,47 +288,6 @@ const PostHandler = >(supercla return batch; }; - /** - * handleFiles: Handler responsible for the Create/Update operations occurring on the File table from the 'Server' schema - * @param {HandleFilesArgs} handleFiles - * @param {RawFile[]} handleFiles.files - * @param {boolean} handleFiles.prepareRecordsOnly - * @returns {Promise} - */ - handleFiles = async ({files, prepareRecordsOnly}: HandleFilesArgs): Promise => { - if (!files?.length) { - logWarning( - 'An empty or undefined "files" array has been passed to the handleFiles method', - ); - return []; - } - - const processedFiles = (await this.processRecords({ - createOrUpdateRawValues: files, - tableName: FILE, - fieldName: 'id', - deleteRawValues: [], - shouldUpdate: shouldUpdateFileRecord, - })); - - const postFiles = await this.prepareRecords({ - createRaws: processedFiles.createRaws, - updateRaws: processedFiles.updateRaws, - transformer: transformFileRecord, - tableName: FILE, - }); - - if (prepareRecordsOnly) { - return postFiles; - } - - if (postFiles?.length) { - await this.batchRecords(postFiles, 'handleFiles'); - } - - return postFiles; - }; - /** * handlePostsInThread: Handler responsible for the Create/Update operations occurring on the PostsInThread table from the 'Server' schema * @param {Record} postsMap diff --git a/app/database/operator/server_data_operator/transformers/channel.ts b/app/database/operator/server_data_operator/transformers/channel.ts index 4c124c69138..671d0f42c30 100644 --- a/app/database/operator/server_data_operator/transformers/channel.ts +++ b/app/database/operator/server_data_operator/transformers/channel.ts @@ -7,6 +7,7 @@ import {extractChannelDisplayName} from '@helpers/database'; import type {TransformerArgs} from '@typings/database/database'; import type ChannelModel from '@typings/database/models/servers/channel'; +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; @@ -14,6 +15,7 @@ import type MyChannelSettingsModel from '@typings/database/models/servers/my_cha const { CHANNEL, + CHANNEL_BOOKMARK, CHANNEL_INFO, CHANNEL_MEMBERSHIP, MY_CHANNEL, @@ -178,3 +180,44 @@ export const transformChannelMembershipRecord = ({action, database, value}: Tran fieldsMapper, }) as Promise; }; + +/** + * transformChannelBookmarkRecord: Prepares a record of the SERVER database 'Channel' table for update or create actions. + * @param {DataFactory} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const transformChannelBookmarkRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw as ChannelBookmark; + const record = value.record as ChannelBookmarkModel; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const fieldsMapper = (bookmark: ChannelBookmarkModel) => { + bookmark._raw.id = isCreateAction ? (raw?.id ?? bookmark.id) : record.id; + bookmark.createAt = raw.create_at; + bookmark.deleteAt = raw.delete_at; + bookmark.updateAt = raw.update_at; + bookmark.channelId = raw.channel_id; + bookmark.ownerId = raw.owner_id; + bookmark.fileId = raw.file_id; + + bookmark.displayName = raw.display_name; + bookmark.sortOrder = raw.sort_order; + bookmark.linkUrl = raw.link_url; + bookmark.imageUrl = raw.image_url; + bookmark.emoji = raw.emoji; + bookmark.type = raw.type; + bookmark.originalId = raw.original_id; + bookmark.parentId = raw.parent_id; + }; + + return prepareBaseRecord({ + action, + database, + tableName: CHANNEL_BOOKMARK, + value, + fieldsMapper, + }) as Promise; +}; diff --git a/app/database/operator/server_data_operator/transformers/general.test.ts b/app/database/operator/server_data_operator/transformers/general.test.ts index 12e25c6e1c9..6121db4f43c 100644 --- a/app/database/operator/server_data_operator/transformers/general.test.ts +++ b/app/database/operator/server_data_operator/transformers/general.test.ts @@ -4,6 +4,7 @@ import {OperationType} from '@constants/database'; import { transformCustomEmojiRecord, + transformFileRecord, transformRoleRecord, transformSystemRecord, } from '@database/operator/server_data_operator/transformers/general'; @@ -83,3 +84,37 @@ describe('*** CustomEmoj Prepare Records Test ***', () => { }); }); +describe('*** Files Prepare Records Test ***', () => { + it('=> transformFileRecord: should return an array of type File', async () => { + expect.assertions(3); + + const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true}); + expect(database).toBeTruthy(); + + const preparedRecords = await transformFileRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'file-id', + post_id: 'ps81iqbddesfby8jayz7owg4yypoo', + name: 'test_file', + extension: '.jpg', + has_preview_image: true, + mime_type: 'image/jpeg', + size: 1000, + create_at: 1609253011321, + delete_at: 1609253011321, + height: 20, + width: 20, + update_at: 1609253011321, + user_id: 'wqyby5r5pinxxdqhoaomtacdhc', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.table).toBe('File'); + }); +}); diff --git a/app/database/operator/server_data_operator/transformers/general.ts b/app/database/operator/server_data_operator/transformers/general.ts index d157780762c..8264739b797 100644 --- a/app/database/operator/server_data_operator/transformers/general.ts +++ b/app/database/operator/server_data_operator/transformers/general.ts @@ -7,11 +7,13 @@ import {prepareBaseRecord} from '@database/operator/server_data_operator/transfo import type {TransformerArgs} from '@typings/database/database'; import type ConfigModel from '@typings/database/models/servers/config'; import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; +import type FileModel from '@typings/database/models/servers/file'; import type RoleModel from '@typings/database/models/servers/role'; import type SystemModel from '@typings/database/models/servers/system'; const { CUSTOM_EMOJI, + FILE, ROLE, SYSTEM, CONFIG, @@ -121,3 +123,38 @@ export const transformConfigRecord = ({action, database, value}: TransformerArgs fieldsMapper, }) as Promise; }; + +/** + * transformFileRecord: Prepares a record of the SERVER database 'Files' table for update or create actions. + * @param {TransformerArgs} operator + * @param {Database} operator.database + * @param {RecordPair} operator.value + * @returns {Promise} + */ +export const transformFileRecord = ({action, database, value}: TransformerArgs): Promise => { + const raw = value.raw as FileInfo; + const record = value.record as FileModel; + const isCreateAction = action === OperationType.CREATE; + + // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database + const fieldsMapper = (file: FileModel) => { + file._raw.id = isCreateAction ? (raw.id || file.id) : record.id; + file.postId = raw.post_id || ''; + file.name = raw.name; + file.extension = raw.extension; + file.size = raw.size; + file.mimeType = raw?.mime_type ?? ''; + file.width = raw?.width || record?.width || 0; + file.height = raw?.height || record?.height || 0; + file.imageThumbnail = raw?.mini_preview || record?.imageThumbnail || ''; + file.localPath = raw?.localPath || record?.localPath || null; + }; + + return prepareBaseRecord({ + action, + database, + tableName: FILE, + value, + fieldsMapper, + }) as Promise; +}; diff --git a/app/database/operator/server_data_operator/transformers/post.test.ts b/app/database/operator/server_data_operator/transformers/post.test.ts index 53bea8c62e8..6f018d2fb39 100644 --- a/app/database/operator/server_data_operator/transformers/post.test.ts +++ b/app/database/operator/server_data_operator/transformers/post.test.ts @@ -4,7 +4,6 @@ import {OperationType} from '@constants/database'; import { transformDraftRecord, - transformFileRecord, transformPostInThreadRecord, transformPostRecord, transformPostsInChannelRecord, @@ -75,39 +74,6 @@ describe('*** POST Prepare Records Test ***', () => { expect(preparedRecords!.collection.table).toBe('PostsInThread'); }); - it('=> transformFileRecord: should return an array of type File', async () => { - expect.assertions(3); - - const database = await createTestConnection({databaseName: 'post_prepare_records', setActive: true}); - expect(database).toBeTruthy(); - - const preparedRecords = await transformFileRecord({ - action: OperationType.CREATE, - database: database!, - value: { - record: undefined, - raw: { - id: 'file-id', - post_id: 'ps81iqbddesfby8jayz7owg4yypoo', - name: 'test_file', - extension: '.jpg', - has_preview_image: true, - mime_type: 'image/jpeg', - size: 1000, - create_at: 1609253011321, - delete_at: 1609253011321, - height: 20, - width: 20, - update_at: 1609253011321, - user_id: 'wqyby5r5pinxxdqhoaomtacdhc', - }, - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.table).toBe('File'); - }); - it('=> transformDraftRecord: should return an array of type Draft', async () => { expect.assertions(3); diff --git a/app/database/operator/server_data_operator/transformers/post.ts b/app/database/operator/server_data_operator/transformers/post.ts index 0b42b8b6a6c..7bd63609d4f 100644 --- a/app/database/operator/server_data_operator/transformers/post.ts +++ b/app/database/operator/server_data_operator/transformers/post.ts @@ -6,14 +6,12 @@ import {prepareBaseRecord} from '@database/operator/server_data_operator/transfo import type{TransformerArgs} from '@typings/database/database'; import type DraftModel from '@typings/database/models/servers/draft'; -import type FileModel from '@typings/database/models/servers/file'; import type PostModel from '@typings/database/models/servers/post'; import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel'; import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread'; const { DRAFT, - FILE, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, @@ -94,41 +92,6 @@ export const transformPostInThreadRecord = ({action, database, value}: Transform }) as Promise; }; -/** - * transformFileRecord: Prepares a record of the SERVER database 'Files' table for update or create actions. - * @param {TransformerArgs} operator - * @param {Database} operator.database - * @param {RecordPair} operator.value - * @returns {Promise} - */ -export const transformFileRecord = ({action, database, value}: TransformerArgs): Promise => { - const raw = value.raw as FileInfo; - const record = value.record as FileModel; - const isCreateAction = action === OperationType.CREATE; - - // If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database - const fieldsMapper = (file: FileModel) => { - file._raw.id = isCreateAction ? (raw.id || file.id) : record.id; - file.postId = raw.post_id; - file.name = raw.name; - file.extension = raw.extension; - file.size = raw.size; - file.mimeType = raw?.mime_type ?? ''; - file.width = raw?.width || record?.width || 0; - file.height = raw?.height || record?.height || 0; - file.imageThumbnail = raw?.mini_preview || record?.imageThumbnail || ''; - file.localPath = raw?.localPath || record?.localPath || null; - }; - - return prepareBaseRecord({ - action, - database, - tableName: FILE, - value, - fieldsMapper, - }) as Promise; -}; - /** * transformDraftRecord: Prepares a record of the SERVER database 'Draft' table for update or create actions. * @param {TransformerArgs} operator diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index 8220f092e07..7f0203c1676 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -6,9 +6,10 @@ import {type AppSchema, appSchema} from '@nozbe/watermelondb'; import { CategorySchema, CategoryChannelSchema, + ChannelSchema, + ChannelBookmarkSchema, ChannelInfoSchema, ChannelMembershipSchema, - ChannelSchema, ConfigSchema, CustomEmojiSchema, DraftSchema, @@ -39,13 +40,14 @@ import { } from './table_schemas'; export const serverSchema: AppSchema = appSchema({ - version: 3, + version: 4, tables: [ CategorySchema, CategoryChannelSchema, + ChannelSchema, + ChannelBookmarkSchema, ChannelInfoSchema, ChannelMembershipSchema, - ChannelSchema, ConfigSchema, CustomEmojiSchema, DraftSchema, diff --git a/app/database/schema/server/table_schemas/channel_bookmark.ts b/app/database/schema/server/table_schemas/channel_bookmark.ts new file mode 100644 index 00000000000..c7d62188d42 --- /dev/null +++ b/app/database/schema/server/table_schemas/channel_bookmark.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {tableSchema} from '@nozbe/watermelondb'; + +import {MM_TABLES} from '@constants/database'; + +const {CHANNEL_BOOKMARK} = MM_TABLES.SERVER; + +export default tableSchema({ + name: CHANNEL_BOOKMARK, + columns: [ + {name: 'create_at', type: 'number'}, + {name: 'update_at', type: 'number'}, + {name: 'delete_at', type: 'number'}, + {name: 'channel_id', type: 'string', isIndexed: true}, + {name: 'owner_id', type: 'string'}, + {name: 'file_id', type: 'string', isOptional: true}, + {name: 'display_name', type: 'string'}, + {name: 'sort_order', type: 'number'}, + {name: 'link_url', type: 'string', isOptional: true}, + {name: 'image_url', type: 'string', isOptional: true}, + {name: 'emoji', type: 'string', isOptional: true}, + {name: 'type', type: 'string'}, + {name: 'original_id', type: 'string', isOptional: true}, + {name: 'parent_id', type: 'string', isOptional: true}, + ], +}); diff --git a/app/database/schema/server/table_schemas/index.ts b/app/database/schema/server/table_schemas/index.ts index 27676546eae..ae6337034fb 100644 --- a/app/database/schema/server/table_schemas/index.ts +++ b/app/database/schema/server/table_schemas/index.ts @@ -3,9 +3,10 @@ export {default as CategorySchema} from './category'; export {default as CategoryChannelSchema} from './category_channel'; +export {default as ChannelSchema} from './channel'; +export {default as ChannelBookmarkSchema} from './channel_bookmark'; export {default as ChannelInfoSchema} from './channel_info'; export {default as ChannelMembershipSchema} from './channel_membership'; -export {default as ChannelSchema} from './channel'; export {default as CustomEmojiSchema} from './custom_emoji'; export {default as DraftSchema} from './draft'; export {default as FileSchema} from './file'; diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index e64685048fd..da7cc1fc65f 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -11,6 +11,7 @@ const { CATEGORY, CATEGORY_CHANNEL, CHANNEL, + CHANNEL_BOOKMARK, CHANNEL_INFO, CHANNEL_MEMBERSHIP, CONFIG, @@ -45,7 +46,7 @@ const { describe('*** Test schema for SERVER database ***', () => { it('=> The SERVER SCHEMA should strictly match', () => { expect(serverSchema).toEqual({ - version: 3, + version: 4, unsafeSql: undefined, tables: { [CATEGORY]: { @@ -136,6 +137,43 @@ describe('*** Test schema for SERVER database ***', () => { {name: 'update_at', type: 'number'}, ], }, + [CHANNEL_BOOKMARK]: { + name: CHANNEL_BOOKMARK, + unsafeSql: undefined, + columns: { + create_at: {name: 'create_at', type: 'number'}, + update_at: {name: 'update_at', type: 'number'}, + delete_at: {name: 'delete_at', type: 'number'}, + channel_id: {name: 'channel_id', type: 'string', isIndexed: true}, + owner_id: {name: 'owner_id', type: 'string'}, + file_id: {name: 'file_id', type: 'string', isOptional: true}, + display_name: {name: 'display_name', type: 'string'}, + sort_order: {name: 'sort_order', type: 'number'}, + link_url: {name: 'link_url', type: 'string', isOptional: true}, + image_url: {name: 'image_url', type: 'string', isOptional: true}, + emoji: {name: 'emoji', type: 'string', isOptional: true}, + type: {name: 'type', type: 'string'}, + original_id: {name: 'original_id', type: 'string', isOptional: true}, + parent_id: {name: 'parent_id', type: 'string', isOptional: true}, + + }, + columnArray: [ + {name: 'create_at', type: 'number'}, + {name: 'update_at', type: 'number'}, + {name: 'delete_at', type: 'number'}, + {name: 'channel_id', type: 'string', isIndexed: true}, + {name: 'owner_id', type: 'string'}, + {name: 'file_id', type: 'string', isOptional: true}, + {name: 'display_name', type: 'string'}, + {name: 'sort_order', type: 'number'}, + {name: 'link_url', type: 'string', isOptional: true}, + {name: 'image_url', type: 'string', isOptional: true}, + {name: 'emoji', type: 'string', isOptional: true}, + {name: 'type', type: 'string'}, + {name: 'original_id', type: 'string', isOptional: true}, + {name: 'parent_id', type: 'string', isOptional: true}, + ], + }, [CHANNEL_MEMBERSHIP]: { name: CHANNEL_MEMBERSHIP, unsafeSql: undefined, diff --git a/app/hooks/files.ts b/app/hooks/files.ts index 6daa81947a7..49da59e5122 100644 --- a/app/hooks/files.ts +++ b/app/hooks/files.ts @@ -1,11 +1,53 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useMemo} from 'react'; +import {useEffect, useMemo, useState} from 'react'; +import {getLocalFileInfo} from '@actions/local/file'; import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file'; import {useServerUrl} from '@context/server'; import {isGif, isImage, isVideo} from '@utils/file'; +import {getImageSize} from '@utils/gallery'; + +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; + +const getFileInfo = async (serverUrl: string, bookmarks: ChannelBookmarkModel[], publicLinkEnabled: boolean, cb: (files: FileInfo[]) => void) => { + const fileInfos: FileInfo[] = []; + for await (const b of bookmarks) { + if (b.fileId) { + const res = await getLocalFileInfo(serverUrl, b.fileId); + if (res.file) { + const fileInfo = res.file.toFileInfo(b.ownerId); + const imageFile = isImage(fileInfo); + const videoFile = isVideo(fileInfo); + + let uri; + if (imageFile || (videoFile && publicLinkEnabled)) { + if (fileInfo.localPath) { + uri = fileInfo.localPath; + } else { + uri = (isGif(fileInfo) || (imageFile && !fileInfo.has_preview_image) || videoFile) ? buildFileUrl(serverUrl, fileInfo.id!) : buildFilePreviewUrl(serverUrl, fileInfo.id!); + } + } else { + uri = fileInfo.localPath || buildFileUrl(serverUrl, fileInfo.id!); + } + + let {width, height} = fileInfo; + if (imageFile && !width) { + const size = await getImageSize(uri); + width = size.width; + height = size.height; + } + + fileInfos.push({...fileInfo, uri, width, height}); + } + } + } + + if (fileInfos.length) { + cb(fileInfos); + } +}; export const useImageAttachments = (filesInfo: FileInfo[], publicLinkEnabled: boolean) => { const serverUrl = useServerUrl(); @@ -35,3 +77,13 @@ export const useImageAttachments = (filesInfo: FileInfo[], publicLinkEnabled: bo }, [filesInfo, publicLinkEnabled]); }; +export const useChannelBookmarkFiles = (bookmarks: ChannelBookmarkModel[], publicLinkEnabled: boolean) => { + const serverUrl = useServerUrl(); + const [files, setFiles] = useState([]); + + useEffect(() => { + getFileInfo(serverUrl, bookmarks, publicLinkEnabled, setFiles); + }, [serverUrl, bookmarks, publicLinkEnabled]); + + return files; +}; diff --git a/app/managers/websocket_manager.ts b/app/managers/websocket_manager.ts index b40f94db249..1a3538525c0 100644 --- a/app/managers/websocket_manager.ts +++ b/app/managers/websocket_manager.ts @@ -9,7 +9,8 @@ import {distinctUntilChanged} from 'rxjs/operators'; import {setCurrentUserStatus} from '@actions/local/user'; import {fetchStatusByIds} from '@actions/remote/user'; -import {handleEvent, handleFirstConnect, handleReconnect} from '@actions/websocket'; +import {handleFirstConnect, handleReconnect} from '@actions/websocket'; +import {handleWebSocketEvent} from '@actions/websocket/event'; import WebSocketClient from '@client/websocket'; import {General} from '@constants'; import DatabaseManager from '@database/manager'; @@ -84,7 +85,7 @@ class WebsocketManager { const client = new WebSocketClient(serverUrl, bearerToken); client.setFirstConnectCallback(() => this.onFirstConnect(serverUrl)); - client.setEventCallback((evt: any) => handleEvent(serverUrl, evt)); + client.setEventCallback((evt: WebSocketMessage) => handleWebSocketEvent(serverUrl, evt)); //client.setMissedEventsCallback(() => {}) Nothing to do on missedEvents callback client.setReconnectCallback(() => this.onReconnect(serverUrl)); diff --git a/app/products/calls/components/floating_call_container.tsx b/app/products/calls/components/floating_call_container.tsx index d5c264efa70..3ab1741f5d2 100644 --- a/app/products/calls/components/floating_call_container.tsx +++ b/app/products/calls/components/floating_call_container.tsx @@ -8,7 +8,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'; import CurrentCallBar from '@calls/components/current_call_bar'; import {IncomingCallsContainer} from '@calls/components/incoming_calls_container'; import JoinCallBanner from '@calls/components/join_call_banner'; -import {DEFAULT_HEADER_HEIGHT, TABLET_HEADER_HEIGHT} from '@constants/view'; +import {BOOKMARKS_BAR_HEIGHT, DEFAULT_HEADER_HEIGHT, TABLET_HEADER_HEIGHT} from '@constants/view'; import {useServerUrl} from '@context/server'; import {useIsTablet} from '@hooks/device'; @@ -28,6 +28,7 @@ type Props = { isInACall?: boolean; threadScreen?: boolean; channelsScreen?: boolean; + includeBookmarkBar?: boolean; } const FloatingCallContainer = ({ @@ -37,6 +38,7 @@ const FloatingCallContainer = ({ isInACall, threadScreen, channelsScreen, + includeBookmarkBar, }: Props) => { const serverUrl = useServerUrl(); const insets = useSafeAreaInsets(); @@ -45,7 +47,7 @@ const FloatingCallContainer = ({ const topBarForTablet = (isTablet && !threadScreen) ? TABLET_HEADER_HEIGHT : 0; const topBarChannel = (!isTablet && !threadScreen) ? DEFAULT_HEADER_HEIGHT : 0; const wrapperTop = { - top: insets.top + topBarForTablet + topBarChannel, + top: insets.top + topBarForTablet + topBarChannel + (includeBookmarkBar ? BOOKMARKS_BAR_HEIGHT : 0), }; const wrapperBottom = { bottom: 8, diff --git a/app/products/calls/hooks.ts b/app/products/calls/hooks.ts index bab65f87f68..5bf8ae65a49 100644 --- a/app/products/calls/hooks.ts +++ b/app/products/calls/hooks.ts @@ -115,14 +115,16 @@ const micPermission = Platform.select({ export const usePermissionsChecker = (micPermissionsGranted: boolean) => { const appState = useAppState(); + const [hasPermission, setHasPermission] = useState(micPermissionsGranted); useEffect(() => { const asyncFn = async () => { if (appState === 'active') { - const hasPermission = (await Permissions.check(micPermission)) === Permissions.RESULTS.GRANTED; - if (hasPermission) { + const result = (await Permissions.check(micPermission)) === Permissions.RESULTS.GRANTED; + setHasPermission(result); + if (result) { initializeVoiceTrack(); - setMicPermissionsGranted(hasPermission); + setMicPermissionsGranted(result); } } }; @@ -130,6 +132,8 @@ export const usePermissionsChecker = (micPermissionsGranted: boolean) => { asyncFn(); } }, [appState]); + + return hasPermission; }; export const useCallsAdjustment = (serverUrl: string, channelId: string): number => { @@ -139,6 +143,7 @@ export const useCallsAdjustment = (serverUrl: string, channelId: string): number const globalCallsState = useGlobalCallsState(); const currentCall = useCurrentCall(); const [numServers, setNumServers] = useState(1); + const micPermissionsGranted = usePermissionsChecker(globalCallsState.micPermissionsGranted); const dismissed = Boolean(callsState.calls[channelId]?.dismissed[callsState.myUserId]); const inCurrentCall = currentCall?.id === channelId; const joinCallBannerVisible = Boolean(channelsWithCalls[channelId]) && !dismissed && !inCurrentCall; @@ -153,7 +158,7 @@ export const useCallsAdjustment = (serverUrl: string, channelId: string): number // Do we have calls banners? const currentCallBarVisible = Boolean(currentCall); - const micPermissionsError = !globalCallsState.micPermissionsGranted && (currentCall && !currentCall.micPermissionsErrorDismissed); + const micPermissionsError = !micPermissionsGranted && (currentCall && !currentCall.micPermissionsErrorDismissed); const callQualityAlert = Boolean(currentCall?.callQualityAlert); const incomingCallsShowing = incomingCalls.filter((ic) => ic.channelID !== channelId); const notificationBarHeight = CALL_NOTIFICATION_BAR_HEIGHT + (numServers > 1 ? 8 : 0); diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 71c9b2225aa..fd846912e5c 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -20,6 +20,7 @@ import {observeTeammateNameDisplay} from './user'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type {Clause} from '@nozbe/watermelondb/QueryDescription'; import type ChannelModel from '@typings/database/models/servers/channel'; +import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; import type ChannelInfoModel from '@typings/database/models/servers/channel_info'; import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership'; import type MyChannelModel from '@typings/database/models/servers/my_channel'; @@ -28,6 +29,29 @@ import type UserModel from '@typings/database/models/servers/user'; const {SERVER: {CHANNEL, MY_CHANNEL, CHANNEL_MEMBERSHIP, MY_CHANNEL_SETTINGS, CHANNEL_INFO, USER, TEAM}} = MM_TABLES; +type ChannelMembershipsExtended = Pick; + +function prepareChannels( + operator: ServerDataOperator, + channels?: Channel[], + channelInfos?: ChannelInfo[], + channelMemberships?: ChannelMembershipsExtended[], + memberships?: ChannelMembership[], + isCRTEnabled?: boolean, +): Array> { + try { + const channelRecords = operator.handleChannel({channels, prepareRecordsOnly: true}); + const channelInfoRecords = operator.handleChannelInfo({channelInfos, prepareRecordsOnly: true}); + const membershipRecords = operator.handleChannelMembership({channelMemberships, prepareRecordsOnly: true}); + const myChannelRecords = operator.handleMyChannel({channels, myChannels: memberships, prepareRecordsOnly: true, isCRTEnabled}); + const myChannelSettingsRecords = operator.handleMyChannelSettings({settings: memberships, prepareRecordsOnly: true}); + + return [channelRecords, channelInfoRecords, membershipRecords, myChannelRecords, myChannelSettingsRecords]; + } catch { + return []; + } +} + export function prepareMissingChannelsForAllTeams(operator: ServerDataOperator, channels: Channel[], channelMembers: ChannelMembership[], isCRTEnabled?: boolean): Array> { const channelInfos: ChannelInfo[] = []; const channelMap: Record = {}; @@ -54,17 +78,7 @@ export function prepareMissingChannelsForAllTeams(operator: ServerDataOperator, }; }); - try { - const channelRecords = operator.handleChannel({channels, prepareRecordsOnly: true}); - const channelInfoRecords = operator.handleChannelInfo({channelInfos, prepareRecordsOnly: true}); - const membershipRecords = operator.handleChannelMembership({channelMemberships: memberships, prepareRecordsOnly: true}); - const myChannelRecords = operator.handleMyChannel({channels, myChannels: memberships, prepareRecordsOnly: true, isCRTEnabled}); - const myChannelSettingsRecords = operator.handleMyChannelSettings({settings: memberships, prepareRecordsOnly: true}); - - return [channelRecords, channelInfoRecords, membershipRecords, myChannelRecords, myChannelSettingsRecords]; - } catch { - return []; - } + return prepareChannels(operator, channels, channelInfos, memberships, memberships, isCRTEnabled); } export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, teamId: string, channels: Channel[], channelMembers: ChannelMembership[], isCRTEnabled?: boolean) => { @@ -117,17 +131,7 @@ export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, tea }); } - try { - const channelRecords = operator.handleChannel({channels, prepareRecordsOnly: true}); - const channelInfoRecords = operator.handleChannelInfo({channelInfos, prepareRecordsOnly: true}); - const membershipRecords = operator.handleChannelMembership({channelMemberships: channelMembers, prepareRecordsOnly: true}); - const myChannelRecords = operator.handleMyChannel({channels, myChannels: memberships, prepareRecordsOnly: true, isCRTEnabled}); - const myChannelSettingsRecords = operator.handleMyChannelSettings({settings: memberships, prepareRecordsOnly: true}); - - return [channelRecords, channelInfoRecords, membershipRecords, myChannelRecords, myChannelSettingsRecords]; - } catch { - return []; - } + return prepareChannels(operator, channels, channelInfos, channelMembers, memberships, isCRTEnabled); }; export const prepareDeleteChannel = async (channel: ChannelModel): Promise => { @@ -169,6 +173,27 @@ export const prepareDeleteChannel = async (channel: ChannelModel): Promise { + const preparedModels: Model[] = [bookmark.prepareDestroyPermanently()]; + try { + if (bookmark.fileId) { + const file = await bookmark.file.fetch(); + preparedModels.push(file.prepareDestroyPermanently()); + } + } catch { + // Record not found, do nothing + } return preparedModels; }; diff --git a/app/queries/servers/channel_bookmark.ts b/app/queries/servers/channel_bookmark.ts new file mode 100644 index 00000000000..9baeeecdec1 --- /dev/null +++ b/app/queries/servers/channel_bookmark.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q, type Database} from '@nozbe/watermelondb'; +import {of as of$} from 'rxjs'; +import {distinctUntilChanged, switchMap, combineLatestWith} from 'rxjs/operators'; + +import {General, Permissions} from '@constants'; +import {MM_TABLES} from '@constants/database'; +import ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark'; +import {isDMorGM} from '@utils/channel'; +import {isMinimumServerVersion} from '@utils/helpers'; + +import {observeChannel} from './channel'; +import {observePermissionForChannel} from './role'; +import {observeConfigValue} from './system'; +import {observeCurrentUser} from './user'; + +const {CHANNEL_BOOKMARK} = MM_TABLES.SERVER; + +const observeHasPermissionToBookmarks = ( + database: Database, + channelId: string, + public_permission: string, + private_permission: string, +) => { + const serverVersion = observeConfigValue(database, 'Version'); + const currentUser = observeCurrentUser(database); + + return observeChannel(database, channelId).pipe( + combineLatestWith(currentUser, serverVersion), + switchMap(([c, user, version]) => { + if (!c || !user || c.deleteAt !== 0 || user?.isGuest || !isMinimumServerVersion(version || '', 9, 4)) { + return of$(false); + } + + if (isDMorGM(c)) { + return of$(true); + } + + const permission = c.type === General.OPEN_CHANNEL ? public_permission : private_permission; + return observePermissionForChannel(database, c, user, permission, true); + }), + distinctUntilChanged(), + ); +}; + +export const observeCanAddBookmarks = (database: Database, channelId: string) => { + return observeHasPermissionToBookmarks( + database, + channelId, + Permissions.ADD_BOOKMARK_PUBLIC_CHANNEL, + Permissions.ADD_BOOKMARK_PRIVATE_CHANNEL, + ); +}; + +export const observeCanEditBookmarks = (database: Database, channelId: string) => { + return observeHasPermissionToBookmarks( + database, + channelId, + Permissions.EDIT_BOOKMARK_PUBLIC_CHANNEL, + Permissions.EDIT_BOOKMARK_PRIVATE_CHANNEL, + ); +}; + +export const observeCanDeleteBookmarks = (database: Database, channelId: string) => { + return observeHasPermissionToBookmarks( + database, + channelId, + Permissions.DELETE_BOOKMARK_PUBLIC_CHANNEL, + Permissions.DELETE_BOOKMARK_PRIVATE_CHANNEL, + ); +}; + +export const getChannelBookmarkById = async (database: Database, bookmarkId: string) => { + try { + const bookmark = await database.get(CHANNEL_BOOKMARK).find(bookmarkId); + return bookmark; + } catch { + return undefined; + } +}; + +export const queryBookmarks = (database: Database, channelId: string) => { + return database.get(CHANNEL_BOOKMARK).query( + Q.and( + Q.where('channel_id', channelId), + Q.where('delete_at', Q.eq(0)), + ), + Q.sortBy('sort_order', Q.asc), + ); +}; + +export const getBookmarksSince = async (database: Database, channelId: string) => { + try { + const result = await database.get(CHANNEL_BOOKMARK).query( + Q.unsafeSqlQuery( + `SELECT + COALESCE( + MAX ( + MAX(COALESCE(create_at, 0)), + MAX(COALESCE(update_at, 0)), + MAX(COALESCE(delete_at, 0)) + ) + 1, 0) as mostRecent + FROM ChannelBookmark + WHERE channel_id='${channelId}'`), + ).unsafeFetchRaw(); + + return result?.[0]?.mostRecent ?? 0; + } catch { + return 0; + } +}; + +export const observeBookmarks = (database: Database, channelId: string) => { + return queryBookmarks(database, channelId).observeWithColumns(['file_id']); +}; diff --git a/app/queries/servers/entry.ts b/app/queries/servers/entry.ts index ec5149b852f..60ed5ffb0ba 100644 --- a/app/queries/servers/entry.ts +++ b/app/queries/servers/entry.ts @@ -33,6 +33,7 @@ type PrepareModelsArgs = { } const { + CHANNEL_BOOKMARK, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD, @@ -100,6 +101,7 @@ export async function truncateCrtRelatedTables(serverUrl: string): Promise<{erro [`DELETE FROM ${THREAD_PARTICIPANT}`, []], [`DELETE FROM ${TEAM_THREADS_SYNC}`, []], [`DELETE FROM ${MY_CHANNEL}`, []], + [`DELETE FROM ${CHANNEL_BOOKMARK}`, []], ], }); }); diff --git a/app/queries/servers/file.ts b/app/queries/servers/file.ts index 7226058d22b..501181954ff 100644 --- a/app/queries/servers/file.ts +++ b/app/queries/servers/file.ts @@ -2,6 +2,8 @@ // See LICENSE.txt for license information. import {Database, Q} from '@nozbe/watermelondb'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; import {MM_TABLES} from '@constants/database'; @@ -18,6 +20,12 @@ export const getFileById = async (database: Database, fileId: string) => { } }; +export const observeFileById = (database: Database, id: string) => { + return database.get(FILE).query(Q.where('id', id), Q.take(1)).observe().pipe( + switchMap((result) => (result.length ? result[0].observe() : of$(undefined))), + ); +}; + export const queryFilesForPost = (database: Database, postId: string) => { return database.get(FILE).query( Q.where('post_id', postId), diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index 2d0909c9398..55e07186452 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -40,6 +40,7 @@ type ChannelProps = { currentUserId: string; channelType: ChannelType; hasGMasDMFeature: boolean; + includeBookmarkBar?: boolean; }; const edges: Edge[] = ['left', 'right']; @@ -63,6 +64,7 @@ const Channel = ({ channelType, currentUserId, hasGMasDMFeature, + includeBookmarkBar, }: ChannelProps) => { useGMasDMNotice(currentUserId, channelType, dismissedGMasDMNotice, hasGMasDMFeature); const isTablet = useIsTablet(); @@ -124,6 +126,7 @@ const Channel = ({ componentId={componentId} callsEnabledInChannel={isCallsEnabledInChannel} isTabletView={isTabletView} + shouldRenderBookmarks={shouldRender} /> {shouldRender && <> @@ -145,12 +148,13 @@ const Channel = ({ /> } - {showFloatingCallContainer && + {showFloatingCallContainer && shouldRender && } diff --git a/app/screens/channel/header/bookmarks.tsx b/app/screens/channel/header/bookmarks.tsx new file mode 100644 index 00000000000..21a6da48765 --- /dev/null +++ b/app/screens/channel/header/bookmarks.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {View} from 'react-native'; + +import ChannelBookmarks from '@components/channel_bookmarks'; +import {useTheme} from '@context/theme'; +import {useDefaultHeaderHeight} from '@hooks/header'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + canAddBookmarks: boolean; + channelId: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + backgroundColor: theme.sidebarBg, + width: '100%', + position: 'absolute', + }, + content: { + backgroundColor: theme.centerChannelBg, + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + }, + separator: { + height: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + }, + separatorContainer: { + backgroundColor: theme.centerChannelBg, + zIndex: 1, + }, + padding: { + paddingTop: 2, + }, + paddingHorizontal: { + paddingHorizontal: 10, + }, +})); + +const ChannelHeaderBookmarks = ({canAddBookmarks, channelId}: Props) => { + const theme = useTheme(); + const defaultHeight = useDefaultHeaderHeight(); + const styles = getStyleSheet(theme); + + const containerStyle = useMemo(() => ({ + ...styles.content, + top: defaultHeight, + zIndex: 1, + }), [defaultHeight]); + + return ( + + + + + + + + + + + ); +}; + +export default ChannelHeaderBookmarks; diff --git a/app/screens/channel/header/header.tsx b/app/screens/channel/header/header.tsx index 7d83a064ae1..27b58498053 100644 --- a/app/screens/channel/header/header.tsx +++ b/app/screens/channel/header/header.tsx @@ -24,17 +24,21 @@ import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; +import ChannelHeaderBookmarks from './bookmarks'; import QuickActions, {MARGIN, SEPARATOR_HEIGHT} from './quick_actions'; import type {HeaderRightButton} from '@components/navigation_header/header'; import type {AvailableScreens} from '@typings/screens/navigation'; type ChannelProps = { + canAddBookmarks: boolean; channelId: string; channelType: ChannelType; customStatus?: UserCustomStatus; + isBookmarksEnabled: boolean; isCustomStatusEnabled: boolean; isCustomStatusExpired: boolean; + hasBookmarks: boolean; componentId?: AvailableScreens; displayName: string; isOwnDirectMessage: boolean; @@ -43,6 +47,7 @@ type ChannelProps = { teamId: string; callsEnabledInChannel: boolean; isTabletView?: boolean; + shouldRenderBookmarks: boolean; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ @@ -71,9 +76,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ })); const ChannelHeader = ({ - channelId, channelType, componentId, customStatus, displayName, - isCustomStatusEnabled, isCustomStatusExpired, isOwnDirectMessage, memberCount, - searchTerm, teamId, callsEnabledInChannel, isTabletView, + canAddBookmarks, channelId, channelType, componentId, customStatus, displayName, hasBookmarks, + isBookmarksEnabled, isCustomStatusEnabled, isCustomStatusExpired, isOwnDirectMessage, memberCount, + searchTerm, teamId, callsEnabledInChannel, isTabletView, shouldRenderBookmarks, }: ChannelProps) => { const intl = useIntl(); const isTablet = useIsTablet(); @@ -244,6 +249,12 @@ const ChannelHeader = ({ + {isBookmarksEnabled && hasBookmarks && shouldRenderBookmarks && + + } ); }; diff --git a/app/screens/channel/header/index.ts b/app/screens/channel/header/index.ts index e59210e9682..64ee1767c3b 100644 --- a/app/screens/channel/header/index.ts +++ b/app/screens/channel/header/index.ts @@ -4,10 +4,11 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import React from 'react'; import {of as of$} from 'rxjs'; -import {combineLatestWith, switchMap} from 'rxjs/operators'; +import {combineLatestWith, distinctUntilChanged, switchMap} from 'rxjs/operators'; import {General} from '@constants'; import {observeChannel, observeChannelInfo} from '@queries/servers/channel'; +import {observeCanAddBookmarks, queryBookmarks} from '@queries/servers/channel_bookmark'; import {observeConfigBooleanValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system'; import {observeUser} from '@queries/servers/user'; import { @@ -77,11 +78,21 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps const memberCount = channelInfo.pipe( combineLatestWith(dmUser), switchMap(([ci, dm]) => of$(dm ? undefined : ci?.memberCount))); + const hasBookmarks = queryBookmarks(database, channelId).observeCount(false).pipe( + switchMap((count) => of$(count > 0)), + distinctUntilChanged(), + ); + + const isBookmarksEnabled = observeConfigBooleanValue(database, 'FeatureFlagChannelBookmarks'); + const canAddBookmarks = observeCanAddBookmarks(database, channelId); return { + canAddBookmarks, channelType, customStatus, displayName, + hasBookmarks, + isBookmarksEnabled, isCustomStatusEnabled, isCustomStatusExpired, isOwnDirectMessage, diff --git a/app/screens/channel/index.tsx b/app/screens/channel/index.tsx index 094659b1bc2..049452fde9e 100644 --- a/app/screens/channel/index.tsx +++ b/app/screens/channel/index.tsx @@ -2,15 +2,16 @@ // See LICENSE.txt for license information. import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {of as of$, switchMap} from 'rxjs'; +import {combineLatestWith, distinctUntilChanged, of as of$, switchMap} from 'rxjs'; import {observeCallStateInChannel, observeIsCallsEnabledInChannel} from '@calls/observers'; import {Preferences} from '@constants'; import {withServerUrl} from '@context/server'; import {observeCurrentChannel} from '@queries/servers/channel'; +import {queryBookmarks} from '@queries/servers/channel_bookmark'; import {observeHasGMasDMFeature} from '@queries/servers/features'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; -import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system'; +import {observeConfigBooleanValue, observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system'; import Channel from './channel'; @@ -26,6 +27,21 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => { const channelType = observeCurrentChannel(database).pipe(switchMap((c) => of$(c?.type))); const currentUserId = observeCurrentUserId(database); const hasGMasDMFeature = observeHasGMasDMFeature(database); + const isBookmarksEnabled = observeConfigBooleanValue(database, 'FeatureFlagChannelBookmarks'); + const hasBookmarks = (count: number) => of$(count > 0); + const includeBookmarkBar = channelId.pipe( + combineLatestWith(isBookmarksEnabled), + switchMap(([cId, enabled]) => { + if (!enabled) { + return of$(false); + } + + return queryBookmarks(database, cId).observeCount(false).pipe( + switchMap(hasBookmarks), + distinctUntilChanged(), + ); + }), + ); return { channelId, @@ -35,6 +51,7 @@ const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => { channelType, currentUserId, hasGMasDMFeature, + includeBookmarkBar, }; }); diff --git a/app/screens/channel_bookmark/components/bookmark_detail.tsx b/app/screens/channel_bookmark/components/bookmark_detail.tsx new file mode 100644 index 00000000000..3f68a4c5e5d --- /dev/null +++ b/app/screens/channel_bookmark/components/bookmark_detail.tsx @@ -0,0 +1,140 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Button} from '@rneui/base'; +import React, {useCallback, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {TextInput, View} from 'react-native'; + +import BookmarkIcon from '@components/channel_bookmarks/channel_bookmark/bookmark_icon'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {openAsBottomSheet} from '@screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + disabled: boolean; + emoji?: string; + file?: ExtractedFileInfo; + imageUrl?: string; + setBookmarkDisplayName: (displayName: string) => void; + setBookmarkEmoji: (emoji?: string) => void; + title: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + title: { + color: theme.centerChannelColor, + marginBottom: 8, + ...typography('Heading', 100, 'SemiBold'), + }, + container: { + flexDirection: 'row', + }, + disabled: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.16), + }, + iconContainer: { + borderWidth: 1, + paddingLeft: 16, + paddingRight: 8, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderRightWidth: 0, + borderTopLeftRadius: 4, + borderBottomLeftRadius: 4, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + }, + iconButton: { + backgroundColor: 'transparent', + alignItems: 'center', + justifyContent: 'center', + }, + imageContainer: {width: 28, height: 28, marginRight: 2}, + image: {width: 24, height: 24}, + input: { + borderBottomRightRadius: 4, + borderTopRightRadius: 4, + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + paddingVertical: 12, + paddingHorizontal: 16, + flex: 1, + color: theme.centerChannelColor, + ...typography('Body', 200), + lineHeight: undefined, + }, + genericBookmark: { + alignSelf: 'center', + top: 2, + }, +})); + +const BookmarkDetail = ({disabled, emoji, file, imageUrl, setBookmarkDisplayName, setBookmarkEmoji, title}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const isTablet = useIsTablet(); + const paddingStyle = useMemo(() => ({paddingHorizontal: isTablet ? 42 : 0}), [isTablet]); + const styles = getStyleSheet(theme); + + const openEmojiPicker = useCallback(() => { + openAsBottomSheet({ + closeButtonId: 'close-add-emoji', + screen: Screens.EMOJI_PICKER, + theme, + title: intl.formatMessage({id: 'channel_bookmark.add.emoji', defaultMessage: 'Add emoji'}), + props: { + onEmojiPress: setBookmarkEmoji, + imageUrl, + file, + }, + }); + }, [imageUrl, file, theme, setBookmarkEmoji]); + + return ( + + + + + + + + ); +}; + +export default BookmarkDetail; diff --git a/app/screens/channel_bookmark/components/bookmark_file/bookmark_file.tsx b/app/screens/channel_bookmark/components/bookmark_file/bookmark_file.tsx new file mode 100644 index 00000000000..e77550b09d8 --- /dev/null +++ b/app/screens/channel_bookmark/components/bookmark_file/bookmark_file.tsx @@ -0,0 +1,360 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Button} from '@rneui/base'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {View, Text, Platform, type Insets} from 'react-native'; +import {Shadow} from 'react-native-shadow-2'; + +import {uploadFile} from '@actions/remote/file'; +import CompassIcon from '@app/components/compass_icon'; +import FileIcon from '@app/components/files/file_icon'; +import ProgressBar from '@app/components/progress_bar'; +import FormattedText from '@components/formatted_text'; +import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {fileSizeWarning, getExtensionFromMime, getFormattedFileSize} from '@utils/file'; +import PickerUtil from '@utils/file/file_picker'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type {ClientResponse} from '@mattermost/react-native-network-client'; + +type Props = { + channelId: string; + close: () => void; + disabled: boolean; + initialFile?: FileInfo; + maxFileSize: number; + setBookmark: (file: ExtractedFileInfo) => void; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + viewContainer: { + marginTop: 32, + marginBottom: 24, + width: '100%', + flex: 0, + }, + title: { + color: theme.centerChannelColor, + marginBottom: 8, + ...typography('Heading', 100, 'SemiBold'), + }, + shadowContainer: { + alignItems: 'center', + borderColor: changeOpacity(theme.centerChannelColor, 0.16), + borderWidth: 1, + borderRadius: 4, + }, + fileContainer: { + height: 64, + flexDirection: 'row', + paddingLeft: 12, + alignItems: 'center', + }, + fileInfoContainer: { + paddingHorizontal: 16, + flex: 1, + justifyContent: 'center', + }, + filename: { + color: theme.centerChannelColor, + ...typography('Body', 200, 'SemiBold'), + }, + fileInfo: { + color: changeOpacity(theme.centerChannelColor, 0.64), + textTransform: 'uppercase', + ...typography('Body', 75), + }, + uploadError: { + color: theme.errorTextColor, + ...typography('Body', 75), + }, + retry: { + paddingRight: 20, + height: 40, + justifyContent: 'center', + }, + removeContainer: { + position: 'absolute', + elevation: 11, + top: -18, + right: -12, + width: 24, + height: 24, + }, + removeButton: { + borderRadius: 12, + alignSelf: 'center', + marginTop: Platform.select({ + ios: 5.4, + android: 4.75, + }), + backgroundColor: theme.centerChannelBg, + width: 24, + height: 25, + }, + uploading: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 75), + }, + progressContainer: { + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.1)', + bottom: 2, + borderBottomRightRadius: 4, + borderBottomLeftRadius: 4, + }, + progress: { + borderRadius: 4, + borderTopRightRadius: 0, + borderTopLeftRadius: 0, + }, +})); + +const shadowSides = {top: false, bottom: true, end: true, start: false}; +const hitSlop: Insets = {top: 10, bottom: 10, left: 10, right: 10}; + +const BookmarkFile = ({channelId, close, disabled, initialFile, maxFileSize, setBookmark}: Props) => { + const theme = useTheme(); + const intl = useIntl(); + const isTablet = useIsTablet(); + const serverUrl = useServerUrl(); + const [file, setFile] = useState(initialFile); + const [error, setError] = useState(''); + const [progress, setProgress] = useState(0); + const [uploading, setUploading] = useState(false); + const [failed, setFailed] = useState(false); + const styles = getStyleSheet(theme); + const subContainerStyle = useMemo(() => [styles.viewContainer, {paddingHorizontal: isTablet ? 42 : 0}], [isTablet]); + const cancelUpload = useRef<() => void | undefined>(); + + const onProgress = useCallback((p: number, bytes: number) => { + if (!file) { + return; + } + + const f: ExtractedFileInfo = {...file}; + f.bytesRead = bytes; + + setProgress(p); + setFile(f); + }, []); + + const onComplete = useCallback((response: ClientResponse) => { + cancelUpload.current = undefined; + if (response.code !== 201 || !response.data) { + setUploadError(); + return; + } + + const data = response.data.file_infos as FileInfo[] | undefined; + if (!data?.length) { + setUploadError(); + return; + } + + const fileInfo = data[0]; + setFile(fileInfo); + setBookmark(fileInfo); + setUploading(false); + setProgress(1); + setFailed(false); + setError(''); + }, []); + + const onError = useCallback(() => { + cancelUpload.current = undefined; + setUploadError(); + }, []); + + const setUploadError = useCallback(() => { + setProgress(0); + setUploading(false); + setFailed(true); + + setError(intl.formatMessage({ + id: 'channel_bookmark.add.file_upload_error', + defaultMessage: 'Error uploading file. Please try again.', + })); + }, [file, intl]); + + const startUpload = useCallback((fileInfo: FileInfo | ExtractedFileInfo) => { + setUploading(true); + setProgress(0); + + const {cancel, error: uploadError} = uploadFile( + serverUrl, + fileInfo, + channelId, + onProgress, + onComplete, + onError, + fileInfo.bytesRead, + true, + ); + + if (cancel) { + cancelUpload.current = cancel; + } + + if (uploadError) { + setUploadError(); + cancelUpload.current?.(); + } + }, [channelId, onProgress, onComplete, onError, serverUrl]); + + const browseFile = useCallback(async () => { + const picker = new PickerUtil(intl, (files) => { + if (files.length) { + const f = files[0]; + const extension = getExtensionFromMime(f.mime_type) || ''; + const fileWithExtension: ExtractedFileInfo = {...f, extension}; + setFile(fileWithExtension); + startUpload(fileWithExtension); + } + }); + + const res = await picker.attachFileFromFiles(undefined, false); + if (res.error) { + close(); + } + }, [close, startUpload]); + + const removeAndUpload = useCallback(() => { + cancelUpload.current?.(); + browseFile(); + }, [file, browseFile]); + + const retry = useCallback(() => { + cancelUpload.current?.(); + if (file) { + startUpload(file); + } + }, [file, startUpload]); + + useEffect(() => { + if (!initialFile) { + browseFile(); + } + + return () => { + cancelUpload.current?.(); + }; + }, []); + + useEffect(() => { + if (uploading) { + return; + } + + if (!file?.id && (file?.size || 0) > maxFileSize) { + setError(fileSizeWarning(intl, maxFileSize)); + return; + } + + if (!file?.id && file?.name) { + setBookmark(file); + } + }, [file, intl, maxFileSize, uploading]); + + let info; + if (error) { + info = ( + + {error} + + ); + } else if (uploading) { + info = ( + + ); + } else if (file) { + info = ( + + {`${file.extension} ${getFormattedFileSize(file.size || 0)}`} + + ); + } + + if (file) { + return ( + + + + + + + + {decodeURIComponent(file.name.trim())} + + {info} + + {failed && + + } + + + + + + + + {uploading && + + + + } + + ); + } + + return null; +}; + +export default BookmarkFile; diff --git a/app/screens/channel_bookmark/components/bookmark_file/index.ts b/app/screens/channel_bookmark/components/bookmark_file/index.ts new file mode 100644 index 00000000000..723083e709b --- /dev/null +++ b/app/screens/channel_bookmark/components/bookmark_file/index.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; + +import {DEFAULT_SERVER_MAX_FILE_SIZE} from '@constants/post_draft'; +import {observeConfigIntValue} from '@queries/servers/system'; + +import BookmarkFile from './bookmark_file'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + return { + maxFileSize: observeConfigIntValue(database, 'MaxFileSize', DEFAULT_SERVER_MAX_FILE_SIZE), + }; +}); + +export default withDatabase(enhanced(BookmarkFile)); diff --git a/app/screens/channel_bookmark/components/bookmark_link.tsx b/app/screens/channel_bookmark/components/bookmark_link.tsx new file mode 100644 index 00000000000..a4d59ca47bd --- /dev/null +++ b/app/screens/channel_bookmark/components/bookmark_link.tsx @@ -0,0 +1,133 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {Platform, View} from 'react-native'; + +import {useIsTablet} from '@app/hooks/device'; +import FloatingTextInput from '@components/floating_text_input_label'; +import FormattedText from '@components/formatted_text'; +import Loading from '@components/loading'; +import {useTheme} from '@context/theme'; +import {debounce} from '@helpers/api/general'; +import useDidUpdate from '@hooks/did_update'; +import {fetchOpenGraph} from '@utils/opengraph'; +import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; +import {getUrlAfterRedirect} from '@utils/url'; + +type Props = { + disabled: boolean; + initialUrl?: string; + resetBookmark: () => void; + setBookmark: (url: string, title: string, imageUrl: string) => void; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + viewContainer: { + marginVertical: 32, + width: '100%', + }, + description: { + marginTop: 8, + }, + descriptionText: { + ...typography('Body', 75), + color: changeOpacity(theme.centerChannelColor, 0.56), + }, + loading: { + alignItems: 'flex-end', + justifyContent: 'center', + }, +})); + +const BookmarkLink = ({disabled, initialUrl = '', resetBookmark, setBookmark}: Props) => { + const theme = useTheme(); + const intl = useIntl(); + const isTablet = useIsTablet(); + const [error, setError] = useState(''); + const [url, setUrl] = useState(initialUrl); + const [loading, setLoading] = useState(false); + const styles = getStyleSheet(theme); + const keyboard = (Platform.OS === 'android') ? 'default' : 'url'; + const subContainerStyle = useMemo(() => [styles.viewContainer, {paddingHorizontal: isTablet ? 42 : 0}], [isTablet, styles]); + const descContainer = useMemo(() => [styles.description, {paddingHorizontal: isTablet ? 42 : 0}], [isTablet, styles]); + + const validateAndFetchOG = useCallback(debounce(async (text: string) => { + setLoading(true); + let link = await getUrlAfterRedirect(text, false); + + if (link.error) { + link = await getUrlAfterRedirect(text, true); + } + + if (link.url) { + const result = await fetchOpenGraph(link.url, true); + const title = result.title || text; + const imageUrl = result.favIcon || result.imageURL || ''; + setLoading(false); + setBookmark(link.url, title, imageUrl); + return; + } + setError(intl.formatMessage({ + id: 'channel_bookmark_add.link.invalid', + defaultMessage: 'Please enter a valid link', + })); + setLoading(false); + }, 500), [intl]); + + const onChangeText = useCallback((text: string) => { + resetBookmark(); + setUrl(text); + setError(''); + }, [resetBookmark]); + + const onSubmitEditing = useCallback(() => { + if (url) { + validateAndFetchOG(url); + } + }, [url, error]); + + useDidUpdate(debounce(() => { + onSubmitEditing(); + }, 300), [onSubmitEditing]); + + return ( + + + } + /> + + + + + ); +}; + +export default BookmarkLink; diff --git a/app/screens/channel_bookmark/index.tsx b/app/screens/channel_bookmark/index.tsx new file mode 100644 index 00000000000..81f310494ad --- /dev/null +++ b/app/screens/channel_bookmark/index.tsx @@ -0,0 +1,323 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {Alert, View, type AlertButton} from 'react-native'; +import {SafeAreaView, type Edge} from 'react-native-safe-area-context'; + +import {addRecentReaction} from '@actions/local/reactions'; +import {createChannelBookmark, deleteChannelBookmark, editChannelBookmark} from '@actions/remote/channel_bookmark'; +import Button from '@components/button'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation'; +import {getFullErrorMessage} from '@utils/errors'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import BookmarkDetail from './components/bookmark_detail'; +import AddBookmarkFile from './components/bookmark_file'; +import BookmarkLink from './components/bookmark_link'; + +import type {AvailableScreens} from '@typings/screens/navigation'; + +type Props = { + bookmark?: ChannelBookmark; + canDeleteBookmarks?: boolean; + channelId: string; + closeButtonId: string; + componentId: AvailableScreens; + file?: FileInfo; + ownerId: string; + type: ChannelBookmarkType; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + content: { + flex: 1, + paddingHorizontal: 20, + paddingBottom: 16, + }, + progress: { + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.1)', + borderRadius: 4, + paddingLeft: 3, + marginTop: 12, + }, + deleteBg: {backgroundColor: changeOpacity(theme.errorTextColor, 0.16)}, + deleteContainer: {paddingTop: 32}, + deleteText: {color: theme.errorTextColor}, +})); + +const RIGHT_BUTTON = buildNavigationButton('edit-bookmark', 'channel_bookmark.edit.save_button'); +const edges: Edge[] = ['bottom', 'left', 'right']; +const emptyBookmark: ChannelBookmark = { + id: '', + create_at: 0, + update_at: 0, + delete_at: 0, + channel_id: '', + owner_id: '', + display_name: '', + sort_order: 0, + type: 'link', +}; + +const ChannelBookmarkAddOrEdit = ({ + bookmark: original, + canDeleteBookmarks = false, + channelId, + closeButtonId, + componentId, + file: originalFile, + ownerId, + type, +}: Props) => { + const {formatMessage} = useIntl(); + const theme = useTheme(); + const serverUrl = useServerUrl(); + const styles = getStyleSheet(theme); + const [bookmark, setBookmark] = useState(original); + const [file, setFile] = useState(originalFile); + const [isSaving, setIsSaving] = useState(false); + + const enableSaveButton = useCallback((enabled: boolean) => { + setButtons(componentId, { + rightButtons: [{ + ...RIGHT_BUTTON, + color: theme.sidebarHeaderTextColor, + text: formatMessage({id: 'channel_bookmark.edit.save_button', defaultMessage: 'Save'}), + enabled, + }], + }); + }, [formatMessage, theme]); + + const setBookmarkToSave = useCallback((b?: ChannelBookmark) => { + enableSaveButton((b?.type === 'link' && Boolean(b?.link_url)) || (b?.type === 'file' && Boolean(b.file_id))); + setBookmark(b); + }, []); + + const handleError = useCallback((error: string, buttons?: AlertButton[]) => { + const title = original ? formatMessage({id: 'channel_bookmark.edit.failed_title', defaultMessage: 'Error editing bookmark'}) : formatMessage({id: 'channel_bookmark.add.failed_title', defaultMessage: 'Error adding bookmark'}); + Alert.alert( + title, + formatMessage({ + id: 'channel_bookmark.add_edit.failed_desc', + defaultMessage: 'Details: {error}', + }, {error}), + buttons, + ); + setIsSaving(false); + const enabled = Boolean(bookmark?.display_name && + ((bookmark?.type === 'link' && Boolean(bookmark?.link_url)) || (bookmark?.type === 'file' && Boolean(bookmark.file_id)))); + enableSaveButton(enabled); + }, [bookmark, enableSaveButton, formatMessage]); + + const close = useCallback(() => { + return dismissModal({componentId}); + }, [componentId]); + + const createBookmark = useCallback(async (b: ChannelBookmark) => { + const res = await createChannelBookmark(serverUrl, channelId, b); + if (res.bookmark) { + close(); + return; + } + + handleError((res.error as Error).message); + }, [channelId, handleError, serverUrl]); + + const updateBookmark = useCallback(async (b: ChannelBookmark) => { + const res = await editChannelBookmark(serverUrl, b); + if (res.bookmarks) { + close(); + return; + } + + handleError((res.error as Error).message); + }, [handleError, serverUrl]); + + const setLinkBookmark = useCallback((url: string, title: string, imageUrl: string) => { + const b: ChannelBookmark = { + ...(bookmark || emptyBookmark), + owner_id: ownerId, + channel_id: channelId, + link_url: url, + image_url: imageUrl, + display_name: title, + type: 'link', + }; + + setBookmarkToSave(b); + }, [bookmark, channelId, setBookmarkToSave, ownerId]); + + const setFileBookmark = useCallback((f: ExtractedFileInfo) => { + const b: ChannelBookmark = { + ...(bookmark || emptyBookmark), + owner_id: ownerId, + channel_id: channelId, + display_name: decodeURIComponent(f.name), + type: 'file', + file_id: f.id, + }; + setBookmarkToSave(b); + setFile(f); + }, [bookmark, channelId, ownerId]); + + const setBookmarkDisplayName = useCallback((displayName: string) => { + if (bookmark) { + setBookmark((prev) => ({ + ...(prev!), + display_name: displayName, + })); + } + + enableSaveButton(Boolean(displayName)); + }, [bookmark, enableSaveButton]); + + const setBookmarkEmoji = useCallback((emoji?: string) => { + if (bookmark) { + setBookmark((prev) => ({ + ...prev!, + emoji, + })); + + const prevEmoji = original ? original.emoji : ''; + if (prevEmoji !== emoji) { + enableSaveButton(true); + } + } + + if (emoji) { + addRecentReaction(serverUrl, [emoji]); + } + }, [bookmark, enableSaveButton, serverUrl]); + + const resetBookmark = useCallback(() => { + setBookmarkToSave(original); + setFile(originalFile); + }, [setBookmarkToSave]); + + const onSaveBookmark = useCallback(async () => { + if (bookmark) { + enableSaveButton(false); + setIsSaving(true); + if (original) { + updateBookmark(bookmark); + return; + } + + createBookmark(bookmark); + } + }, [bookmark, createBookmark, updateBookmark]); + + const handleDelete = useCallback(async () => { + if (bookmark) { + setIsSaving(true); + enableSaveButton(false); + const {error} = await deleteChannelBookmark(serverUrl, bookmark.channel_id, bookmark.id); + if (error) { + Alert.alert( + formatMessage({id: 'channel_bookmark.delete.failed_title', defaultMessage: 'Error deleting bookmark'}), + formatMessage({ + id: 'channel_bookmark.add_edit.failed_desc', + defaultMessage: 'Details: {error}', + }, {error: getFullErrorMessage(error)}), + ); + setIsSaving(false); + enableSaveButton(true); + return; + } + + close(); + } + }, [bookmark, serverUrl, close]); + + const onDelete = useCallback(async () => { + if (bookmark) { + Alert.alert( + formatMessage({id: 'channel_bookmark.delete.confirm_title', defaultMessage: 'Delete bookmark'}), + formatMessage({id: 'channel_bookmark.delete.confirm', defaultMessage: 'You sure want to delete the bookmark {displayName}?'}, { + displayName: bookmark.display_name, + }), + [{ + text: formatMessage({id: 'channel_bookmark.delete.yes', defaultMessage: 'Yes'}), + style: 'destructive', + isPreferred: true, + onPress: handleDelete, + }, { + text: formatMessage({id: 'channel_bookmark.add.file_cancel', defaultMessage: 'Cancel'}), + style: 'cancel', + }], + ); + } + }, [bookmark, handleDelete]); + + useEffect(() => { + enableSaveButton(false); + }, []); + + useNavButtonPressed(RIGHT_BUTTON.id, componentId, onSaveBookmark, [bookmark]); + useNavButtonPressed(closeButtonId, componentId, close, [close]); + useAndroidHardwareBackHandler(componentId, close); + + return ( + + {type === 'link' && + + } + {type === 'file' && + + } + {Boolean(bookmark) && + <> + + {canDeleteBookmarks && + + + + ); + } else { + content = ( + + + + + ); + } + return ( - {loginError || error ? ( - - - - {`${loginError || error}.`} - - - - ) : ( - - - - - )} + {content} ); }; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index e03c2695e82..27e7df620fe 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -703,6 +703,8 @@ "mobile.no_results.spelling": "Check the spelling or try another search.", "mobile.oauth.failed_to_login": "Your login attempt failed. Please try again.", "mobile.oauth.something_wrong.okButton": "OK", + "mobile.oauth.success.description": "Signing in now, just a moment...", + "mobile.oauth.success.title": "Authentication successful", "mobile.oauth.switch_to_browser": "You are being redirected to your login provider", "mobile.oauth.switch_to_browser.error_title": "Sign in error", "mobile.oauth.switch_to_browser.title": "Redirecting...", From 13ca3461556c2677edef7182c8e393b060a8489d Mon Sep 17 00:00:00 2001 From: Claudio Costa Date: Wed, 28 Aug 2024 07:28:12 -0600 Subject: [PATCH 58/67] [MM-60291] Fix rendering of live captions (#8185) * Load jobs state from call state * Fix rendering of live captions overlay --- app/products/calls/components/captions.tsx | 6 +++++- app/products/calls/connection/websocket_event_handlers.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/products/calls/components/captions.tsx b/app/products/calls/components/captions.tsx index 47bffee4197..dff6410d0b3 100644 --- a/app/products/calls/components/captions.tsx +++ b/app/products/calls/components/captions.tsx @@ -21,11 +21,15 @@ const styles = StyleSheet.create({ captionContainer: { display: 'flex', height: 400, - bottom: -352, // 48-400, to place the bottoms at the same place + bottom: 400 - 48, // to place the bottoms at the same place gap: 8, alignItems: 'center', flexDirection: 'column-reverse', overflow: 'hidden', + + // needed so that events (e.g., long press on participants) + // still work when the captions overlay is rendered on top + pointerEvents: 'none', }, caption: { paddingTop: 1, diff --git a/app/products/calls/connection/websocket_event_handlers.ts b/app/products/calls/connection/websocket_event_handlers.ts index 9a0693ddac5..5c1c9aac003 100644 --- a/app/products/calls/connection/websocket_event_handlers.ts +++ b/app/products/calls/connection/websocket_event_handlers.ts @@ -250,5 +250,13 @@ export const handleCallState = (serverUrl: string, msg: WebSocketMessage Date: Thu, 29 Aug 2024 00:02:40 +0800 Subject: [PATCH 59/67] add unit tests to app/utils/post (#8142) --- app/constants/post.ts | 1 + app/utils/post/index.test.ts | 623 ++++++++++++++++++++++++++++++++--- 2 files changed, 579 insertions(+), 45 deletions(-) diff --git a/app/constants/post.ts b/app/constants/post.ts index caefdbf3655..cc09dfb6b01 100644 --- a/app/constants/post.ts +++ b/app/constants/post.ts @@ -77,4 +77,5 @@ export default { PostTypes.ADD_TO_TEAM, PostTypes.REMOVE_FROM_TEAM, ], + POST_TIME_TO_FAIL, }; diff --git a/app/utils/post/index.test.ts b/app/utils/post/index.test.ts index 8fd8bd93c0e..463577f2bd4 100644 --- a/app/utils/post/index.test.ts +++ b/app/utils/post/index.test.ts @@ -1,52 +1,585 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import * as utils from './index'; +import {createIntl} from 'react-intl'; +import {Alert} from 'react-native'; + +import {getUsersCountFromMentions} from '@actions/local/post'; +import {General, Post} from '@constants'; +import {DEFAULT_LOCALE, getTranslations} from '@i18n'; +import {getUserById} from '@queries/servers/user'; +import {toMilliseconds} from '@utils/datetime'; + +import { + areConsecutivePosts, + isFromWebhook, + isEdited, + isPostEphemeral, + isPostFailed, + isPostPendingOrFailed, + isSystemMessage, + fromAutoResponder, + postUserDisplayName, + shouldIgnorePost, + processPostsFetched, + getLastFetchedAtFromPosts, + moreThan5minAgo, + hasSpecialMentions, + persistentNotificationsConfirmation, +} from '.'; + +import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; + +jest.mock('@actions/local/post', () => ({ + getUsersCountFromMentions: jest.fn(), +})); + +jest.mock('@queries/servers/user', () => ({ + getUserById: jest.fn(), +})); + +jest.mock('@database/manager', () => ({ + getServerDatabaseAndOperator: jest.fn().mockReturnValue({ + database: {}, + }), +})); describe('post utils', () => { - test.each([ - ['@here where is Jessica Hyde', true], - ['@all where is Jessica Hyde', true], - ['@channel where is Jessica Hyde', true], - - ['where is Jessica Hyde @here', true], - ['where is Jessica Hyde @all', true], - ['where is Jessica Hyde @channel', true], - - ['where is Jessica @here Hyde', true], - ['where is Jessica @all Hyde', true], - ['where is Jessica @channel Hyde', true], - - ['where is Jessica Hyde\n@here', true], - ['where is Jessica Hyde\n@all', true], - ['where is Jessica Hyde\n@channel', true], - - ['where is Jessica\n@here Hyde', true], - ['where is Jessica\n@all Hyde', true], - ['where is Jessica\n@channel Hyde', true], - - ['where is Jessica Hyde @her', false], - ['where is Jessica Hyde @al', false], - ['where is Jessica Hyde @chann', false], - - ['where is Jessica Hyde@here', false], - ['where is Jessica Hyde@all', false], - ['where is Jessica Hyde@channel', false], - - ['where is Jessica @hereHyde', false], - ['where is Jessica @allHyde', false], - ['where is Jessica @channelHyde', false], - - ['@herewhere is Jessica Hyde@here', false], - ['@allwhere is Jessica Hyde@all', false], - ['@channelwhere is Jessica Hyde@channel', false], - - ['where is Jessica Hyde here', false], - ['where is Jessica Hyde all', false], - ['where is Jessica Hyde channel', false], - - ['where is Jessica Hyde', false], - ])('hasSpecialMentions: %s => %s', (message, expected) => { - expect(utils.hasSpecialMentions(message)).toBe(expected); + describe('areConsecutivePosts', () => { + it('should return true for consecutive posts from the same user within the collapse timeout', () => { + const post = { + userId: 'user1', + createAt: 1000, + props: {}, + } as PostModel; + const previousPost = { + userId: 'user1', + createAt: 500, + props: {}, + } as PostModel; + + const result = areConsecutivePosts(post, previousPost); + expect(result).toBe(true); + }); + + it('should return false for posts from different users', () => { + const post = { + userId: 'user1', + createAt: 1000, + props: {}, + } as PostModel; + const previousPost = { + userId: 'user2', + createAt: 500, + props: {}, + } as PostModel; + + const result = areConsecutivePosts(post, previousPost); + expect(result).toBe(false); + }); + }); + + describe('isFromWebhook', () => { + it('should return true for posts from a webhook', () => { + const post = { + props: { + from_webhook: 'true', + }, + } as PostModel; + + const result = isFromWebhook(post); + expect(result).toBe(true); + }); + + it('should return false for posts not from a webhook', () => { + const post = { + props: { + from_webhook: 'false', + }, + } as PostModel; + + const result = isFromWebhook(post); + expect(result).toBe(false); + }); + }); + + describe('isEdited', () => { + it('should return true if the post is edited', () => { + const post = { + editAt: 1000, + } as PostModel; + + const result = isEdited(post); + expect(result).toBe(true); + }); + + it('should return false if the post is not edited', () => { + const post = { + editAt: 0, + } as PostModel; + + const result = isEdited(post); + expect(result).toBe(false); + }); + }); + + describe('isPostEphemeral', () => { + it('should return true for an ephemeral post', () => { + const post = { + type: Post.POST_TYPES.EPHEMERAL, + } as PostModel; + + const result = isPostEphemeral(post); + expect(result).toBe(true); + }); + + it('should return false for a non-ephemeral post', () => { + const post = { + type: 'normal', + } as PostModel; + + const result = isPostEphemeral(post); + expect(result).toBe(false); + }); + }); + + describe('isPostFailed', () => { + it('should return true if the post has failed prop', () => { + const post = { + props: { + failed: true, + }, + pendingPostId: 'id', + id: 'id', + updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000, + } as PostModel; + + const result = isPostFailed(post); + expect(result).toBe(true); + }); + + it('should return true if the post is pending and the update time has exceeded the failure time', () => { + const post = { + props: {}, + pendingPostId: 'id', + id: 'id', + updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000, + } as PostModel; + + const result = isPostFailed(post); + expect(result).toBe(true); + }); + + it('should return false if the post is not failed', () => { + const post = { + props: {}, + pendingPostId: 'id', + id: 'id', + updateAt: Date.now(), + } as PostModel; + + const result = isPostFailed(post); + expect(result).toBe(false); + }); + }); + + describe('isPostPendingOrFailed', () => { + it('should return true if the post is pending', () => { + const post = { + pendingPostId: 'id', + id: 'id', + props: {}, + } as PostModel; + + const result = isPostPendingOrFailed(post); + expect(result).toBe(true); + }); + + it('should return true if the post has failed', () => { + const post = { + pendingPostId: 'id', + id: 'id', + updateAt: Date.now() - Post.POST_TIME_TO_FAIL - 1000, + props: {}, + } as PostModel; + + const result = isPostPendingOrFailed(post); + expect(result).toBe(true); + }); + + it('should return false if the post is neither pending nor failed', () => { + const post = { + pendingPostId: 'differentId', + id: 'id', + props: {}, + } as PostModel; + + const result = isPostPendingOrFailed(post); + expect(result).toBe(false); + }); + }); + + describe('isSystemMessage', () => { + it('should return true if the post is a system message', () => { + const post = { + type: `${Post.POST_TYPES.SYSTEM_MESSAGE_PREFIX}any_type`, + } as PostModel; + + const result = isSystemMessage(post); + expect(result).toBe(true); + }); + + it('should return false if the post is not a system message', () => { + const post = { + type: 'normal_type', + } as PostModel; + + const result = isSystemMessage(post); + expect(result).toBe(false); + }); + }); + + describe('hasSpecialMentions', () => { + test.each([ + ['@here where is Jessica Hyde', true], + ['@all where is Jessica Hyde', true], + ['@channel where is Jessica Hyde', true], + + ['where is Jessica Hyde @here', true], + ['where is Jessica Hyde @all', true], + ['where is Jessica Hyde @channel', true], + + ['where is Jessica @here Hyde', true], + ['where is Jessica @all Hyde', true], + ['where is Jessica @channel Hyde', true], + + ['where is Jessica Hyde\n@here', true], + ['where is Jessica Hyde\n@all', true], + ['where is Jessica Hyde\n@channel', true], + + ['where is Jessica\n@here Hyde', true], + ['where is Jessica\n@all Hyde', true], + ['where is Jessica\n@channel Hyde', true], + + ['where is Jessica Hyde @her', false], + ['where is Jessica Hyde @al', false], + ['where is Jessica Hyde @chann', false], + + ['where is Jessica Hyde@here', false], + ['where is Jessica Hyde@all', false], + ['where is Jessica Hyde@channel', false], + + ['where is Jessica @hereHyde', false], + ['where is Jessica @allHyde', false], + ['where is Jessica @channelHyde', false], + + ['@herewhere is Jessica Hyde@here', false], + ['@allwhere is Jessica Hyde@all', false], + ['@channelwhere is Jessica Hyde@channel', false], + + ['where is Jessica Hyde here', false], + ['where is Jessica Hyde all', false], + ['where is Jessica Hyde channel', false], + + ['where is Jessica Hyde', false], + ])('hasSpecialMentions: %s => %s', (message, expected) => { + expect(hasSpecialMentions(message)).toBe(expected); + }); + }); + + describe('fromAutoResponder', () => { + it('should return true if the post is from an auto responder', () => { + const post = { + type: Post.POST_TYPES.SYSTEM_AUTO_RESPONDER, + } as PostModel; + + const result = fromAutoResponder(post); + expect(result).toBe(true); + }); + + it('should return false if the post is not from an auto responder', () => { + const post = { + type: 'normal_type', + } as PostModel; + + const result = fromAutoResponder(post); + expect(result).toBe(false); + }); + }); + + describe('persistentNotificationsConfirmation', () => { + const serverUrl = 'http://server'; + const value = '@user'; + const mentionsList = ['@user']; + const sendMessage = jest.fn(); + const persistentNotificationMaxRecipients = 10; + const persistentNotificationInterval = 5; + const currentUserId = 'current_user_id'; + const channelName = 'channel_id__teammate_id'; + const intl = createIntl({locale: DEFAULT_LOCALE, messages: getTranslations(DEFAULT_LOCALE)}); + + it('should show alert with DM channel description when channelType is DM_CHANNEL', async () => { + const mockUser = {username: 'teammate'}; + (getUserById as jest.Mock).mockResolvedValue(mockUser); + + await persistentNotificationsConfirmation( + serverUrl, + value, + mentionsList, + intl, + sendMessage, + persistentNotificationMaxRecipients, + persistentNotificationInterval, + currentUserId, + channelName, + General.DM_CHANNEL, + ); + + expect(Alert.alert).toHaveBeenCalledWith( + intl.formatMessage({ + id: 'persistent_notifications.confirm.title', + defaultMessage: 'Send persistent notifications', + }), + intl.formatMessage({ + id: 'persistent_notifications.dm_channel.description', + defaultMessage: '@{username} will be notified every {interval, plural, one {minute} other {{interval} minutes}} until they’ve acknowledged or replied to the message.', + }, { + interval: persistentNotificationInterval, + username: mockUser.username, + }), + expect.any(Array), + ); + }); + + it('should show alert when special mentions are present', async () => { + await persistentNotificationsConfirmation( + serverUrl, + '@channel', + mentionsList, + intl, + sendMessage, + persistentNotificationMaxRecipients, + persistentNotificationInterval, + currentUserId, + channelName, + ); + + expect(Alert.alert).toHaveBeenCalledWith( + '', + intl.formatMessage({ + id: 'persistent_notifications.error.special_mentions', + defaultMessage: 'Cannot use @channel, @all or @here to mention recipients of persistent notifications.', + }), + expect.any(Array), + ); + }); + + it('should show alert when no mentions found', async () => { + (getUsersCountFromMentions as jest.Mock).mockResolvedValue(0); + + await persistentNotificationsConfirmation( + serverUrl, + value, + mentionsList, + intl, + sendMessage, + persistentNotificationMaxRecipients, + persistentNotificationInterval, + currentUserId, + channelName, + ); + + expect(Alert.alert).toHaveBeenCalledWith( + intl.formatMessage({ + id: 'persistent_notifications.error.no_mentions.title', + defaultMessage: 'Recipients must be @mentioned', + }), + intl.formatMessage({ + id: 'persistent_notifications.error.no_mentions.description', + defaultMessage: 'There are no recipients mentioned in your message. You’ll need add mentions to be able to send persistent notifications.', + }), + expect.any(Array), + ); + }); + + it('should show alert when mentions exceed max recipients', async () => { + (getUsersCountFromMentions as jest.Mock).mockResolvedValue(15); + + await persistentNotificationsConfirmation( + serverUrl, + value, + mentionsList, + intl, + sendMessage, + persistentNotificationMaxRecipients, + persistentNotificationInterval, + currentUserId, + channelName, + ); + + expect(Alert.alert).toHaveBeenCalledWith( + intl.formatMessage({ + id: 'persistent_notifications.error.max_recipients.title', + defaultMessage: 'Too many recipients', + }), + intl.formatMessage({ + id: 'persistent_notifications.error.max_recipients.description', + defaultMessage: 'You can send persistent notifications to a maximum of {max} recipients. There are {count} recipients mentioned in your message. You’ll need to change who you’ve mentioned before you can send.', + }, { + max: persistentNotificationMaxRecipients, + count: mentionsList.length, + }), + expect.any(Array), + ); + }); + + it('should show confirmation alert for valid mentions within limit', async () => { + (getUsersCountFromMentions as jest.Mock).mockResolvedValue(5); + + await persistentNotificationsConfirmation( + serverUrl, + value, + mentionsList, + intl, + sendMessage, + persistentNotificationMaxRecipients, + persistentNotificationInterval, + currentUserId, + channelName, + ); + + expect(Alert.alert).toHaveBeenCalledWith( + intl.formatMessage({ + id: 'persistent_notifications.confirm.title', + defaultMessage: 'Send persistent notifications', + }), + intl.formatMessage({ + id: 'persistent_notifications.confirm.description', + defaultMessage: 'Mentioned recipients will be notified every {interval, plural, one {minute} other {{interval} minutes}} until they’ve acknowledged or replied to the message.', + }, { + interval: persistentNotificationInterval, + }), + expect.any(Array), + ); + }); + }); + + describe('postUserDisplayName', () => { + it('should return the override username if from webhook and override is enabled', () => { + const post = { + props: { + from_webhook: 'true', + override_username: 'webhook_user', + }, + } as PostModel; + + const result = postUserDisplayName(post, undefined, undefined, true); + expect(result).toBe('webhook_user'); + }); + + it('should return the author’s display name if not from webhook or override is disabled', () => { + const post = { + props: { + from_webhook: 'false', + }, + } as PostModel; + const author = { + username: 'user1', + locale: 'en', + } as UserModel; + + const result = postUserDisplayName(post, author, undefined, false); + expect(result).toBe('user1'); + }); + + it('should return the author’s display name using the teammate name display', () => { + const post = { + props: { + from_webhook: 'false', + }, + } as PostModel; + const author = { + username: 'user1', + locale: 'en', + } as UserModel; + + const result = postUserDisplayName(post, author, 'nickname', false); + expect(result).toBe('user1'); + }); + }); + + describe('shouldIgnorePost', () => { + it('should return true if the post type is in the ignore list', () => { + const post = { + type: Post.POST_TYPES.CHANNEL_DELETED, + } as Post; + + const result = shouldIgnorePost(post); + expect(result).toBe(true); + }); + + it('should return false if the post type is not in the ignore list', () => { + const post = { + type: Post.POST_TYPES.EPHEMERAL, + } as Post; + + const result = shouldIgnorePost(post); + expect(result).toBe(false); + }); + }); + + describe('processPostsFetched', () => { + it('should process the fetched posts correctly', () => { + const data = { + order: ['post1', 'post2'], + posts: { + post1: {id: 'post1', message: 'First post'}, + post2: {id: 'post2', message: 'Second post'}, + }, + prev_post_id: 'post0', + } as unknown as PostResponse; + + const result = processPostsFetched(data); + expect(result).toEqual({ + posts: [ + {id: 'post1', message: 'First post'}, + {id: 'post2', message: 'Second post'}, + ], + order: ['post1', 'post2'], + previousPostId: 'post0', + }); + }); + }); + + describe('getLastFetchedAtFromPosts', () => { + it('should return the maximum timestamp from the posts', () => { + const posts = [ + {create_at: 1000, update_at: 2000, delete_at: 0}, + {create_at: 1500, update_at: 2500, delete_at: 3000}, + ] as Post[]; + + const result = getLastFetchedAtFromPosts(posts); + expect(result).toBe(3000); + }); + + it('should return 0 if no posts are provided', () => { + const result = getLastFetchedAtFromPosts(); + expect(result).toBe(0); + }); + }); + + describe('moreThan5minAgo', () => { + it('should return true if the time is more than 5 minutes ago', () => { + const time = Date.now() - toMilliseconds({minutes: 6}); + const result = moreThan5minAgo(time); + expect(result).toBe(true); + }); + + it('should return false if the time is within 5 minutes', () => { + const time = Date.now() - toMilliseconds({minutes: 4}); + const result = moreThan5minAgo(time); + expect(result).toBe(false); + }); }); }); From 358417dd7b57fcf535fb374ff0e6d07b2a3f9929 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Thu, 29 Aug 2024 00:03:25 +0800 Subject: [PATCH 60/67] add unit tests to app/utils/permalink (#8141) --- app/utils/permalink/index.test.ts | 84 +++++++++++++++++++++++++++++++ app/utils/permalink/index.ts | 1 + 2 files changed, 85 insertions(+) create mode 100644 app/utils/permalink/index.test.ts diff --git a/app/utils/permalink/index.test.ts b/app/utils/permalink/index.test.ts new file mode 100644 index 00000000000..add8265bddc --- /dev/null +++ b/app/utils/permalink/index.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Keyboard, Platform} from 'react-native'; +import {OptionsModalPresentationStyle} from 'react-native-navigation'; + +import {dismissAllModals, showModalOverCurrentContext} from '@screens/navigation'; + +import {displayPermalink, closePermalink} from '.'; + +jest.mock('@screens/navigation', () => ({ + dismissAllModals: jest.fn(), + showModalOverCurrentContext: jest.fn(), +})); + +describe('permalinkUtils', () => { + const originalSelect = Platform.select; + + beforeAll(() => { + Platform.select = ({android, ios, default: dft}: any) => { + if (Platform.OS === 'android' && android) { + return android; + } else if (Platform.OS === 'ios' && ios) { + return ios; + } + + return dft; + }; + }); + + afterAll(() => { + Platform.select = originalSelect; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('displayPermalink', () => { + it('should dismiss keyboard and show permalink modal', async () => { + const dismiss = jest.spyOn(Keyboard, 'dismiss'); + await displayPermalink('teamName', 'postId'); + expect(dismiss).toHaveBeenCalled(); + expect(showModalOverCurrentContext).toHaveBeenCalledWith( + 'Permalink', + {isPermalink: true, teamName: 'teamName', postId: 'postId'}, + { + modalPresentationStyle: OptionsModalPresentationStyle.overFullScreen, + layout: { + componentBackgroundColor: 'rgba(0,0,0,0.2)', + }, + }, + ); + }); + + it('should dismiss all modals if showingPermalink is true', async () => { + // Simulate showingPermalink being true + await displayPermalink('teamName', 'postId'); + await displayPermalink('teamName', 'postId'); + expect(dismissAllModals).toHaveBeenCalled(); + }); + + it('should handle platform specific options correctly', async () => { + await displayPermalink('teamName', 'postId'); + expect(showModalOverCurrentContext).toHaveBeenCalledWith( + 'Permalink', + {isPermalink: true, teamName: 'teamName', postId: 'postId'}, + { + modalPresentationStyle: OptionsModalPresentationStyle.overFullScreen, + layout: { + componentBackgroundColor: 'rgba(0,0,0,0.2)', + }, + }, + ); + }); + }); + + describe('closePermalink', () => { + it('should set showingPermalink to false', () => { + const showingPermalink = closePermalink(); + expect(showingPermalink).toBe(false); + }); + }); +}); diff --git a/app/utils/permalink/index.ts b/app/utils/permalink/index.ts index b52f1dba202..6af509fc3ed 100644 --- a/app/utils/permalink/index.ts +++ b/app/utils/permalink/index.ts @@ -39,4 +39,5 @@ export const displayPermalink = async (teamName: string, postId: string, openAsP export const closePermalink = () => { showingPermalink = false; + return showingPermalink; }; From 2174c679e1b034f71445ad8cfc9d32f427da7b78 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Thu, 29 Aug 2024 00:03:50 +0800 Subject: [PATCH 61/67] add unit tests for app/utils/navigation (#8139) --- app/utils/navigation/index.test.ts | 80 ++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 app/utils/navigation/index.test.ts diff --git a/app/utils/navigation/index.test.ts b/app/utils/navigation/index.test.ts new file mode 100644 index 00000000000..800e1b5bbef --- /dev/null +++ b/app/utils/navigation/index.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createIntl} from 'react-intl'; +import {Alert} from 'react-native'; +import {Navigation} from 'react-native-navigation'; + +import {ServerErrors} from '@constants'; +import {DEFAULT_LOCALE, getTranslations} from '@i18n'; + +import {mergeNavigationOptions, alertTeamRemove, alertChannelRemove, alertChannelArchived, alertTeamAddError} from '.'; + +describe('Navigation utils', () => { + const componentId = 'component-id'; + const options = {topBar: {title: {text: 'Test'}}}; + const displayName = 'Test Display Name'; + const serverError = {server_error_id: ServerErrors.TEAM_MEMBERSHIP_DENIAL_ERROR_ID}; + const genericServerError = {server_error_id: 'api.some_server_error.id', message: 'Generic error message'}; + const intl = createIntl({locale: DEFAULT_LOCALE, messages: getTranslations(DEFAULT_LOCALE)}); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call Navigation.mergeOptions with the correct arguments', () => { + mergeNavigationOptions(componentId, options); + expect(Navigation.mergeOptions).toHaveBeenCalledWith(componentId, options); + }); + + it('should display alert when a user is removed from a team', () => { + alertTeamRemove(displayName, intl); + expect(Alert.alert).toHaveBeenCalledWith( + 'Removed from team', + 'You have been removed from team Test Display Name.', + [{style: 'cancel', text: 'OK'}], + ); + }); + + it('should display alert when a user is removed from a channel', () => { + alertChannelRemove(displayName, intl); + expect(Alert.alert).toHaveBeenCalledWith( + 'Removed from channel', + 'You have been removed from channel Test Display Name.', + [{style: 'cancel', text: 'OK'}], + ); + }); + + it('should display alert when a channel is archived', () => { + alertChannelArchived(displayName, intl); + expect(Alert.alert).toHaveBeenCalledWith( + 'Archived channel', + 'The channel Test Display Name has been archived.', + [{style: 'cancel', text: 'OK'}], + ); + }); + + it('should display alert for team add error with default message', () => { + alertTeamAddError({}, intl); + expect(Alert.alert).toHaveBeenCalledWith( + 'Error joining a team', + 'There has been an error joining the team', + ); + }); + + it('should display alert for team add error with specific server error message', () => { + alertTeamAddError(serverError, intl); + expect(Alert.alert).toHaveBeenCalledWith( + 'Error joining a team', + 'You need to be a member of a linked group to join this team.', + ); + }); + + it('should display alert for team add error with generic error message', () => { + alertTeamAddError(genericServerError, intl); + expect(Alert.alert).toHaveBeenCalledWith( + 'Error joining a team', + 'Generic error message', + ); + }); +}); From a2dca382c74569c87a35ea8e7d779c95f89e7c56 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Thu, 29 Aug 2024 00:04:12 +0800 Subject: [PATCH 62/67] add unit tests to app/utils/notification (#8140) --- app/utils/notification/index.test.ts | 146 +++++++++++++++++++++++++++ test/setup.ts | 1 + 2 files changed, 147 insertions(+) create mode 100644 app/utils/notification/index.test.ts diff --git a/app/utils/notification/index.test.ts b/app/utils/notification/index.test.ts new file mode 100644 index 00000000000..c7c36887cf8 --- /dev/null +++ b/app/utils/notification/index.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment-timezone'; +import {createIntl} from 'react-intl'; +import {Alert, DeviceEventEmitter} from 'react-native'; +import {Notifications} from 'react-native-notifications'; + +import {Events} from '@constants'; +import {DEFAULT_LOCALE, getTranslations} from '@i18n'; +import {popToRoot} from '@screens/navigation'; + +import { + convertToNotificationData, + notificationError, + emitNotificationError, + scheduleExpiredNotification, +} from '.'; + +describe('Notification Utils', () => { + const intl = createIntl({locale: DEFAULT_LOCALE, messages: getTranslations(DEFAULT_LOCALE)}); + const notification = { + identifier: 'id', + payload: { + ack_id: 'ack_id', + channel_id: 'channel_id', + channel_name: 'channel_name', + from_webhook: true, + message: 'Test message', + override_icon_url: 'icon_url', + override_username: 'username', + post_id: 'post_id', + root_id: 'root_id', + sender_id: 'sender_id', + sender_name: 'sender_name', + server_id: 'server_id', + server_url: 'server_url', + team_id: 'team_id', + type: 'message', + sub_type: 'sub_type', + use_user_icon: true, + version: '1.0', + is_crt_enabled: 'true', + data: {}, + }, + body: 'body', + }; + + const session = { + expires_at: moment().add(10, 'hours').valueOf(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('convertToNotificationData', () => { + it('should convert notification with payload to NotificationWithData', () => { + const result = convertToNotificationData(notification as any, true); + const not = {...notification}; + Reflect.deleteProperty(not.payload, 'is_crt_enabled'); + expect(result).toEqual({ + ...not, + payload: { + ...not.payload, + identifier: 'id', + isCRTEnabled: true, + message: 'Test message', + }, + userInteraction: true, + foreground: false, + }); + }); + + it('should return the original notification if no payload is present', () => { + const result = convertToNotificationData({identifier: 'id'} as any, false); + expect(result).toEqual({identifier: 'id'}); + }); + }); + + describe('notificationError', () => { + it('should display alert and popToRoot for Channel type', () => { + notificationError(intl, 'Channel'); + expect(Alert.alert).toHaveBeenCalledWith( + 'Message not found', + 'This message belongs to a channel where you are not a member.', + ); + expect(popToRoot).toHaveBeenCalled(); + }); + + it('should display alert and popToRoot for Team type', () => { + notificationError(intl, 'Team'); + expect(Alert.alert).toHaveBeenCalledWith( + 'Message not found', + 'This message belongs to a team where you are not a member.', + ); + expect(popToRoot).toHaveBeenCalled(); + }); + + it('should display alert and popToRoot for Post type', () => { + notificationError(intl, 'Post'); + expect(Alert.alert).toHaveBeenCalledWith( + 'Message not found', + 'The message has not been found.', + ); + expect(popToRoot).toHaveBeenCalled(); + }); + + it('should display alert and popToRoot for Connection type', () => { + notificationError(intl, 'Connection'); + expect(Alert.alert).toHaveBeenCalledWith( + 'Message not found', + 'The server is unreachable and it was not possible to retrieve the specific message information for the notification.', + ); + expect(popToRoot).toHaveBeenCalled(); + }); + }); + + describe('emitNotificationError', () => { + it('should emit notification error after 500ms', (done) => { + const spyEmit = jest.spyOn(DeviceEventEmitter, 'emit'); + emitNotificationError('Channel'); + setTimeout(() => { + expect(spyEmit).toHaveBeenCalledWith(Events.NOTIFICATION_ERROR, 'Channel'); + done(); + }, 600); // wait a little longer than 500ms to ensure the timeout has executed + }); + }); + + describe('scheduleExpiredNotification', () => { + it('should schedule a notification for session expiration with hours', () => { + const result = scheduleExpiredNotification('server_url', session as any, 'ServerName', 'en'); + expect(Notifications.postLocalNotification).toHaveBeenCalledWith(expect.objectContaining({ + fireDate: new Date(session.expires_at).toISOString(), + body: 'Please log in to continue receiving notifications. Sessions for ServerName are configured to expire every 10 hours.', + title: 'Session Expired', + })); + expect(result).toBeDefined(); + }); + + it('should return 0 if expiresAt is not defined', () => { + const result = scheduleExpiredNotification('server_url', {} as any, 'ServerName', 'en'); + expect(result).toBe(0); + }); + }); +}); diff --git a/test/setup.ts b/test/setup.ts index 409753cc81e..2243502e752 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -323,6 +323,7 @@ jest.mock('react-native-notifications', () => { }), setBadgeCount: jest.fn(), }, + postLocalNotification: jest.fn((notification) => notification), }, }; }); From aba68c45d5b9dcb9795d04966f26d9e06e979e59 Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Thu, 29 Aug 2024 09:46:16 -0400 Subject: [PATCH 63/67] MM-59440 Add unit tests for actions/remote/channel (#8184) * Add member tests for actions/remote/channel * Add channel and dm tests to actions/remote/channel * Update app/actions/remote/channel.test.ts Co-authored-by: Elias Nahum --------- Co-authored-by: Elias Nahum --- app/actions/remote/channel.test.ts | 762 +++++++++++++++++++++++++++++ app/actions/remote/channel.ts | 6 +- 2 files changed, 766 insertions(+), 2 deletions(-) create mode 100644 app/actions/remote/channel.test.ts diff --git a/app/actions/remote/channel.test.ts b/app/actions/remote/channel.test.ts new file mode 100644 index 00000000000..4a86cb6b8b0 --- /dev/null +++ b/app/actions/remote/channel.test.ts @@ -0,0 +1,762 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable max-lines */ + +import {createIntl} from 'react-intl'; + +import {DeepLink} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import NetworkManager from '@managers/network_manager'; + +import { + removeMemberFromChannel, + fetchChannelMembersByIds, + updateChannelMemberSchemeRoles, + fetchMemberInChannel, + fetchChannelMemberships, + addMembersToChannel, + fetchChannelByName, + createChannel, + patchChannel, + leaveChannel, + fetchChannelCreator, + fetchMyChannelsForTeam, + fetchMyChannel, + joinChannel, + joinChannelIfNeeded, + markChannelAsRead, + unsetActiveChannelOnServer, + switchToChannelByName, + goToNPSChannel, + fetchMissingDirectChannelsInfo, + fetchDirectChannelsInfo, + createDirectChannel, + fetchChannels, + makeDirectChannel, + fetchArchivedChannels, + createGroupChannel, + fetchSharedChannels, + makeGroupChannel, + getChannelMemberCountsByGroup, + getChannelTimezones, + switchToChannelById, + switchToPenultimateChannel, + switchToLastChannel, + searchChannels, + fetchChannelById, + searchAllChannels, + updateChannelNotifyProps, + toggleMuteChannel, + archiveChannel, + unarchiveChannel, + convertChannelToPrivate, + handleKickFromChannel, + fetchGroupMessageMembersCommonTeams, + convertGroupMessageToPrivateChannel, +} from './channel'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type ChannelModel from '@typings/database/models/servers/channel'; + +const serverUrl = 'baseHandler.test.com'; +let operator: ServerDataOperator; + +const user: UserProfile = { + id: 'userid', + username: 'username', + roles: '', +} as UserProfile; + +let mockIsTablet: jest.Mock; +jest.mock('@utils/helpers', () => { + const original = jest.requireActual('@utils/helpers'); + mockIsTablet = jest.fn(() => false); + return { + ...original, + isTablet: mockIsTablet, + }; +}); + +let mockGetActiveServer: jest.Mock; +jest.mock('@queries/app/servers', () => { + const original = jest.requireActual('@queries/app/servers'); + mockGetActiveServer = jest.fn(() => false); + return { + ...original, + getActiveServer: mockGetActiveServer, + }; +}); + +const mockClient = { + removeFromChannel: jest.fn(), + getChannelMembersByIds: jest.fn((channelId: string, userIds: string[]) => userIds.map((uid) => ({user_id: uid, channel_id: channelId, roles: ''}))), + updateChannelMemberSchemeRoles: jest.fn(), + getMemberInChannel: jest.fn((channelId: string, userId: string) => ({id: userId + '-' + channelId, user_id: userId, channel_id: channelId, roles: ''})), + getChannel: jest.fn((channelId: string) => ({id: channelId, name: 'channel1', creatorId: user.id})), + getProfilesInChannel: jest.fn(() => ([user])), + addToChannel: jest.fn((channelId: string, userId: string) => ({id: userId + '-' + channelId, user_id: userId, channel_id: channelId, roles: ''})), + getProfilesByIds: jest.fn((userIds: string[]) => userIds.map((uid) => ({id: uid, username: 'u' + uid, roles: ''}))), + getChannelByName: jest.fn((teamId: string, channelName: string) => ({id: channelId, name: channelName, team_id: teamId})), + createChannel: jest.fn((channel: Channel) => ({...channel, id: channelId})), + getChannelMember: jest.fn((channelId: string, userId: string) => ({id: userId + '-' + channelId, user_id: userId, channel_id: channelId, roles: ''})), + patchChannel: jest.fn((channelId: string, channel: ChannelPatch) => ({...channel, id: channelId})), + 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']})), + 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'})), + 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'})), + getProfilesInGroupChannels: jest.fn(() => ({groupid: [user, {...user, id: 'userid2'}]})), + savePreferences: jest.fn(), + getRolesByNames: jest.fn((roles: string[]) => roles.map((r) => ({id: r, name: r} as Role))), + getSharedChannels: jest.fn((teamId: string) => ([{id: channelId + 'shared', name: 'channel1shared', creatorId: user.id, team_id: teamId, shared: true}])), + getChannelMemberCountsByGroup: jest.fn((channelId: string) => ({group_id: channelId, channel_member_count: 3, channel_member_timezones_count: 2})), + getChannelTimezones: jest.fn(() => ['est']), + autocompleteChannels: jest.fn((teamId: string) => ([{id: channelId, name: 'channel1', creatorId: user.id, team_id: teamId}])), + searchAllChannels: jest.fn(() => ([{id: channelId, name: 'channel1', creatorId: user.id, team_id: teamId}])), + updateChannelNotifyProps: jest.fn(), + deleteChannel: jest.fn(), + unarchiveChannel: jest.fn(), + convertChannelToPrivate: jest.fn(), + getGroupMessageMembersCommonTeams: jest.fn(() => ({id: teamId, name: 'teamname'})), + convertGroupMessageToPrivateChannel: jest.fn((channelId: string) => ({id: channelId, name: 'channel1', creatorId: user.id, type: 'P'})), +}; + +const teamId = 'teamid1'; +const channelId = 'channelid1'; + +const intl = createIntl({ + locale: 'en', + messages: {}, +}); + +beforeAll(() => { + // eslint-disable-next-line + // @ts-ignore + NetworkManager.getClient = () => mockClient; +}); + +beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + operator = DatabaseManager.serverDatabases[serverUrl]!.operator; +}); + +afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); +}); + +describe('channelMember', () => { + it('removeMemberFromChannel - handle not found database', async () => { + const result = await removeMemberFromChannel('foo', '', '') as {error: unknown}; + expect(result?.error).toBeDefined(); + }); + + it('removeMemberFromChannel - base case', async () => { + const result = await removeMemberFromChannel(serverUrl, channelId, user.id); + expect(result).toBeDefined(); + }); + + it('fetchChannelMembersByIds - handle not found database', async () => { + const {members, error} = await fetchChannelMembersByIds('foo', '', []); + expect(members).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('fetchChannelMembersByIds - base case', async () => { + const {members, error} = await fetchChannelMembersByIds(serverUrl, channelId, [user.id]); + expect(error).toBeUndefined(); + expect(members).toBeDefined(); + expect(members?.length).toBe(1); + }); + + it('updateChannelMemberSchemeRoles - base case', async () => { + const result = await updateChannelMemberSchemeRoles(serverUrl, channelId, user.id, true, true); + expect(result).toBeDefined(); + }); + + it('fetchMemberInChannel - handle not found database', async () => { + const {member, error} = await fetchMemberInChannel('foo', '', ''); + expect(member).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('fetchMemberInChannel - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + const {member, error} = await fetchMemberInChannel(serverUrl, channelId, user.id); + expect(error).toBeUndefined(); + expect(member).toBeDefined(); + }); + + it('fetchChannelMemberships - base case', async () => { + const {members, users} = await fetchChannelMemberships(serverUrl, channelId, {}); + expect(users).toBeDefined(); + expect(users.length).toBe(1); + expect(members).toBeDefined(); + expect(members.length).toBe(1); + }); + + it('addMembersToChannel - handle not found database', async () => { + const {channelMemberships, error} = await addMembersToChannel('foo', '', []); + expect(channelMemberships).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('addMembersToChannel - base case', async () => { + const {channelMemberships, error} = await addMembersToChannel(serverUrl, channelId, [user.id]); + expect(error).toBeUndefined(); + expect(channelMemberships).toBeDefined(); + expect(channelMemberships?.length).toBe(1); + }); + + it('getChannelMemberCountsByGroup - base case', async () => { + const {channelMemberCountsByGroup, error} = await getChannelMemberCountsByGroup(serverUrl, channelId, true); + expect(error).toBeUndefined(); + expect(channelMemberCountsByGroup).toBeDefined(); + }); + + it('updateChannelNotifyProps - handle not found database', async () => { + const {notifyProps, error} = await updateChannelNotifyProps('foo', '', {}); + expect(notifyProps).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('updateChannelNotifyProps - base case', async () => { + const {notifyProps, error} = await updateChannelNotifyProps(serverUrl, channelId, {}); + expect(error).toBeUndefined(); + expect(notifyProps).toBeDefined(); + }); + + it('toggleMuteChannel - handle not found database', async () => { + const {notifyProps, error} = await toggleMuteChannel('foo', ''); + expect(notifyProps).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('toggleMuteChannel - base case', async () => { + await operator.handleMyChannelSettings({ + settings: [{id: channelId, user_id: user.id, channel_id: channelId, roles: '', last_viewed_at: 1, last_update_at: 1, msg_count: 10, mention_count: 0, notify_props: {}}], + prepareRecordsOnly: false, + }); + + const {notifyProps, error} = await toggleMuteChannel(serverUrl, channelId, true); + expect(error).toBeUndefined(); + expect(notifyProps).toBeDefined(); + }); +}); + +describe('channel', () => { + it('fetchChannelByName - handle not found database', async () => { + const {channel, error} = await fetchChannelByName('foo', '', ''); + expect(channel).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('fetchChannelByName - base case', async () => { + const {channel, error} = await fetchChannelByName(serverUrl, channelId, 'channelname'); + expect(error).toBeUndefined(); + expect(channel).toBeDefined(); + }); + + it('createChannel - handle not found database', async () => { + const {channel, error} = await createChannel('foo', '', '', '', 'O'); + expect(channel).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('createChannel - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const {channel, error} = await createChannel(serverUrl, 'channeldisplayname', 'purpose', 'header', 'O'); + expect(error).toBeUndefined(); + expect(channel).toBeDefined(); + }); + + it('patchChannel - handle not found database', async () => { + const {channel, error} = await patchChannel('foo', '', {}); + expect(channel).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('patchChannel - base case', async () => { + await operator.handleChannel({channels: [{ + id: channelId, + purpose: 'oldpurpose', + team_id: teamId, + total_msg_count: 0, + } as Channel], + prepareRecordsOnly: false}); + + const {channel, error} = await patchChannel(serverUrl, channelId, {name: 'channelname', display_name: 'Channel Name', purpose: 'purpose', header: 'header'}); + expect(error).toBeUndefined(); + expect(channel).toBeDefined(); + }); + + it('leaveChannel - handle not found database', async () => { + const {error} = await leaveChannel('foo', ''); + expect(error).toBeDefined(); + }); + + it('leaveChannel - no user', async () => { + const {error} = await leaveChannel(serverUrl, channelId); + expect(error).toBeDefined(); + expect(error).toBe('current user not found'); + }); + + it('leaveChannel - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + + const result = await leaveChannel(serverUrl, channelId); + expect(result.error).toBeUndefined(); + expect(result).toBeDefined(); + }); + + it('fetchChannelCreator - handle not found database', async () => { + const {user: fetchedUser, error} = await fetchChannelCreator('foo', ''); + expect(fetchedUser).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('fetchChannelCreator - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + await operator.handleChannel({channels: [{ + id: channelId, + team_id: teamId, + total_msg_count: 0, + creator_id: user.id, + } as Channel], + prepareRecordsOnly: false}); + + const {user: fetchedUser, error} = await fetchChannelCreator(serverUrl, channelId); + expect(error).toBeUndefined(); + expect(fetchedUser).toBeDefined(); + }); + + it('fetchMyChannelsForTeam - handle not found database', async () => { + const {error} = await fetchMyChannelsForTeam('foo', ''); + expect(error).toBeDefined(); + }); + + it('fetchMyChannelsForTeam - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const {channels, memberships, categories, error} = await fetchMyChannelsForTeam(serverUrl, teamId, true, 0, false, true); + expect(error).toBeUndefined(); + expect(channels).toBeDefined(); + expect(memberships).toBeDefined(); + expect(categories).toBeDefined(); + }); + + it('fetchMyChannel - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const {channels, memberships, error} = await fetchMyChannel(serverUrl, teamId, channelId); + expect(error).toBeUndefined(); + expect(channels).toBeDefined(); + expect(memberships).toBeDefined(); + }); + + it('joinChannel - handle not found database', async () => { + const {error} = await joinChannel('foo', ''); + expect(error).toBeDefined(); + }); + + it('joinChannel - by id', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const {channel, member, error} = await joinChannel(serverUrl, teamId, channelId); + expect(error).toBeUndefined(); + expect(channel).toBeDefined(); + expect(member).toBeDefined(); + }); + + it('joinChannel - by name', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const {channel, member, error} = await joinChannel(serverUrl, teamId, undefined, 'channelname'); + expect(error).toBeUndefined(); + expect(channel).toBeDefined(); + expect(member).toBeDefined(); + }); + + it('joinChannelIfNeeded - handle not found database', async () => { + const {error} = await joinChannelIfNeeded('foo', '') as {error: unknown}; + expect(error).toBeDefined(); + }); + + it('joinChannelIfNeeded - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + const {channel, member} = await joinChannelIfNeeded(serverUrl, channelId) as {channel: Channel; member: ChannelMember}; + expect(channel).toBeDefined(); + expect(member).toBeDefined(); + }); + + it('joinChannelIfNeeded - not needed', async () => { + await operator.handleMyChannel({channels: [{ + id: channelId, + team_id: teamId, + total_msg_count: 0, + creator_id: user.id, + } as Channel], + myChannels: [{ + id: 'id', + channel_id: channelId, + user_id: user.id, + msg_count: 0, + } as ChannelMembership], + prepareRecordsOnly: false}); + + const result = await joinChannelIfNeeded(serverUrl, channelId) as {}; + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('channel'); + expect(result).not.toHaveProperty('error'); + }); + + it('markChannelAsRead - base case', async () => { + const result = await markChannelAsRead(serverUrl, channelId, true); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('unsetActiveChannelOnServer - base case', async () => { + const result = await unsetActiveChannelOnServer(serverUrl); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('switchToChannelByName - handle not found database', async () => { + const {error} = await switchToChannelByName('foo', '', '', () => {}, intl); + expect(error).toBeDefined(); + }); + + it('switchToChannelByName - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + + const result = await switchToChannelByName(serverUrl, 'channelname', 'teamname', () => {}, intl); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('switchToChannelByName - team redirect', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + + const result = await switchToChannelByName(serverUrl, 'channelname', DeepLink.Redirect, () => {}, intl); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('goToNPSChannel - handle not found database', async () => { + const {error} = await goToNPSChannel('foo'); + expect(error).toBeDefined(); + }); + + it('goToNPSChannel - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + + const result = await goToNPSChannel(serverUrl); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('fetchChannels - base case', async () => { + const {channels, error} = await fetchChannels(serverUrl, teamId); + expect(error).toBeUndefined(); + expect(channels).toBeDefined(); + }); + + it('fetchArchivedChannels - base case', async () => { + const {channels, error} = await fetchArchivedChannels(serverUrl, teamId); + expect(error).toBeUndefined(); + expect(channels).toBeDefined(); + }); + + it('fetchSharedChannels - base case', async () => { + const {channels, error} = await fetchSharedChannels(serverUrl, teamId); + expect(error).toBeUndefined(); + expect(channels).toBeDefined(); + }); + + it('getChannelTimezones - base case', async () => { + const {channelTimezones, error} = await getChannelTimezones(serverUrl, channelId); + expect(error).toBeUndefined(); + expect(channelTimezones).toBeDefined(); + }); + + it('switchToChannelById - handle not found database', async () => { + const {error} = await switchToChannelById('foo', '') as {error: unknown}; + expect(error).toBeDefined(); + }); + + it('switchToChannelById - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + + const result = await switchToChannelById(serverUrl, channelId); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('switchToPenultimateChannel - handle not found database', async () => { + const {error} = await switchToPenultimateChannel('foo') as {error: unknown}; + expect(error).toBeDefined(); + }); + + it('switchToPenultimateChannel - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + + const result = await switchToPenultimateChannel(serverUrl); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('switchToLastChannel - handle not found database', async () => { + const {error} = await switchToLastChannel('foo') as {error: unknown}; + expect(error).toBeDefined(); + }); + + it('switchToLastChannel - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + + const result = await switchToLastChannel(serverUrl); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('searchChannels - handle error', async () => { + const {error} = await searchChannels('foo', '', teamId, true) as {error: unknown}; + expect(error).toBeDefined(); + }); + + it('searchChannels - base case', async () => { + const result = await searchChannels(serverUrl, 'searchterm', teamId, false); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + expect(result).toHaveProperty('channels'); + }); + + it('fetchChannelById - base case', async () => { + const result = await fetchChannelById(serverUrl, channelId); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('searchAllChannels - handle not found database', async () => { + const {error} = await searchAllChannels('foo', '') as {error: unknown}; + expect(error).toBeDefined(); + }); + + it('searchAllChannels - base case', async () => { + const result = await searchAllChannels(serverUrl, 'searchterm'); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + expect(result).toHaveProperty('channels'); + }); + + it('archiveChannel - handle not found database', async () => { + const {error} = await archiveChannel('foo', '') as {error: unknown}; + expect(error).toBeDefined(); + }); + + it('archiveChannel - base case', async () => { + const result = await archiveChannel(serverUrl, channelId); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('unarchiveChannel - base case', async () => { + const result = await unarchiveChannel(serverUrl, channelId); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('convertChannelToPrivate - handle not found database', async () => { + const {error} = await convertChannelToPrivate('foo', '') as {error: unknown}; + expect(error).toBeDefined(); + }); + + it('convertChannelToPrivate - base case', async () => { + await operator.handleChannel({channels: [{ + id: channelId, + team_id: teamId, + total_msg_count: 0, + type: 'O', + } as Channel], + prepareRecordsOnly: false}); + + const result = await convertChannelToPrivate(serverUrl, channelId); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('handleKickFromChannel - handle not found database', async () => { + const {error} = await handleKickFromChannel('foo', ''); + expect(error).toBeDefined(); + }); + + it('handleKickFromChannel - not current channel', async () => { + const result = await handleKickFromChannel(serverUrl, channelId); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); + + it('handleKickFromChannel - base case', async () => { + mockIsTablet.mockImplementationOnce(() => true); + mockGetActiveServer.mockImplementationOnce(() => ({url: serverUrl})); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID, value: channelId}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + + const result = await handleKickFromChannel(serverUrl, channelId); + expect(result).toBeDefined(); + expect(result).not.toHaveProperty('error'); + }); +}); + +describe('direct and group', () => { + it('fetchMissingDirectChannelsInfo - handle not found database', async () => { + const {directChannels, error} = await fetchMissingDirectChannelsInfo('foo', []); + expect(directChannels).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('fetchMissingDirectChannelsInfo - base case', async () => { + const {directChannels, error} = await fetchMissingDirectChannelsInfo(serverUrl, [{id: 'id', name: 'name', type: 'D'} as Channel], 'channelname'); + expect(error).toBeUndefined(); + expect(directChannels).toBeDefined(); + }); + + it('fetchDirectChannelsInfo - handle not found database', async () => { + const {directChannels, error} = await fetchDirectChannelsInfo('foo', []); + expect(directChannels).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('fetchDirectChannelsInfo - base case', async () => { + const channel = {id: 'id', name: 'name', type: 'D'} as Channel; + const {directChannels, error} = await fetchDirectChannelsInfo(serverUrl, [{...channel, toApi: () => channel} as unknown as ChannelModel]); + expect(error).toBeUndefined(); + expect(directChannels).toBeDefined(); + }); + + it('createDirectChannel - handle not found database', async () => { + const {data, error} = await createDirectChannel('foo', ''); + expect(data).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('createDirectChannel - no user', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + const {data, error} = await createDirectChannel(serverUrl, 'userid2'); + expect(data).toBeUndefined(); + expect(error).toBeDefined(); + expect(error).toBe('Cannot get the current user'); + }); + + it('createDirectChannel - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + + const {data, error} = await createDirectChannel(serverUrl, 'userid2'); + expect(error).toBeUndefined(); + expect(data).toBeDefined(); + }); + + it('createDirectChannel - with display name', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + + const {data, error} = await createDirectChannel(serverUrl, 'userid2', 'displayname'); + expect(error).toBeUndefined(); + expect(data).toBeDefined(); + }); + + it('makeDirectChannel - handle not found database', async () => { + const {data, error} = await makeDirectChannel('foo', ''); + expect(data).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('makeDirectChannel - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + + const {data, error} = await makeDirectChannel(serverUrl, 'userid2'); + expect(error).toBeUndefined(); + expect(data).toBeDefined(); + }); + + it('makeDirectChannel - with display name', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + + const {data, error} = await makeDirectChannel(serverUrl, 'userid2', 'displayname'); + expect(error).toBeUndefined(); + expect(data).toBeDefined(); + }); + + it('createGroupChannel - handle not found database', async () => { + const {data, error} = await createGroupChannel('foo', []); + expect(data).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('createGroupChannel - no user', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + const {data, error} = await createGroupChannel(serverUrl, ['userid2']); + expect(data).toBeUndefined(); + expect(error).toBeDefined(); + expect(error).toBe('Cannot get the current user'); + }); + + it('createGroupChannel - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + + const {data, error} = await createGroupChannel(serverUrl, ['userid2']); + expect(error).toBeUndefined(); + expect(data).toBeDefined(); + }); + + it('makeGroupChannel - handle not found database', async () => { + const {data, error} = await makeGroupChannel('foo', []); + expect(data).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('makeGroupChannel - base case', async () => { + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}], prepareRecordsOnly: false}); + await operator.handleUsers({users: [user], prepareRecordsOnly: false}); + + const {data, error} = await makeGroupChannel(serverUrl, ['userid2']); + expect(error).toBeUndefined(); + expect(data).toBeDefined(); + }); + + it('fetchGroupMessageMembersCommonTeams - base case', async () => { + const {teams, error} = await fetchGroupMessageMembersCommonTeams(serverUrl, channelId); + expect(error).toBeUndefined(); + expect(teams).toBeDefined(); + }); + + it('convertGroupMessageToPrivateChannel - handle not found database', async () => { + const {updatedChannel, error} = await convertGroupMessageToPrivateChannel('foo', '', '', ''); + expect(updatedChannel).toBeUndefined(); + expect(error).toBeDefined(); + }); + + it('convertGroupMessageToPrivateChannel - base case', async () => { + await operator.handleChannel({channels: [{ + id: channelId, + team_id: teamId, + total_msg_count: 0, + type: 'G', + } as Channel], + prepareRecordsOnly: false}); + + const {updatedChannel, error} = await convertGroupMessageToPrivateChannel(serverUrl, channelId, teamId, 'newprivatechannel'); + expect(error).toBeUndefined(); + expect(updatedChannel).toBeDefined(); + }); +}); diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 6b64322a164..5140cc576fc 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -798,7 +798,7 @@ export async function createDirectChannel(serverUrl: string, userId: string, dis const config = await getConfig(database); const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], config.LockTeammateNameDisplay, config.TeammateNameDisplay, license); const {directChannels, users} = await fetchMissingDirectChannelsInfo(serverUrl, [created], currentUser.locale, teammateDisplayNameSetting, currentUser.id, true); - created.display_name = directChannels?.[0].display_name || created.display_name; + created.display_name = (directChannels?.length && directChannels?.[0].display_name) || created.display_name; if (users?.length) { profiles.push(...users); } @@ -1243,7 +1243,7 @@ export const handleKickFromChannel = async (serverUrl: string, channelId: string const currentChannelId = await getCurrentChannelId(database); if (currentChannelId !== channelId) { - return; + return {}; } const currentServer = await getActiveServer(); @@ -1273,8 +1273,10 @@ export const handleKickFromChannel = async (serverUrl: string, channelId: string } else { await setCurrentChannelId(operator, ''); } + return {}; } catch (error) { logDebug('cannot kick user from channel', error); + return {error}; } }; From 94a6b9bcb1c1c7983548f73e4004fd8ad93874d3 Mon Sep 17 00:00:00 2001 From: "unified-ci-app[bot]" <121569378+unified-ci-app[bot]@users.noreply.github.com> Date: Fri, 30 Aug 2024 08:20:59 -0400 Subject: [PATCH 64/67] Bump app build number to 555 (#8189) Co-authored-by: runner --- android/app/build.gradle | 2 +- ios/Mattermost.xcodeproj/project.pbxproj | 8 ++++---- ios/Mattermost/Info.plist | 2 +- ios/MattermostShare/Info.plist | 2 +- ios/NotificationService/Info.plist | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index a88795d467b..18ec581623a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,7 +111,7 @@ android { applicationId "com.mattermost.rnbeta" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 550 + versionCode 555 versionName "2.20.0" testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' diff --git a/ios/Mattermost.xcodeproj/project.pbxproj b/ios/Mattermost.xcodeproj/project.pbxproj index 2d5ea4fc699..795da23e34e 100644 --- a/ios/Mattermost.xcodeproj/project.pbxproj +++ b/ios/Mattermost.xcodeproj/project.pbxproj @@ -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 = 550; + CURRENT_PROJECT_VERSION = 555; 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 = 550; + CURRENT_PROJECT_VERSION = 555; 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 = 550; + CURRENT_PROJECT_VERSION = 555; 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 = 550; + CURRENT_PROJECT_VERSION = 555; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = UQ8HT4Q2XM; GCC_C_LANGUAGE_STANDARD = gnu11; diff --git a/ios/Mattermost/Info.plist b/ios/Mattermost/Info.plist index f4f2c83e1d7..8c442e53930 100644 --- a/ios/Mattermost/Info.plist +++ b/ios/Mattermost/Info.plist @@ -37,7 +37,7 @@ CFBundleVersion - 550 + 555 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/ios/MattermostShare/Info.plist b/ios/MattermostShare/Info.plist index b536fc2f067..8a446b5e70b 100644 --- a/ios/MattermostShare/Info.plist +++ b/ios/MattermostShare/Info.plist @@ -21,7 +21,7 @@ CFBundleShortVersionString 2.20.0 CFBundleVersion - 550 + 555 UIAppFonts OpenSans-Bold.ttf diff --git a/ios/NotificationService/Info.plist b/ios/NotificationService/Info.plist index 183939d6e4a..6f267a34917 100644 --- a/ios/NotificationService/Info.plist +++ b/ios/NotificationService/Info.plist @@ -21,7 +21,7 @@ CFBundleShortVersionString 2.20.0 CFBundleVersion - 550 + 555 NSExtension NSExtensionPointIdentifier From a41f1a867df28b56e758623a86f344ab0c6fcbf0 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Fri, 30 Aug 2024 09:26:18 -0400 Subject: [PATCH 65/67] MM-55972/MM-59809 Update Markdown library to improve handling of tables (#8137) * MM-59972/MM-59809 Update Markdown library to improve handling of tables * Change Commonmark back to the published version of our fork * Actually use the new version --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 97f46fa9c57..ed8a82530fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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-2", + "commonmark": "npm:@mattermost/commonmark@0.30.1-3", "commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#81b5d27509652bae50b4b510ede777dd3bd923cf", "deep-equal": "2.2.3", "deepmerge": "4.3.1", @@ -12195,9 +12195,9 @@ }, "node_modules/commonmark": { "name": "@mattermost/commonmark", - "version": "0.30.1-2", - "resolved": "https://registry.npmjs.org/@mattermost/commonmark/-/commonmark-0.30.1-2.tgz", - "integrity": "sha512-r/xhNVt49pEFTjOgmKvjnPNM5RA8OztWsUn3CSQBcXiH2r36QipnR6qxU1hHo3XCteXkYDu9ypn+voA+jaN4Xg==", + "version": "0.30.1-3", + "resolved": "https://registry.npmjs.org/@mattermost/commonmark/-/commonmark-0.30.1-3.tgz", + "integrity": "sha512-Kjsl/sZmb6R6PtpPVIifPfqBMrKs7z6Tukb1TJl/S0LfC5uNici3yol4waGxhGsJuvCF2kZqStwVQP7ieUbAgw==", "dependencies": { "entities": "~3.0.1", "mdurl": "~1.0.1", diff --git a/package.json b/package.json index 82c496e7ec8..e1a8fbd4faf 100644 --- a/package.json +++ b/package.json @@ -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-2", + "commonmark": "npm:@mattermost/commonmark@0.30.1-3", "commonmark-react-renderer": "github:mattermost/commonmark-react-renderer#81b5d27509652bae50b4b510ede777dd3bd923cf", "deep-equal": "2.2.3", "deepmerge": "4.3.1", From ad54f53df6e95bce42121007b31961d05c3d7fbc Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Sat, 31 Aug 2024 01:35:50 +0800 Subject: [PATCH 66/67] update network-client to fix ssl error (#8194) --- ios/Podfile.lock | 4 +-- package-lock.json | 83 ++++++++++++++++++++++------------------------- package.json | 4 +-- 3 files changed, 42 insertions(+), 49 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 585d81c6bf2..2cdf147eb9e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1162,7 +1162,7 @@ PODS: - Yoga - react-native-netinfo (11.3.2): - React-Core - - react-native-network-client (1.7.1): + - react-native-network-client (1.7.2): - Alamofire (~> 5.9.1) - DoubleConversion - glog @@ -2025,7 +2025,7 @@ SPEC CHECKSUMS: react-native-emm: ecab44d78fb1cc7d7b7901914f48fb6309c46ff2 react-native-image-picker: c3afe5472ef870d98a4b28415fc0b928161ee5f7 react-native-netinfo: 076df4f9b07f6670acf4ce9a75aac8d34c2e2ccc - react-native-network-client: 41d5c636c224cd37d1d6bd9fab871abfb6036b77 + react-native-network-client: 5173c47230b5f497cdef469cba8e6e1b3df687eb react-native-notifications: 4601a5a8db4ced6ae7cfc43b44d35fe437ac50c4 react-native-paste-input: 011a9916ef3acf809a7da122847c61ca0f93a60e react-native-performance: ff93f8af3b2ee9519fd7879896aa9b8b8272691d diff --git a/package-lock.json b/package-lock.json index ed8a82530fa..cb9d55b810c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "mattermost-mobile", - "version": "2.19.0", + "version": "2.20.0", "hasInstallScript": true, "license": "Apache 2.0", "dependencies": { @@ -22,7 +22,7 @@ "@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.1", + "@mattermost/react-native-network-client": "1.7.2", "@mattermost/react-native-paste-input": "0.8.0", "@mattermost/react-native-turbo-log": "0.4.0", "@mattermost/rnshare": "file:./libraries/@mattermost/rnshare", @@ -139,7 +139,7 @@ "@types/uuid": "10.0.0", "@typescript-eslint/eslint-plugin": "7.13.1", "@typescript-eslint/parser": "7.13.1", - "axios": "1.7.3", + "axios": "1.7.5", "axios-cookiejar-support": "5.0.2", "babel-jest": "29.7.0", "babel-loader": "9.1.3", @@ -5905,9 +5905,10 @@ } }, "node_modules/@mattermost/react-native-network-client": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@mattermost/react-native-network-client/-/react-native-network-client-1.7.1.tgz", - "integrity": "sha512-9KUowD8NcOlBl9Z76Zw9X73WTrZDOKTj1Gvgpf/RWedxh6s/TY5BZ0phcGJgGaRDjBUzSIdSYvIh+97drwRdHA==", + "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==", + "license": "MIT", "dependencies": { "validator": "13.12.0", "zod": "3.23.8" @@ -9058,34 +9059,12 @@ "integrity": "sha512-tqdiS4otQP4KmY0PR3u6KbZ5EWvhNdUoS/jc93UuK23C220lOZ/9TvjfxdPcKvqwwDVtmtSCrnr0p/2dirAxkA==", "dev": true }, - "node_modules/@types/eslint": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.0.tgz", - "integrity": "sha512-gi6WQJ7cHRgZxtkQEoyHMppPjq9Kxo5Tjn2prSKDSmZrCz8TZ3jSRCeTJm+WoM+oB0WG37bRqLzaaU3q7JypGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true, + "license": "MIT", "peer": true }, "node_modules/@types/graceful-fs": { @@ -10191,6 +10170,17 @@ "acorn": "^8" } }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "acorn": "^8" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -10683,9 +10673,9 @@ } }, "node_modules/axios": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.3.tgz", - "integrity": "sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz", + "integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==", "dev": true, "license": "MIT", "dependencies": { @@ -13626,10 +13616,11 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.16.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.0.tgz", - "integrity": "sha512-O+QWCviPNSSLAD9Ucn8Awv+poAkqn3T1XY5/N7kR7rQO9yfSGWkYZDwpJ+iKF7B8rxaQKWngSqACpgzeapSyoA==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "graceful-fs": "^4.2.4", @@ -21185,11 +21176,12 @@ "integrity": "sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==" }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -27104,6 +27096,7 @@ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", "dev": true, + "license": "MIT", "peer": true, "engines": { "node": ">=6" @@ -28393,22 +28386,22 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { - "version": "5.91.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.91.0.tgz", - "integrity": "sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", + "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.16.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", diff --git a/package.json b/package.json index e1a8fbd4faf..e83cd00385e 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@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.1", + "@mattermost/react-native-network-client": "1.7.2", "@mattermost/react-native-paste-input": "0.8.0", "@mattermost/react-native-turbo-log": "0.4.0", "@mattermost/rnshare": "file:./libraries/@mattermost/rnshare", @@ -140,7 +140,7 @@ "@types/uuid": "10.0.0", "@typescript-eslint/eslint-plugin": "7.13.1", "@typescript-eslint/parser": "7.13.1", - "axios": "1.7.3", + "axios": "1.7.5", "axios-cookiejar-support": "5.0.2", "babel-jest": "29.7.0", "babel-loader": "9.1.3", From 2e46f6c015f5753caebddf0edbcc52ab9b2462fb Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 2 Sep 2024 17:43:35 +0200 Subject: [PATCH 67/67] Translations update from Mattermost Weblate (#8198) * Translated using Weblate (German) Currently translated at 100.0% (1183 of 1183 strings) Translation: Mattermost/mattermost-mobile-v2 Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/de/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1183 of 1183 strings) Translation: Mattermost/mattermost-mobile-v2 Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/zh_Hans/ * Translated using Weblate (Russian) Currently translated at 99.8% (1181 of 1183 strings) Translation: Mattermost/mattermost-mobile-v2 Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ru/ * Translated using Weblate (Swedish) Currently translated at 100.0% (1183 of 1183 strings) Translation: Mattermost/mattermost-mobile-v2 Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/sv/ * Translated using Weblate (Japanese) Currently translated at 100.0% (1183 of 1183 strings) Translation: Mattermost/mattermost-mobile-v2 Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ja/ * Translated using Weblate (Russian) Currently translated at 100.0% (1183 of 1183 strings) Translation: Mattermost/mattermost-mobile-v2 Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ru/ * Translated using Weblate (Croatian) Currently translated at 43.4% (514 of 1183 strings) Translation: Mattermost/mattermost-mobile-v2 Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/hr/ --------- Co-authored-by: jprusch Co-authored-by: ThrRip Co-authored-by: ja49619 Co-authored-by: MArtin Johnson Co-authored-by: kaakaa Co-authored-by: Milo Ivir --- assets/base/i18n/de.json | 2 ++ assets/base/i18n/hr.json | 2 +- assets/base/i18n/ja.json | 3 +++ assets/base/i18n/ru.json | 6 ++++-- assets/base/i18n/sv.json | 2 ++ assets/base/i18n/zh-CN.json | 3 +++ 6 files changed, 15 insertions(+), 3 deletions(-) diff --git a/assets/base/i18n/de.json b/assets/base/i18n/de.json index feea40faeca..02a016b29f4 100644 --- a/assets/base/i18n/de.json +++ b/assets/base/i18n/de.json @@ -703,6 +703,8 @@ "mobile.no_results_with_term.messages": "Keine Treffer für \"{term}\" gefunden", "mobile.oauth.failed_to_login": "Dein Anmeldeversuch ist fehlgeschlagen. Bitte nochmal versuchen.", "mobile.oauth.something_wrong.okButton": "OK", + "mobile.oauth.success.description": "Melde jetzt an, nur einen Moment...", + "mobile.oauth.success.title": "Authentifizierung erfolgreich", "mobile.oauth.switch_to_browser": "Du wirst zu deinem Login-Anbieter weitergeleitet", "mobile.oauth.switch_to_browser.error_title": "Fehler bei der Anmeldung", "mobile.oauth.switch_to_browser.title": "Weiterleitung...", diff --git a/assets/base/i18n/hr.json b/assets/base/i18n/hr.json index c66e67ac3fe..db5f695bd9d 100644 --- a/assets/base/i18n/hr.json +++ b/assets/base/i18n/hr.json @@ -424,7 +424,7 @@ "settings.about.server.version": "Verzija servera: {version} (izgradnja {buildNumber}", "settings.about.server.version.noBuild": "Verzija servera: {version}", "settings.about.server.version.title": "Verzija servera:", - "settings.about.server.version.value": "{version} (izgradnja {number})", + "settings.about.server.version.value": "{version} (izgradnja {buildNumber})", "settings.advanced.cancel": "Odustani", "settings.advanced.delete": "Izbriši", "settings.advanced.delete_data": "Izbriši lokalne datoteke", diff --git a/assets/base/i18n/ja.json b/assets/base/i18n/ja.json index 62f4655f53e..203f1783b4e 100644 --- a/assets/base/i18n/ja.json +++ b/assets/base/i18n/ja.json @@ -486,6 +486,7 @@ "mobile.calls_ended_at": "終了時刻", "mobile.calls_error_message": "エラー: {error}", "mobile.calls_error_title": "エラー", + "mobile.calls_group_calls_not_available": "通話はDMチャンネルでのみ利用できます。", "mobile.calls_headset": "ヘッドセット", "mobile.calls_hide_cc": "ライブキャプションを非表示にする", "mobile.calls_host": "ホスト", @@ -702,6 +703,8 @@ "mobile.no_results_with_term.messages": "\"{term}\"に該当する情報がありませんでした", "mobile.oauth.failed_to_login": "ログインできませんでした。再度試してみてください。", "mobile.oauth.something_wrong.okButton": "OK", + "mobile.oauth.success.description": "サインイン中です。少々お待ちください...", + "mobile.oauth.success.title": "認証に成功しました", "mobile.oauth.switch_to_browser": "ログインプロバイダにリダイレクトされています", "mobile.oauth.switch_to_browser.error_title": "サインインエラー", "mobile.oauth.switch_to_browser.title": "リダイレクト中...", diff --git a/assets/base/i18n/ru.json b/assets/base/i18n/ru.json index 8fab8ac2350..7530c9c00c8 100644 --- a/assets/base/i18n/ru.json +++ b/assets/base/i18n/ru.json @@ -255,11 +255,11 @@ "combined_system_message.left_team.two": "{firstUser} и {secondUser} **покинули команду**.", "combined_system_message.removed_from_channel.many_expanded": "{users} и {lastUser} **удалены с канала**.", "combined_system_message.removed_from_channel.one": "{firstUser} был **удалён с канала**.", - "combined_system_message.removed_from_channel.one_you": "Вы были **удалены с канала**.", + "combined_system_message.removed_from_channel.one_you": "Вы были **удалены из канала**.", "combined_system_message.removed_from_channel.two": "{firstUser} и {secondUser} были **удалены с канала**.", "combined_system_message.removed_from_team.many_expanded": "{users} и {lastUser} **удалены из команды**.", "combined_system_message.removed_from_team.one": "{firstUser} был **удалён из команды**.", - "combined_system_message.removed_from_team.one_you": "Вы были **удалены с канала**.", + "combined_system_message.removed_from_team.one_you": "Вы были **удалены из канала**.", "combined_system_message.removed_from_team.two": "{firstUser} и {secondUser} **удалены из команды**.", "combined_system_message.you": "Вы", "connection_banner.connected": "Соединение восстановлено", @@ -703,6 +703,8 @@ "mobile.no_results_with_term.messages": "Не найдено совпадений для \"{term}\"", "mobile.oauth.failed_to_login": "Ваша попытка входа не удалась. Пожалуйста, попробуйте ещё раз.", "mobile.oauth.something_wrong.okButton": "ОК", + "mobile.oauth.success.description": "Входим в систему, один момент...", + "mobile.oauth.success.title": "Аутентификация успешна", "mobile.oauth.switch_to_browser": "Вы перенаправляетесь к поставщику услуг входа в систему", "mobile.oauth.switch_to_browser.error_title": "Ошибка при входе", "mobile.oauth.switch_to_browser.title": "Перенаправление...", diff --git a/assets/base/i18n/sv.json b/assets/base/i18n/sv.json index 53596efd322..f748a7200ad 100644 --- a/assets/base/i18n/sv.json +++ b/assets/base/i18n/sv.json @@ -703,6 +703,8 @@ "mobile.no_results_with_term.messages": "Inga träffar hittades för “{term}”", "mobile.oauth.failed_to_login": "Inloggningen misslyckades. Försök igen.", "mobile.oauth.something_wrong.okButton": "OK", + "mobile.oauth.success.description": "Inloggning pågår, ett ögonblick...", + "mobile.oauth.success.title": "Autentiseringen lyckades", "mobile.oauth.switch_to_browser": "Du omdirigeras till din inloggningsleverantör", "mobile.oauth.switch_to_browser.error_title": "Fel vid inloggning", "mobile.oauth.switch_to_browser.title": "Omdirigeras...", diff --git a/assets/base/i18n/zh-CN.json b/assets/base/i18n/zh-CN.json index ee03afc6a9a..11ad61b8775 100644 --- a/assets/base/i18n/zh-CN.json +++ b/assets/base/i18n/zh-CN.json @@ -486,6 +486,7 @@ "mobile.calls_ended_at": "结束于", "mobile.calls_error_message": "错误:{error}", "mobile.calls_error_title": "错误", + "mobile.calls_group_calls_not_available": "通话仅在私信中可用。", "mobile.calls_headset": "耳机", "mobile.calls_hide_cc": "隐藏实时字幕", "mobile.calls_host": "主持人", @@ -702,6 +703,8 @@ "mobile.no_results_with_term.messages": "\"{term}\"没有匹配的发现", "mobile.oauth.failed_to_login": "登入失败。请重试。", "mobile.oauth.something_wrong.okButton": "好的", + "mobile.oauth.success.description": "正在登录,请稍候…", + "mobile.oauth.success.title": "验证成功", "mobile.oauth.switch_to_browser": "您正在被切换到登录服务提供商", "mobile.oauth.switch_to_browser.error_title": "登录错误", "mobile.oauth.switch_to_browser.title": "切换中…",