Skip to content

Commit f4af0c8

Browse files
committed
feat(ActivityStream): load more activities on scroll up
1 parent 9b2fd45 commit f4af0c8

File tree

7 files changed

+169
-22
lines changed

7 files changed

+169
-22
lines changed

src/components/WebexActivityStream/WebexActivityStream.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import React, {Fragment} from 'react';
1+
import React, {Fragment, useRef} from 'react';
22
import PropTypes from 'prop-types';
33
import {ListSeparator} from '@momentum-ui/react';
44
import {format, isToday, isSameWeek, isYesterday} from 'date-fns';
55

66
import {RoomType} from '../../adapters/RoomsAdapter';
7-
import {useActivityStream, useRoom} from '../hooks';
7+
import {useActivityStream, useActivityScroll, useRoom} from '../hooks';
8+
import {PREPEND_ACTIVITIES} from '../hooks/useActivityStream';
89
import WebexActivity from '../WebexActivity/WebexActivity';
910

1011
import './WebexActivityStream.scss';
@@ -179,8 +180,15 @@ TimeRuler.propTypes = {
179180
};
180181

181182
export default function WebexActivityStream({roomID}) {
183+
const [activitiesData, dispatch] = useActivityStream(roomID);
184+
const loadPreviousActivities = (previousActivities) => {
185+
dispatch({type: PREPEND_ACTIVITIES, payload: previousActivities});
186+
};
187+
182188
const {title, roomType} = useRoom(roomID);
183-
const activitiesData = useActivityStream(roomID);
189+
const activityStreamRef = useRef(null);
190+
const showLoader = useActivityScroll(roomID, activityStreamRef, loadPreviousActivities);
191+
184192
const personName = roomType === RoomType.DIRECT ? title : '';
185193
const activities = activitiesData.map((activity) => {
186194
// If the activity is an object with a date property, it is a time ruler
@@ -194,7 +202,8 @@ export default function WebexActivityStream({roomID}) {
194202
});
195203

196204
return (
197-
<div className="activity-stream">
205+
<div className="activity-stream" ref={activityStreamRef}>
206+
{showLoader && <div className="activity-stream-loader" />}
198207
{activities.length ? <Fragment>{activities}</Fragment> : <Greeting personName={personName} />}
199208
</div>
200209
);

src/components/WebexActivityStream/WebexActivityStream.scss

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,61 @@
1+
.activity-stream {
2+
position: relative;
3+
width: 100%;
4+
height: 100px;
5+
overflow-y: auto;
6+
7+
&-loader {
8+
position: relative;
9+
width: 10px;
10+
height: 10px;
11+
margin: 1rem auto;
12+
border-radius: 5px;
13+
background-color: $md-gray-30;
14+
color: $md-gray-30;
15+
animation: dotFlashing 1s infinite linear alternate;
16+
animation-delay: .5s;
17+
}
18+
19+
&-loader::before, &-loader::after {
20+
content: '';
21+
display: inline-block;
22+
position: absolute;
23+
top: 0;
24+
}
25+
26+
&-loader::before {
27+
left: -15px;
28+
width: 10px;
29+
height: 10px;
30+
border-radius: 5px;
31+
background-color: $md-gray-30;
32+
color: $md-gray-30;
33+
animation: dotFlashing 1s infinite alternate;
34+
animation-delay: 0s;
35+
}
36+
37+
&-loader::after {
38+
left: 15px;
39+
width: 10px;
40+
height: 10px;
41+
border-radius: 5px;
42+
background-color: $md-gray-30;
43+
color: $md-gray-30;
44+
animation: dotFlashing 1s infinite alternate;
45+
animation-delay: 1s;
46+
}
47+
48+
@keyframes dotFlashing {
49+
0% {
50+
background-color: $md-gray-30;
51+
}
52+
50%,
53+
100% {
54+
background-color: $md-gray-10;
55+
}
56+
}
57+
}
58+
159
.greeting {
260
display: block;
361
flex-direction: column;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function useActivityScroll() {
2+
return false;
3+
}

src/components/hooks/__mocks__/useActivityStream.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import {useContext} from 'react';
22

33
export default function useActivityStream(ID) {
44
const datasource = useContext();
5+
let data = [];
56

6-
return `${ID}-activities` in datasource.roomsAdapter ? datasource.roomsAdapter[`${ID}-activities`] : [];
7+
if (`${ID}-activities` in datasource.roomsAdapter) {
8+
data = datasource.roomsAdapter[`${ID}-activities`];
9+
}
10+
11+
return [data, () => {}];
712
}

src/components/hooks/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {default as useActivity} from './useActivity';
2+
export {default as useActivityScroll} from './useActivityScroll';
23
export {default as useActivityStream} from './useActivityStream';
34
export {default as usePerson} from './usePerson';
45
export {default as useRoom} from './useRoom';
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {useContext, useEffect, useState} from 'react';
2+
import {empty, fromEvent} from 'rxjs';
3+
import {filter, flatMap} from 'rxjs/operators';
4+
5+
import {AdapterContext} from '../../components/';
6+
7+
/**
8+
* Callback function to execute once data has been fetched.
9+
*
10+
* @callback previousActivitiesCallback
11+
* @param {Array.<string|ActivityDate>} previousActivities Array of previous activities fetched.
12+
* @returns undefined
13+
*/
14+
15+
/**
16+
17+
/**
18+
* Custom hook returns a loading state to indicate when data is being fetched,
19+
* once user has scrolled to the top of the component of the given DOM reference.
20+
*
21+
* @param {string} roomID ID of the room from which to fetch data.
22+
* @param {object} elementRef reference to the element to attach scroll listener.
23+
* @param {previousActivitiesCallback} callback Callback to execute once data has been fetched.
24+
* @returns {boolean} Data loading state.
25+
*/
26+
export default function useActivityScroll(roomID, elementRef, callback) {
27+
const {roomsAdapter} = useContext(AdapterContext);
28+
const [showLoader, setShowLoader] = useState(false);
29+
30+
useEffect(() => {
31+
let scrollListener = empty();
32+
33+
if (elementRef.current) {
34+
// Listen to scroll event, and on reaching the top,
35+
// And load previous activities if there are any
36+
scrollListener = fromEvent(elementRef.current, 'scroll')
37+
.pipe(
38+
filter((event) => {
39+
const isViewportTop = event.target.scrollTop === 0;
40+
const loadMoreItems = isViewportTop && roomsAdapter.hasMoreActivities(roomID);
41+
42+
if (loadMoreItems) {
43+
setShowLoader(true);
44+
}
45+
46+
return loadMoreItems;
47+
}),
48+
flatMap(() => roomsAdapter.getPreviousRoomActivities(roomID))
49+
)
50+
.subscribe((previousActivities) => {
51+
// Show loader for half second
52+
setTimeout(() => {
53+
callback(previousActivities);
54+
setShowLoader(false);
55+
}, 500);
56+
});
57+
}
58+
59+
return () => {
60+
scrollListener.unsubscribe();
61+
};
62+
// eslint-disable-next-line react-hooks/exhaustive-deps
63+
}, []);
64+
65+
return showLoader;
66+
}

src/components/hooks/useActivityStream.js

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,52 +2,57 @@ import {useContext, useEffect, useReducer} from 'react';
22

33
import {AdapterContext} from '../../components/';
44

5-
const APPEND_ACTIVITIES = 'append_activities';
5+
export const PREPEND_ACTIVITIES = 'prepend_activities';
6+
export const APPEND_ACTIVITIES = 'append_activities';
67

78
/**
8-
* Returns a new array of activityIDs based on the given action.
9-
* If no action is passed, it will return the same activityIDs
9+
* Returns a new array of activities based on the given action.
10+
* If no action is passed, it will return the same activities
1011
* array without any changes.
1112
*
12-
* @param {Array.<string>} activityIDs activityIDs associated to the room
13-
* @param {object} action action to apply to given activityIDs
14-
* @returns {Array.<string>} New activityIDs associated to the room
13+
* @param {Array.<string|ActivityDate>} activities activities associated to the room
14+
* @param {object} action action to apply to given activities
15+
* @returns {Array.<string|ActivityDate>}
1516
*/
16-
function reducer(activityIDs, action) {
17-
let newActivityIDs = [];
17+
function reducer(activities, action) {
18+
let newActivities = [];
1819

1920
switch (action.type) {
21+
case PREPEND_ACTIVITIES:
22+
newActivities = action.payload.concat(activities);
23+
break;
2024
case APPEND_ACTIVITIES:
21-
newActivityIDs = activityIDs.concat(action.payload);
25+
newActivities = activities.concat(action.payload);
2226
break;
2327
default:
24-
newActivityIDs = activityIDs;
28+
newActivities = activities;
2529
break;
2630
}
2731

28-
return newActivityIDs;
32+
return newActivities;
2933
}
3034

3135
/**
3236
* Custom hook that returns activity data associated to the room of the given ID.
3337
*
3438
* @param {string} roomID ID of the room for which to return data.
35-
* @returns {Room} Activity ID associated to the room
39+
* @returns {Array} Activities state and state setter
3640
*/
3741
export default function useActivityStream(roomID) {
38-
const [activityIDs, dispatch] = useReducer(reducer, []);
3942
const {roomsAdapter} = useContext(AdapterContext);
43+
const [activities, dispatch] = useReducer(reducer, []);
4044

45+
// Subscribe to future updates on load
4146
useEffect(() => {
42-
const subscription = roomsAdapter.getRoomActivities(roomID).subscribe((activities) => {
43-
dispatch({type: APPEND_ACTIVITIES, payload: activities});
47+
const activityUpdates = roomsAdapter.getRoomActivities(roomID).subscribe((activityData) => {
48+
dispatch({type: APPEND_ACTIVITIES, payload: activityData});
4449
});
4550

4651
return () => {
47-
subscription.unsubscribe();
52+
activityUpdates.unsubscribe();
4853
};
4954
// eslint-disable-next-line react-hooks/exhaustive-deps
5055
}, []);
5156

52-
return activityIDs;
57+
return [activities, dispatch];
5358
}

0 commit comments

Comments
 (0)