diff --git a/src/RoomsSDKAdapter.js b/src/RoomsSDKAdapter.js index 28911c62..3d0eb13b 100644 --- a/src/RoomsSDKAdapter.js +++ b/src/RoomsSDKAdapter.js @@ -1,9 +1,11 @@ import { + Subject, concat, from, fromEvent, BehaviorSubject, Observable, + throwError, } from 'rxjs'; import { filter, @@ -28,6 +30,11 @@ import logger from './logger'; export const ROOM_UPDATED_EVENT = 'updated'; export const CONVERSATION_ACTIVITY_EVENT = 'event:conversation.activity'; +const sortByPublished = (arr) => arr.sort((a, b) => new Date(b.published) - new Date(a.published)); + +// TODO: Need to remove this once we figure out why we need to pre-cache conversations +let FETCHED_CONVERSATIONS = false; + /** * The `RoomsSDKAdapter` is an implementation of the `RoomsAdapter` interface. * This adapter utilizes the Webex JS SDK to fetch data about a room. @@ -41,6 +48,10 @@ export default class RoomsSDKAdapter extends RoomsAdapter { this.getRoomObservables = {}; this.getRoomActivitiesCache = {}; this.listenerCount = 0; + + this.activityLimit = 50; + this.activitiesObservableCache = new Map(); + this.roomActivities = new Map(); } /** @@ -51,12 +62,18 @@ export default class RoomsSDKAdapter extends RoomsAdapter { * @returns {Room} Information about the room of the given ID */ async fetchRoom(ID) { - const {id, title, type} = await this.datasource.rooms.get(ID); + const { + id, + title, + type, + lastActivity, + } = await this.datasource.rooms.get(ID); return { ID: id, title, type, + lastActivity, }; } @@ -140,6 +157,135 @@ export default class RoomsSDKAdapter extends RoomsAdapter { return this.getRoomObservables[ID]; } + /** + * Returns an array of IDs of the most recent activities in a conversation up to the specified limit. + * + * @param {string} ID ID for the room + * @param {string} earliestActivityDate Get all child activities before this date + * @returns {Promise} Resolves with array of activities + * @private + */ + async fetchActivities(ID, earliestActivityDate) { + const {activityLimit} = this; + const conversationId = deconstructHydraId(ID).id; + + logger.debug('ROOM', ID, 'fetchActivities()', ['called with', { + earliestActivityDate, + activityLimit, + }]); + + if (!FETCHED_CONVERSATIONS) { + await this.datasource.internal.conversation.list(); + FETCHED_CONVERSATIONS = true; + } + + return this.datasource.internal.conversation.listActivities({ + conversationId, + limit: activityLimit + 1, // Fetch one extra activity to determine if there are more activities to fetch later + lastActivityFirst: true, + maxDate: earliestActivityDate === null ? undefined : earliestActivityDate, + }); + } + + /** + * Returns `true` if there are more activities to load from the room of the given ID. + * Otherwise, it returns `false`. + * + * @param {string} ID ID of the room for which to verify activities. + * @returns {boolean} `true` if room has more activities to load, `false` otherwise + */ + hasMoreActivities(ID) { + const pastActivities$Cache = this.activitiesObservableCache.get(ID); + const { + hasMore = true, + } = this.roomActivities.get(ID); + + if (!hasMore) { + pastActivities$Cache.complete(); + } else { + this.fetchPastActivities(ID); + } + + return hasMore; + } + + /** + * Fetches past activities and returns array of (id, published) objects. Performs side effects + * + * @param {string} ID The id of the room + * @returns null + */ + fetchPastActivities(ID) { + const roomActivity = this.roomActivities.get(ID); + const {earliestActivityDate} = roomActivity; + const room$ = this.activitiesObservableCache.get(ID); + + logger.debug('ROOM', ID, 'fetchPastActivities()', ['called with', { + earliestActivityDate, + }]); + + if (!ID) { + logger.error('ROOM', ID, 'fetchPastActivities()', ['Must provide room ID']); + room$.error(new Error('fetchPastActivities - Must provide room ID')); + } + + from(this.fetchActivities(ID, earliestActivityDate)) + .subscribe((data) => { + if (!data) { + return room$.complete(); + } + roomActivity.hasMore = data.length >= this.activityLimit + 1; + const {published} = data.shift(); + const activityIds = sortByPublished(data).map((activity) => { + const {id} = activity; + + roomActivity.activities.set(id, activity); + + return [id, activity.published]; + }); + + roomActivity.earliestActivityDate = published; + roomActivity.activityIds.set(published, activityIds.length); + + this.roomActivities.set(ID, roomActivity); + + return room$.next(activityIds); + }); + } + + /** + * Returns an observable that emits an array of the next chunk of previous + * activity data of the given roomID. If `hasMoreActivities` returns false, + * the observable will complete. + * **Previous activity data must be sorted newest-to-oldest.** + * + * @param {string} ID ID of the room for which to get activities. + * @param {number} activityLimit The maximum number of activities to return + * @returns {external:Observable.} Observable stream that emits activity data + */ + getPreviousActivities(ID, activityLimit = 50) { + this.activityLimit = activityLimit; + const pastActivities$Cache = this.activitiesObservableCache.get(ID) || new Subject(); + + if (!ID) { + logger.error('ROOM', ID, 'getPreviousActivities()', ['Must provide room ID']); + + return throwError(new Error('getPreviousActivities - Must provide room ID')); + } + + if (!this.roomActivities.has(ID)) { + this.roomActivities.set(ID, { + earliestActivityDate: null, + activities: new Map(), + activityIds: new Map(), + }); + } + + this.activitiesObservableCache.set(ID, pastActivities$Cache); + + return pastActivities$Cache; + } + /** * Returns an observable that emits current and future activities from the specified room. * diff --git a/src/RoomsSDKAdapter.test.js b/src/RoomsSDKAdapter.test.js index 74ee77db..ab962d3f 100644 --- a/src/RoomsSDKAdapter.test.js +++ b/src/RoomsSDKAdapter.test.js @@ -1,14 +1,17 @@ import {isObservable} from 'rxjs'; import RoomsSDKAdapter from './RoomsSDKAdapter'; +import mockActivities from './mockActivities'; import createMockSDK, {mockSDKActivity, mockSDKRoom} from './mockSdk'; describe('Rooms SDK Adapter', () => { let mockSDK; let roomsSDKAdapter; + const roomId = mockSDKRoom.id; beforeEach(() => { mockSDK = createMockSDK(); + mockSDK.internal.conversation.list = jest.fn(() => Promise.resolve([])); roomsSDKAdapter = new RoomsSDKAdapter(mockSDK); }); @@ -82,6 +85,64 @@ describe('Rooms SDK Adapter', () => { }); }); + describe('getPreviousActivities() functionality', () => { + const getPreviousMock = jest.fn(); + + beforeAll(() => { + getPreviousMock + .mockReturnValueOnce(mockActivities.slice(2)) + .mockReturnValueOnce(mockActivities.slice(4)) + .mockReturnValueOnce(null); + }); + + test('returns an observable', () => { + expect(isObservable(roomsSDKAdapter.getPreviousActivities(roomId))) + .toBeTruthy(); + }); + + test('completes when all activities have been emitted', (done) => { + let itemsCount = 0; + + mockSDK.internal.conversation.listActivities = getPreviousMock; + roomsSDKAdapter = new RoomsSDKAdapter(mockSDK); + + roomsSDKAdapter.getPreviousActivities(roomId, 5).subscribe({ + next(activities) { + itemsCount += activities.length; + }, + complete() { + expect(itemsCount).toBe(8); + done(); + }, + }); + + roomsSDKAdapter.hasMoreActivities(roomId); // 5 + roomsSDKAdapter.hasMoreActivities(roomId); // 3 + roomsSDKAdapter.hasMoreActivities(roomId); // no more + }); + + test('throws error if no room id is present', (done) => { + roomsSDKAdapter.getPreviousActivities().subscribe({ + next() {}, + error(e) { + expect(e).toEqual(new Error('getPreviousActivities - Must provide room ID')); + done(); + }, + }); + }); + + test('sets empty roomActivities if no room exists', () => { + expect(roomsSDKAdapter.roomActivities.has('room-1')).toBe(false); + roomsSDKAdapter.getPreviousActivities('room-1'); + expect(roomsSDKAdapter.roomActivities.has('room-1')).toBe(true); + expect(roomsSDKAdapter.roomActivities.get('room-1')).toStrictEqual({ + activities: new Map(), + earliestActivityDate: null, + activityIds: new Map(), + }); + }); + }); + afterEach(() => { roomsSDKAdapter = null; }); diff --git a/src/mockActivities.js b/src/mockActivities.js new file mode 100644 index 00000000..7499f3a7 --- /dev/null +++ b/src/mockActivities.js @@ -0,0 +1,34 @@ +export default [ + { + id: 'bc2266b2-d6c3-11eb-aef5-6d77908bfcbb', + published: '2021-06-25T21:16:12.827Z', + }, + { + id: 'c6d88ee0-d6c3-11eb-8b76-fb213805e947', + published: '2021-06-23T21:16:30.798Z', + }, + { + id: '5f5ad4a0-d7b6-11eb-a8bc-1badfdac8742', + published: '2021-06-24T02:13:04.874Z', + }, + { + id: 'd33057d0-d831-11eb-a954-81c4a13d6973', + published: '2021-06-25T16:56:47.309Z', + }, + { + id: '69128f70-d841-11eb-9511-0d3f153f12e6', + published: '2021-06-26T18:48:21.223Z', + }, + { + id: '81a8f6a0-d841-11eb-a4e5-af1950fe4a84', + published: '2021-06-27T18:49:02.474Z', + }, + { + id: 'b94c20a0-d841-11eb-9efc-3f4590d442ae', + published: '2021-06-28T18:50:35.818Z', + }, + { + id: 'c0270a70-d841-11eb-9cb2-cfdb30c70966', + published: '2021-06-29T18:50:47.319Z', + }, +]; diff --git a/src/mockSdk.js b/src/mockSdk.js index 66527a26..3e9d2183 100644 --- a/src/mockSdk.js +++ b/src/mockSdk.js @@ -1,7 +1,8 @@ import mockDevices from './mockDevices'; +import mockActivities from './mockActivities'; export const mockSDKRoom = { - id: 'abc', + id: 'Y2lzY29zcGFyazovL3VzL1JPT00vYmMyMjY2YjAtZDZjMy0xMWViLWFlZjUtNmQ3NzkwOGJmY2Ji', type: 'group', title: 'mock room', }; @@ -164,6 +165,10 @@ export const mockSDKOrganization = { displayName: 'Cisco Systems, Inc.', }; +const mockInternalConversationAPI = { + listActivities: jest.fn(() => Promise.resolve(mockActivities)), +}; + export const mockSDKCardActivity = { id: 'activityID', roomId: 'roomID', @@ -212,9 +217,10 @@ export const mockSDKAttachmentAction = { /** * Creates a mock instance of the Webex SDK used in unit testing * + * @param api * @returns {object} mockSDK Instance */ -export default function createMockSDK() { +export default function createMockSDK(api = {}) { const mockSDKMeeting = createMockSDKMeeting(); return { @@ -236,6 +242,7 @@ export default function createMockSDK() { subscribe: jest.fn(() => Promise.resolve({responses: [{status: {status: 'active'}}]})), unsubscribe: jest.fn(() => Promise.resolve()), }, + conversation: mockInternalConversationAPI, }, people: { get: jest.fn(() => Promise.resolve(mockSDKPerson)), @@ -282,5 +289,6 @@ export default function createMockSDK() { messages: { create: jest.fn(() => Promise.resolve(mockSDKCardActivity)), }, + ...api, }; }