diff --git a/public/rex/activity.json b/public/rex/activity.json index 2780151cca..30469c269d 100644 --- a/public/rex/activity.json +++ b/public/rex/activity.json @@ -2,19 +2,9 @@ "activities":{ "reading":{ "icon":{"url":"/rex/icons/reading.svg"}, - "launch":{"url":"/apps/rex/assigned/:id","navigation":true,"xapi":false}, + "launch":{"url":"/apps/rex/assigned/:id","navigation":true,"xapi":false,"textSize":true}, "objectIRI":"https://openstax.org/orn/reading/:id", "objectTypeIRI":"http://id.tincanapi.com/activitytype/school-assignment" - }, - "youtube_video":{ - "launch":{"url":"https://www.youtube.com/embed/:id","navigation":false,"xapi":false}, - "objectIRI":"https://openstax.org/orn/video-youtube/:id", - "objectTypeIRI":"http://adlnet.gov/expapi/activities/media" - }, - "pressbooks_interactive": { - "launch":{"url":"https://ecampusontario.pressbooks.pub/apinteractives/wp-admin/admin-ajax.php","navigation":false,"xapi":false}, - "objectIRI":"https://openstax.org/orn/interactives-pressbooks/:id", - "objectTypeIRI":"http://adlnet.gov/expapi/activities/media" } } } diff --git a/src/app/content/components/AssignedTopBar.spec.tsx b/src/app/content/components/AssignedTopBar.spec.tsx index 2acfae90fc..8e9ce8ff9c 100644 --- a/src/app/content/components/AssignedTopBar.spec.tsx +++ b/src/app/content/components/AssignedTopBar.spec.tsx @@ -11,8 +11,17 @@ import { MiddlewareAPI, Store } from '../../types'; import { setTextSize } from '../actions'; import { LinkedArchiveTreeSection } from '../types'; import { AssignedTopBar } from './AssignedTopBar'; +import { assertDocument, assertWindow } from '../../utils/browser-assertions'; + +jest.mock('react', () => { + const react = (jest as any).requireActual('react'); + return { ...react, useEffect: react.useLayoutEffect }; +}); describe('AssignedTopBar', () => { + const windowBack = assertWindow(); + const addEventListenerBackup = windowBack.addEventListener; + let addEventListener: jest.SpyInstance; let store: Store; let services: ReturnType & MiddlewareAPI; @@ -24,9 +33,15 @@ describe('AssignedTopBar', () => { getState: store.getState, }; + addEventListener = jest.spyOn(windowBack, 'addEventListener'); + store.dispatch(setTextSize(0)); }); + afterEach(() => { + windowBack.addEventListener = addEventListenerBackup; + }); + it('renders', async() => { const dispatch = jest.fn(); jest.spyOn(redux, 'useDispatch').mockImplementation(() => dispatch); @@ -57,4 +72,98 @@ describe('AssignedTopBar', () => { tree.unmount(); }); + + it('renders null with textSize integration', async() => { + services.launchToken = {tokenString: '', tokenData: {textSize: 2}}; + + const section = { title: '1.1 Section Title' } as LinkedArchiveTreeSection; + const intl = await createIntl('en'); + + const tree = renderer.create( + + + + + + + + ); + + expect(tree.toJSON()).toMatchSnapshot(); + + tree.unmount(); + }); + + it('handles font size from postmessage', async() => { + const dispatch = jest.fn(); + Object.defineProperty(assertWindow(), 'parent', {value: {...assertWindow()}}); + Object.defineProperty(assertDocument(), 'referrer', {value: assertWindow().location.toString()}); + services.launchToken = {tokenString: '', tokenData: {textSize: 2}}; + jest.spyOn(redux, 'useDispatch').mockImplementation(() => dispatch); + + const section = { title: '1.1 Section Title' } as LinkedArchiveTreeSection; + const intl = await createIntl('en'); + + const tree = renderer.create( + + + + + + + + ); + + renderer.act(() => { + addEventListener.mock.calls.forEach(([event, handler]) => { + if (event === 'message') { + handler({ + data: {type: 'TextSizeUpdate', value: 2}, + origin: assertWindow().location.origin, + }); + } + }); + }); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith(setTextSize(2)); + + tree.unmount(); + }); + + it('ignores postmessages from weird origins', async() => { + const dispatch = jest.fn(); + Object.defineProperty(assertWindow(), 'parent', {value: {...assertWindow()}}); + Object.defineProperty(assertDocument(), 'referrer', {value: assertWindow().location.toString()}); + services.launchToken = {tokenString: '', tokenData: {textSize: 2}}; + jest.spyOn(redux, 'useDispatch').mockImplementation(() => dispatch); + + const section = { title: '1.1 Section Title' } as LinkedArchiveTreeSection; + const intl = await createIntl('en'); + + const tree = renderer.create( + + + + + + + + ); + + renderer.act(() => { + addEventListener.mock.calls.forEach(([event, handler]) => { + if (event === 'message') { + handler({ + data: {type: 'TextSizeUpdate', value: 2}, + origin: 'https://google.com', + }); + } + }); + }); + + expect(dispatch).not.toHaveBeenCalled(); + + tree.unmount(); + }); }); diff --git a/src/app/content/components/AssignedTopBar.tsx b/src/app/content/components/AssignedTopBar.tsx index 21dc769359..8035aa7db5 100644 --- a/src/app/content/components/AssignedTopBar.tsx +++ b/src/app/content/components/AssignedTopBar.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; import styled, { css } from 'styled-components'; +import { MessageEvent } from '@openstax/types/lib.dom'; import theme from '../../theme'; import { setTextSize } from '../actions'; import * as selectContent from '../selectors'; @@ -8,6 +9,8 @@ import { LinkedArchiveTreeSection } from '../types'; import { shadow, TopBarWrapper } from './Topbar/styled'; import { TextResizer } from './Topbar/TextResizer'; import { topbarDesktopHeight, topbarMobileHeight } from './constants'; +import { TextResizerValue, textResizerValues } from '../constants'; +import { useLaunchToken } from '../launchToken'; // tslint:disable-next-line:variable-name const StyledTopBarWrapper = styled(TopBarWrapper)` @@ -39,21 +42,58 @@ const StyledSectionTitle = styled.h2` `)} `; +const useTextResizeIntegration = (handleChange: (value: TextResizerValue) => void) => { + const launchToken = useLaunchToken(); + + React.useEffect(() => { + if (typeof window === 'undefined' || !window.parent || window.parent === window) { + return; + } + + const win = window; + const referrer = new URL(win.document.referrer); + + const handler = (event: MessageEvent) => { + if ( + event.data.type === 'TextSizeUpdate' && + event.origin === referrer.origin && + textResizerValues.includes(event.data.value as TextResizerValue) + ) { + handleChange(event.data.value); + } + }; + + win.addEventListener('message', handler); + return () => win.removeEventListener('message', handler); + }, [handleChange]); + + return typeof launchToken.textSize === 'number'; +}; + // tslint:disable-next-line:variable-name export const AssignedTopBar = (props: { section: LinkedArchiveTreeSection; }) => { - const bookTheme = useSelector(selectContent.bookTheme); const textSize = useSelector(selectContent.textSize); const dispatch = useDispatch(); + const handleValueChange = React.useCallback((value: TextResizerValue) => { + dispatch(setTextSize(value)); + }, [dispatch]); + + const hasIntegration = useTextResizeIntegration(handleValueChange); + + if (hasIntegration) { + return null; + } + return ( dispatch(setTextSize(value))} + setTextSize={handleValueChange} textSize={textSize} data-testid='text-resizer' mobileToolbarOpen={false} diff --git a/src/app/content/components/__snapshots__/AssignedTopBar.spec.tsx.snap b/src/app/content/components/__snapshots__/AssignedTopBar.spec.tsx.snap index d0540bcba3..bc624e44c4 100644 --- a/src/app/content/components/__snapshots__/AssignedTopBar.spec.tsx.snap +++ b/src/app/content/components/__snapshots__/AssignedTopBar.spec.tsx.snap @@ -174,3 +174,5 @@ exports[`AssignedTopBar renders 1`] = ` `; + +exports[`AssignedTopBar renders null with textSize integration 1`] = `null`; diff --git a/src/app/content/hooks/storeTextSize.spec.ts b/src/app/content/hooks/storeTextSize.spec.ts index 4c81071451..70ce5e1784 100644 --- a/src/app/content/hooks/storeTextSize.spec.ts +++ b/src/app/content/hooks/storeTextSize.spec.ts @@ -80,6 +80,13 @@ describe('loadStoredTextSize', () => { expect(storeDispatch).toHaveBeenCalledWith(setTextSize(2)); }); + it('loads the value from launchToken', async() => { + helpers.launchToken = {tokenString: '', tokenData: {textSize: 2}}; + await hook(); + expect(assertWindow().localStorage.getItem).toHaveBeenCalled(); + expect(storeDispatch).toHaveBeenCalledWith(setTextSize(2)); + }); + it('noops if textSize is already set', async() => { mockLocalStorage(2); store.dispatch(setTextSize(3)); @@ -97,7 +104,7 @@ describe('loadStoredTextSize', () => { global.window = window; }); - Object.entries(invalidValues).map((entry) => { + Object.entries(invalidValues).forEach((entry) => { it(`uses the default if loaded value is ${entry[0]}`, async() => { mockLocalStorage(entry[1]); await hook(); diff --git a/src/app/content/hooks/storeTextSize.ts b/src/app/content/hooks/storeTextSize.ts index a3e7a97cf1..91fc1f9b94 100644 --- a/src/app/content/hooks/storeTextSize.ts +++ b/src/app/content/hooks/storeTextSize.ts @@ -4,12 +4,12 @@ import { textResizerDefaultValue, textResizerStorageKey, TextResizerValue, - textResizerValues + textResizerValues, } from '../constants'; import { textSize } from '../selectors'; export const loadStoredTextSize = (services: MiddlewareAPI & AppServices) => async() => { - const { getState, dispatch } = services; + const { getState, dispatch, launchToken } = services; const state = getState(); let storedTextSize; let value: TextResizerValue = textResizerDefaultValue; @@ -18,19 +18,20 @@ export const loadStoredTextSize = (services: MiddlewareAPI & AppServices) => asy return; } - if (typeof window !== 'undefined') { + if (typeof launchToken?.tokenData.textSize === 'number') { + storedTextSize = launchToken.tokenData.textSize; + } + + if (storedTextSize === undefined && typeof window !== 'undefined') { try { - storedTextSize = window.localStorage.getItem(textResizerStorageKey); + storedTextSize = parseInt(window.localStorage.getItem(textResizerStorageKey) ?? '', 10); } catch { // They have blocked access to localStorage; ignore it } } - if (storedTextSize) { - const parsedValue = parseInt(storedTextSize, 10) as TextResizerValue; - if (!isNaN(parsedValue) && textResizerValues.includes(parsedValue)) { - value = parsedValue; - } + if (storedTextSize && textResizerValues.includes(storedTextSize as TextResizerValue)) { + value = storedTextSize as TextResizerValue; } dispatch(setTextSize(value)); diff --git a/src/app/content/launchToken.spec.ts b/src/app/content/launchToken.spec.ts new file mode 100644 index 0000000000..3d66445533 --- /dev/null +++ b/src/app/content/launchToken.spec.ts @@ -0,0 +1,101 @@ +import * as jwt from 'jsonwebtoken'; +import { pullToken, decodeToken } from './launchToken'; +import { assertWindow } from '../utils/browser-assertions'; + +describe('launchToken', () => { + + it('decodes token', () => { + const token = jwt.sign({ + sub: JSON.stringify({stuff: 'things'}), + }, 'secret'); + + const replaceStateSpy = jest.fn(); + Object.defineProperty(assertWindow().history, 'replaceState', { + writable: true, + value: replaceStateSpy, + }); + + Object.defineProperty(assertWindow(), 'location', { + writable: true, + value: { + ...assertWindow().location, + search: `t=${token}&other=thing`, + }, + }); + + const result = pullToken(assertWindow()); + + expect(result?.tokenString).toEqual(token); + expect(result?.tokenData).toEqual({stuff: 'things'}); + + expect(replaceStateSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), + assertWindow().location.pathname + '?other=thing' + ); + }); + + it('works without token', () => { + const replaceStateSpy = jest.fn(); + Object.defineProperty(assertWindow().history, 'replaceState', { + value: replaceStateSpy, + }); + + Object.defineProperty(assertWindow(), 'location', { + writable: true, + value: { + ...assertWindow().location, + search: `other=thing`, + }, + }); + + const result = pullToken(assertWindow()); + + expect(result).toBe(undefined); + + expect(replaceStateSpy).not.toHaveBeenCalled(); + }); + + it('works with invalid token', () => { + const token = jwt.sign({}, 'secret'); + + const replaceStateSpy = jest.fn(); + Object.defineProperty(assertWindow().history, 'replaceState', { + writable: true, + value: replaceStateSpy, + }); + + Object.defineProperty(assertWindow(), 'location', { + writable: true, + value: { + ...assertWindow().location, + search: `t=${token}`, + }, + }); + + const result = pullToken(assertWindow()); + + expect(result).toBe(undefined); + + expect(replaceStateSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), + assertWindow().location.pathname + ); + }); + + describe('outside browser', () => { + const windowBackup = window; + const documentBackup = document; + + beforeEach(() => { + delete (global as any).window; + delete (global as any).document; + }); + + afterEach(() => { + (global as any).window = windowBackup; + (global as any).document = documentBackup; + }); + + it('works', () => { + expect(() => decodeToken('asdf')).not.toThrow(); + }); + }); +}); diff --git a/src/app/content/launchToken.ts b/src/app/content/launchToken.ts new file mode 100644 index 0000000000..c1d4dc24a6 --- /dev/null +++ b/src/app/content/launchToken.ts @@ -0,0 +1,44 @@ +import { Window } from '@openstax/types/lib.dom'; +import { useServices } from '../context/Services'; + +export const decodeToken = (launchToken: string | undefined) => { + if (!launchToken || typeof window === 'undefined') return undefined; + + // https://stackoverflow.com/a/38552302/14809536 + const base64Url = launchToken.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent(window.atob(base64).split('').map((c) => { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join('')); + + const token = JSON.parse(jsonPayload); + return 'sub' in token ? JSON.parse(token.sub) : undefined; +}; + +export const pullToken = (window: Window) => { + const searchParams = new URLSearchParams(window.location.search); + + const launchToken = searchParams.get('t'); + + if (!launchToken) { + return undefined; + } + + searchParams.delete('t'); + const search = searchParams.toString(); + window.history.replaceState({}, window.document.title, window.location.pathname + (search ? `?${search}` : '')); + + const tokenData = decodeToken(launchToken); + + if (!tokenData) { + return undefined; + } + + return {tokenString: launchToken, tokenData}; +}; + + +export const useLaunchToken = () => { + const {launchToken} = useServices(); + return launchToken?.tokenData ?? {}; +}; diff --git a/src/app/types.ts b/src/app/types.ts index 786c57b4c3..2d712e1223 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -30,6 +30,7 @@ import { State as headState } from './head/types'; import { State as navigationState } from './navigation/types'; import { State as notificationState } from './notifications/types'; import { RouterService } from './navigation/routerService'; +import type { JsonCompatibleStruct } from '@openstax/ts-utils/routing'; export interface AppState { content: contentState; @@ -51,6 +52,7 @@ export interface AppServices { highlightClient: ReturnType; history: History; intl: {current: IntlShape | null}; + launchToken?: {tokenString: string, tokenData: JsonCompatibleStruct}; osWebLoader: ReturnType; practiceQuestionsLoader: ReturnType; prerenderedContent?: string; diff --git a/src/index.tsx b/src/index.tsx index f44db593f7..ef95b9176c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -28,6 +28,7 @@ import pollUpdates from './helpers/pollUpdates'; import Sentry from './helpers/Sentry'; import './index.css'; import * as serviceWorker from './serviceWorker'; +import { pullToken } from './app/content/launchToken'; const window = assertWindow('Browser entrypoint must be used in the browser'); const document = window.document; @@ -58,6 +59,7 @@ const app = createApp({ archiveLoader: createArchiveLoader(), bookConfigLoader: createBookConfigLoader(), buyPrintConfigLoader: createBuyPrintConfigLoader(buyPrintConfigUrl), + launchToken: pullToken(window), config, highlightClient: createHighlightClient(highlightsUrl, userLoader.getAuthorizedFetchConfig), osWebLoader: createOSWebLoader(osWebUrl), diff --git a/src/test/createTestServices.ts b/src/test/createTestServices.ts index 7a02571c43..400f0360c8 100644 --- a/src/test/createTestServices.ts +++ b/src/test/createTestServices.ts @@ -1,6 +1,7 @@ import { SearchApi } from '@openstax/open-search-client'; import { createMemoryHistory } from 'history'; import config from '../config'; +import type { JsonCompatibleStruct } from '@openstax/ts-utils/routing'; import { BuyPrintResponse } from '../gateways/createBuyPrintConfigLoader'; import createHighlightClient from '../gateways/createHighlightClient'; import createPracticeQuestionsLoader from '../gateways/createPracticeQuestionsLoader'; @@ -34,6 +35,7 @@ export const createTestServices = (args?: {prefetchResolutions: boolean}) => ({ practiceQuestionsLoader: createPracticeQuestionsLoader(), promiseCollector: new PromiseCollector(), searchClient: new SearchApi(), + launchToken: undefined as undefined | {tokenString: string, tokenData: JsonCompatibleStruct}, userLoader: mockUserLoader(), imageCDNUtils: createImageCDNUtils(args), router: createRouterService([]),