Skip to content

Commit 860b3f9

Browse files
authored
fix: send XBlock visibility status to the LMS (#1491)
1 parent 4418c54 commit 860b3f9

File tree

4 files changed

+106
-1
lines changed

4 files changed

+106
-1
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"husky": "7.0.4",
5757
"joi": "^17.11.0",
5858
"js-cookie": "3.0.5",
59+
"lodash": "^4.17.21",
5960
"lodash.camelcase": "4.3.0",
6061
"patch-package": "^8.0.0",
6162
"postcss-loader": "^8.1.1",

src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getConfig } from '@edx/frontend-platform';
22
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
33
import React from 'react';
44
import { useDispatch } from 'react-redux';
5+
import { throttle } from 'lodash';
56

67
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
78
import { logError } from '@edx/frontend-platform/logging';
@@ -85,6 +86,49 @@ const useIFrameBehavior = ({
8586

8687
useEventListener('message', receiveMessage);
8788

89+
// Send visibility status to the iframe. It's used to mark XBlocks as viewed.
90+
React.useEffect(() => {
91+
if (!hasLoaded) {
92+
return undefined;
93+
}
94+
95+
const iframeElement = document.getElementById(elementId);
96+
if (!iframeElement || !iframeElement.contentWindow) {
97+
return undefined;
98+
}
99+
100+
const updateIframeVisibility = () => {
101+
const rect = iframeElement.getBoundingClientRect();
102+
const visibleInfo = {
103+
type: 'unit.visibilityStatus',
104+
data: {
105+
topPosition: rect.top,
106+
viewportHeight: window.innerHeight,
107+
},
108+
};
109+
iframeElement.contentWindow.postMessage(
110+
visibleInfo,
111+
`${getConfig().LMS_BASE_URL}`,
112+
);
113+
};
114+
115+
// Throttle the update function to prevent it from sending too many messages to the iframe.
116+
const throttledUpdateVisibility = throttle(updateIframeVisibility, 100);
117+
118+
// Update the visibility of the iframe in case the element is already visible.
119+
updateIframeVisibility();
120+
121+
// Add event listeners to update the visibility of the iframe when the window is scrolled or resized.
122+
window.addEventListener('scroll', throttledUpdateVisibility);
123+
window.addEventListener('resize', throttledUpdateVisibility);
124+
125+
// Clean up event listeners on unmount.
126+
return () => {
127+
window.removeEventListener('scroll', throttledUpdateVisibility);
128+
window.removeEventListener('resize', throttledUpdateVisibility);
129+
};
130+
}, [hasLoaded, elementId]);
131+
88132
/**
89133
* onLoad *should* only fire after everything in the iframe has finished its own load events.
90134
* Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already

src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.test.js

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ jest.mock('react-redux', () => ({
3030
useDispatch: jest.fn(),
3131
}));
3232

33+
jest.mock('lodash', () => ({
34+
...jest.requireActual('lodash'),
35+
throttle: jest.fn((fn) => fn),
36+
}));
37+
3338
jest.mock('./useLoadBearingHook', () => jest.fn());
3439

3540
jest.mock('@edx/frontend-platform/logging', () => ({
@@ -64,7 +69,10 @@ const dispatch = jest.fn();
6469
useDispatch.mockReturnValue(dispatch);
6570

6671
const postMessage = jest.fn();
67-
const frame = { contentWindow: { postMessage } };
72+
const frame = {
73+
contentWindow: { postMessage },
74+
getBoundingClientRect: jest.fn(() => ({ top: 100 })),
75+
};
6876
const mockGetElementById = jest.fn(() => frame);
6977
const testHash = '#test-hash';
7078

@@ -87,6 +95,10 @@ describe('useIFrameBehavior hook', () => {
8795
beforeEach(() => {
8896
jest.clearAllMocks();
8997
state.mock();
98+
global.document.getElementById = mockGetElementById;
99+
global.window.addEventListener = jest.fn();
100+
global.window.removeEventListener = jest.fn();
101+
global.window.innerHeight = 800;
90102
});
91103
afterEach(() => {
92104
state.resetVals();
@@ -265,6 +277,53 @@ describe('useIFrameBehavior hook', () => {
265277
});
266278
});
267279
});
280+
describe('visibility tracking', () => {
281+
it('sets up visibility tracking after iframe has loaded', () => {
282+
state.mockVals({ ...defaultStateVals, hasLoaded: true });
283+
useIFrameBehavior(props);
284+
285+
const effects = getEffects([true, props.elementId], React);
286+
expect(effects.length).toEqual(2);
287+
effects[0](); // Execute the visibility tracking effect.
288+
289+
expect(global.window.addEventListener).toHaveBeenCalledTimes(2);
290+
expect(global.window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
291+
expect(global.window.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
292+
// Initial visibility update.
293+
expect(postMessage).toHaveBeenCalledWith(
294+
{
295+
type: 'unit.visibilityStatus',
296+
data: {
297+
topPosition: 100,
298+
viewportHeight: 800,
299+
},
300+
},
301+
config.LMS_BASE_URL,
302+
);
303+
});
304+
it('does not set up visibility tracking before iframe has loaded', () => {
305+
state.mockVals({ ...defaultStateVals, hasLoaded: false });
306+
useIFrameBehavior(props);
307+
308+
const effects = getEffects([false, props.elementId], React);
309+
expect(effects).toBeNull();
310+
311+
expect(global.window.addEventListener).not.toHaveBeenCalled();
312+
expect(postMessage).not.toHaveBeenCalled();
313+
});
314+
it('cleans up event listeners on unmount', () => {
315+
state.mockVals({ ...defaultStateVals, hasLoaded: true });
316+
useIFrameBehavior(props);
317+
318+
const effects = getEffects([true, props.elementId], React);
319+
const cleanup = effects[0](); // Execute the effect and get the cleanup function.
320+
cleanup(); // Call the cleanup function.
321+
322+
expect(global.window.removeEventListener).toHaveBeenCalledTimes(2);
323+
expect(global.window.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
324+
expect(global.window.removeEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
325+
});
326+
});
268327
});
269328
describe('output', () => {
270329
describe('handleIFrameLoad', () => {

0 commit comments

Comments
 (0)