From e6254885ee1c6add5b63579c7cb3b9a1f3fa0866 Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Wed, 26 Apr 2023 15:20:07 -0400 Subject: [PATCH] MM-43737 - Calls: Follow call thread on user joining a call (#7293) * follow threads on call join * treat custom_calls posts as the start of threads * make thread follow logic robust to race conditions; tests * only follow thread when CRT is enabled * Revert "only follow thread when CRT is enabled" This reverts commit 4e52a2619b35db3d2104f0b6ba5ad8be23f7b224. --- app/products/calls/actions/calls.test.ts | 20 ++++++++++++-- app/products/calls/actions/calls.ts | 21 ++++++++++++++ app/products/calls/state/actions.test.ts | 35 ++++++++++++++++++++++-- app/products/calls/state/actions.ts | 19 ++++++++++++- app/queries/servers/thread.ts | 3 +- 5 files changed, 91 insertions(+), 7 deletions(-) diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts index 334c72dc3..6d975e50e 100644 --- a/app/products/calls/actions/calls.test.ts +++ b/app/products/calls/actions/calls.test.ts @@ -32,6 +32,7 @@ import { DefaultCallsConfig, DefaultCallsState, } from '@calls/types/calls'; +import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; const mockClient = { @@ -77,6 +78,16 @@ jest.mock('@calls/connection/connection', () => ({ })), })); +jest.mock('@actions/remote/thread', () => ({ + updateThreadFollowing: jest.fn(() => Promise.resolve({})), +})); + +jest.mock('@queries/servers/thread', () => ({ + getThreadById: jest.fn(() => Promise.resolve({ + isFollowing: false, + })), +})); + jest.mock('@calls/alerts'); const addFakeCall = (serverUrl: string, channelId: string) => { @@ -104,6 +115,8 @@ const addFakeCall = (serverUrl: string, channelId: string) => { describe('Actions.Calls', () => { const {newConnection} = require('@calls/connection/connection'); + const {updateThreadFollowing} = require('@actions/remote/thread'); + InCallManager.setSpeakerphoneOn = jest.fn(); InCallManager.setForceSpeakerphoneOn = jest.fn(); // eslint-disable-next-line @@ -111,7 +124,9 @@ describe('Actions.Calls', () => { NetworkManager.getClient = () => mockClient; jest.spyOn(Permissions, 'hasMicrophonePermission').mockReturnValue(Promise.resolve(true)); - beforeAll(() => { + beforeAll(async () => { + await DatabaseManager.init(['server1']); + // create subjects const {result} = renderHook(() => { return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall(), useCallsConfig('server1')]; @@ -125,6 +140,7 @@ describe('Actions.Calls', () => { beforeEach(() => { newConnection.mockClear(); + updateThreadFollowing.mockClear(); mockClient.getCalls.mockClear(); mockClient.getCallsConfig.mockClear(); mockClient.getPluginsManifests.mockClear(); @@ -152,13 +168,13 @@ describe('Actions.Calls', () => { // manually call newCurrentConnection because newConnection is mocked newCurrentCall('server1', 'channel-id', 'myUserId'); - userJoinedCall('server1', 'channel-id', 'myUserId'); }); assert.equal(response!.data, 'channel-id'); assert.equal((result.current[1] as CurrentCall).channelId, 'channel-id'); expect(newConnection).toBeCalled(); expect(newConnection.mock.calls[0][1]).toBe('channel-id'); + expect(updateThreadFollowing).toBeCalled(); await act(async () => { CallsActions.leaveCall(); diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 274253dc2..fe087ba85 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -6,6 +6,7 @@ import InCallManager from 'react-native-incall-manager'; import {Navigation} from 'react-native-navigation'; import {forceLogoutIfNecessary} from '@actions/remote/session'; +import {updateThreadFollowing} from '@actions/remote/thread'; import {fetchUsersByIds} from '@actions/remote/user'; import {leaveAndJoinWithAlert, needsRecordingWillBePostedAlert, needsRecordingErrorAlert} from '@calls/alerts'; import { @@ -31,6 +32,7 @@ import NetworkManager from '@managers/network_manager'; import {getChannelById} from '@queries/servers/channel'; import {queryDisplayNamePreferences} from '@queries/servers/preference'; import {getConfig, getLicense} from '@queries/servers/system'; +import {getThreadById} from '@queries/servers/thread'; import {getCurrentUser, getUserById} from '@queries/servers/user'; import {dismissAllModalsAndPopToScreen} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; @@ -265,6 +267,25 @@ export const joinCall = async ( try { await connection.waitForPeerConnection(); + + // Follow the thread. + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {data: channelId}; + } + + // If this was a call started by ourselves, then we should have subscribed in the start_call ws handler + // (unless we received the start_call ws before the post/thread ws). + // If this was us joining an existing call, follow the thread here. + const call = getCallsState(serverUrl).calls[channelId]; + if (call && call.threadId) { + const thread = await getThreadById(database, call.threadId); + if (thread && !thread.isFollowing) { + const channel = await getChannelById(database, channelId); + updateThreadFollowing(serverUrl, channel?.teamId || '', call.threadId, true, false); + } + } + return {data: channelId}; } catch (e) { connection.disconnect(); diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index 2bc8e369a..7058e44ac 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -51,11 +51,22 @@ import { type GlobalCallsState, } from '@calls/types/calls'; import {License} from '@constants'; +import DatabaseManager from '@database/manager'; import type {CallRecordingState} from '@mattermost/calls/lib/types'; jest.mock('@calls/alerts'); +jest.mock('@actions/remote/thread', () => ({ + updateThreadFollowing: jest.fn(() => Promise.resolve({})), +})); + +jest.mock('@queries/servers/thread', () => ({ + getThreadById: jest.fn(() => Promise.resolve({ + isFollowing: false, + })), +})); + const call1: Call = { participants: { 'user-1': {id: 'user-1', muted: false, raisedHand: 0}, @@ -94,6 +105,8 @@ const call3: Call = { }; describe('useCallsState', () => { + const {updateThreadFollowing} = require('@actions/remote/thread'); + beforeAll(() => { // create subjects const {result} = renderHook(() => { @@ -107,6 +120,8 @@ describe('useCallsState', () => { beforeEach(() => { // reset to default state for each test + updateThreadFollowing.mockClear(); + act(() => { setCallsState('server1', DefaultCallsState); setChannelsWithCalls('server1', {}); @@ -372,8 +387,18 @@ describe('useCallsState', () => { assert.deepEqual(result.current[2], expectedCurrentCallState); }); - it('callStarted', () => { + it('callStarted', async () => { + const initialCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, + connected: false, + serverUrl: 'server1', + myUserId: 'myUserId', + ...call1, + }; + // setup + await DatabaseManager.init(['server1']); + const {result} = renderHook(() => { return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall()]; }); @@ -382,10 +407,14 @@ describe('useCallsState', () => { assert.deepEqual(result.current[2], null); // test - act(() => callStarted('server1', call1)); + await act(async () => { + setCurrentCall(initialCurrentCallState); + await callStarted('server1', call1); + }); assert.deepEqual((result.current[0] as CallsState).calls, {'channel-1': call1}); assert.deepEqual(result.current[1], {'channel-1': true}); - assert.deepEqual(result.current[2], null); + assert.deepEqual(result.current[2], initialCurrentCallState); + expect(updateThreadFollowing).toBeCalled(); }); it('callEnded', () => { diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 8fb72a0e6..e60bdce78 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {updateThreadFollowing} from '@actions/remote/thread'; import {needsRecordingAlert} from '@calls/alerts'; import { getCallsConfig, @@ -24,6 +25,9 @@ import { type ReactionStreamEmoji, } from '@calls/types/calls'; import {REACTION_LIMIT, REACTION_TIMEOUT} from '@constants/calls'; +import DatabaseManager from '@database/manager'; +import {getChannelById} from '@queries/servers/channel'; +import {getThreadById} from '@queries/servers/thread'; import type {CallRecordingState, UserReactionData} from '@mattermost/calls/lib/types'; @@ -212,7 +216,7 @@ export const myselfLeftCall = () => { setCurrentCall(null); }; -export const callStarted = (serverUrl: string, call: Call) => { +export const callStarted = async (serverUrl: string, call: Call) => { const callsState = getCallsState(serverUrl); const nextCalls = {...callsState.calls}; nextCalls[call.channelId] = call; @@ -233,6 +237,19 @@ export const callStarted = (serverUrl: string, call: Call) => { ...call, }; setCurrentCall(nextCurrentCall); + + // We started the call, and it succeeded, so follow the call thread. + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return; + } + + // Make sure the post/thread has arrived from the server. + const thread = await getThreadById(database, call.threadId); + if (thread && !thread.isFollowing) { + const channel = await getChannelById(database, call.channelId); + updateThreadFollowing(serverUrl, channel?.teamId || '', call.threadId, true, false); + } }; export const callEnded = (serverUrl: string, channelId: string) => { diff --git a/app/queries/servers/thread.ts b/app/queries/servers/thread.ts index 9c7b98c19..e7506c142 100644 --- a/app/queries/servers/thread.ts +++ b/app/queries/servers/thread.ts @@ -7,6 +7,7 @@ import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'; import {Config} from '@constants'; import {MM_TABLES} from '@constants/database'; +import {PostTypes} from '@constants/post'; import {processIsCRTAllowed, processIsCRTEnabled} from '@utils/thread'; import {observeChannel} from './channel'; @@ -116,7 +117,7 @@ export const prepareThreadsFromReceivedPosts = async (operator: ServerDataOperat let processedThreads: Set | undefined; posts.forEach((post: Post) => { - if (!post.root_id && post.type === '') { + if (!post.root_id && ['', PostTypes.CUSTOM_CALLS].includes(post.type)) { threads.push({ id: post.id, participants: post.participants,