@@ -9,7 +9,7 @@ import React, {
99} from "react" ;
1010import { useGetBookDetail } from "../connection/LibraryQueryHooks" ;
1111import { Book } from "../model/Book" ;
12- import { getUrlOfHtmlOfDigitalVersion } from "./BookDetail/ArtifactHelper " ;
12+ import { getUrlOfHtmlOfDigitalVersion } from "../model/BookUrlUtils " ;
1313import { useHistory , useLocation } from "react-router-dom" ;
1414import { useTrack } from "../analytics/Analytics" ;
1515import { getBookAnalyticsInfo } from "../analytics/BookAnalyticsInfo" ;
@@ -27,13 +27,147 @@ import { useMediaQuery } from "@material-ui/core";
2727import { OSFeaturesContext } from "./OSFeaturesContext" ;
2828import { IReadBookPageProps } from "./ReadBookPageCodeSplit" ;
2929
30+ // To make links in books able to open other books, we need to intercept requests from the Bloom Player iframe to our
31+ // /book/... URLs. To do that, we register a service worker that intercepts those requests
32+ // instead of trying to load them as pages. This file is the service worker that does that interception.
33+ // It is registered in ReadBookPage.tsx and bundled into our build by vite.config.ts.
34+ const bookNavigationInterceptorServiceWorkerUrl =
35+ "/book-navigation-interceptor-sw.js" ;
36+
37+ // Let's not risk totally blocking the page if something goes wrong
38+ // with the service worker, since most books won't have links anyway
39+ const serviceWorkerRegistrationTimeoutMs = 5000 ;
40+
41+ function waitForServiceWorkerActivation (
42+ worker : ServiceWorker ,
43+ timeoutMs = serviceWorkerRegistrationTimeoutMs
44+ ) {
45+ if ( worker . state === "activated" ) {
46+ return Promise . resolve ( ) ;
47+ }
48+
49+ return new Promise < void > ( ( resolve , reject ) => {
50+ const timeoutId = window . setTimeout ( ( ) => {
51+ worker . removeEventListener ( "statechange" , onStateChange ) ;
52+ console . error (
53+ "Book navigation interceptor service worker activation timed out" ,
54+ { state : worker . state , timeoutMs }
55+ ) ;
56+ reject ( new Error ( "Service worker activation timed out" ) ) ;
57+ } , timeoutMs ) ;
58+
59+ const onStateChange = ( ) => {
60+ if ( worker . state === "activated" ) {
61+ window . clearTimeout ( timeoutId ) ;
62+ worker . removeEventListener ( "statechange" , onStateChange ) ;
63+ resolve ( ) ;
64+ } else if ( worker . state === "redundant" ) {
65+ window . clearTimeout ( timeoutId ) ;
66+ worker . removeEventListener ( "statechange" , onStateChange ) ;
67+ console . error (
68+ "Book navigation interceptor service worker became redundant"
69+ ) ;
70+ reject ( new Error ( "Service worker became redundant" ) ) ;
71+ }
72+ } ;
73+
74+ worker . addEventListener ( "statechange" , onStateChange ) ;
75+ } ) ;
76+ }
77+
78+ function waitForServiceWorkerReady (
79+ timeoutMs = serviceWorkerRegistrationTimeoutMs
80+ ) {
81+ return new Promise < ServiceWorkerRegistration > ( ( resolve , reject ) => {
82+ const timeoutId = window . setTimeout ( ( ) => {
83+ console . error (
84+ "Timed out waiting for book navigation interceptor service worker readiness" ,
85+ { timeoutMs }
86+ ) ;
87+ reject ( new Error ( "Timed out waiting for service worker readiness" ) ) ;
88+ } , timeoutMs ) ;
89+
90+ navigator . serviceWorker . ready
91+ . then ( ( registration ) => {
92+ window . clearTimeout ( timeoutId ) ;
93+ resolve ( registration ) ;
94+ } )
95+ . catch ( ( error ) => {
96+ window . clearTimeout ( timeoutId ) ;
97+ console . error (
98+ "Failed while waiting for book navigation interceptor service worker readiness" ,
99+ error
100+ ) ;
101+ reject ( error ) ;
102+ } ) ;
103+ } ) ;
104+ }
105+
106+ function waitForServiceWorkerControl (
107+ timeoutMs = serviceWorkerRegistrationTimeoutMs
108+ ) {
109+ if ( navigator . serviceWorker . controller ) {
110+ return Promise . resolve ( ) ;
111+ }
112+
113+ return new Promise < void > ( ( resolve ) => {
114+ const timeoutId = window . setTimeout ( ( ) => {
115+ navigator . serviceWorker . removeEventListener (
116+ "controllerchange" ,
117+ onControllerChange
118+ ) ;
119+ console . error (
120+ "Book navigation interceptor service worker did not take control before timeout" ,
121+ { timeoutMs }
122+ ) ;
123+ resolve ( ) ;
124+ } , timeoutMs ) ;
125+
126+ const onControllerChange = ( ) => {
127+ window . clearTimeout ( timeoutId ) ;
128+ navigator . serviceWorker . removeEventListener (
129+ "controllerchange" ,
130+ onControllerChange
131+ ) ;
132+ resolve ( ) ;
133+ } ;
134+
135+ navigator . serviceWorker . addEventListener (
136+ "controllerchange" ,
137+ onControllerChange
138+ ) ;
139+ } ) ;
140+ }
141+
142+ async function ensureBookNavigationInterceptorRegistered ( ) {
143+ if ( ! ( "serviceWorker" in navigator ) ) {
144+ return ;
145+ }
146+
147+ const registration = await navigator . serviceWorker . register (
148+ bookNavigationInterceptorServiceWorkerUrl ,
149+ { scope : "/" }
150+ ) ;
151+ const worker =
152+ registration . installing || registration . waiting || registration . active ;
153+ if ( worker ) {
154+ await waitForServiceWorkerActivation ( worker ) ;
155+ }
156+
157+ await waitForServiceWorkerReady ( ) ;
158+ await waitForServiceWorkerControl ( ) ;
159+ }
160+
30161const ReadBookPage : React . FunctionComponent < IReadBookPageProps > = ( props ) => {
31162 const id = props . id ;
32163 const history = useHistory ( ) ;
33164 const location = useLocation ( ) ;
34165 const { mobile } = useContext ( OSFeaturesContext ) ;
35166 const widerThanPhone = useMediaQuery ( "(min-width:450px)" ) ; // a bit more than the largest phone width in the chrome debugger (411px)
36167 const higherThanPhone = useMediaQuery ( "(min-height:450px)" ) ;
168+ const [ iframeInterceptionReady , setIframeInterceptionReady ] = useState (
169+ ! ( "serviceWorker" in navigator )
170+ ) ;
37171
38172 // If either dimension is smaller than a phone, we'll guess we are on one
39173 // and go full screen automatically.
@@ -103,6 +237,19 @@ const ReadBookPage: React.FunctionComponent<IReadBookPageProps> = (props) => {
103237 } , [ history ] ) ;
104238 useEffect ( ( ) => startingBook ( ) , [ id ] ) ;
105239
240+ useEffect ( ( ) => {
241+ ensureBookNavigationInterceptorRegistered ( )
242+ . catch ( ( error ) => {
243+ console . error (
244+ "Unable to register book navigation interceptor service worker" ,
245+ error
246+ ) ;
247+ } )
248+ . finally ( ( ) => {
249+ setIframeInterceptionReady ( true ) ;
250+ } ) ;
251+ } , [ ] ) ;
252+
106253 // We don't use rotateParams here, because one caller wants to call it
107254 // immediately after calling setRotateParams, when the new values won't be
108255 // available.
@@ -193,7 +340,12 @@ const ReadBookPage: React.FunctionComponent<IReadBookPageProps> = (props) => {
193340 getBookAnalyticsInfo ( book , contextLangTag , "read" ) ,
194341 ! ! book
195342 ) ;
196- const url = book ? getUrlOfHtmlOfDigitalVersion ( book ) : "working" ; // url=working shows a loading icon
343+ const url = book
344+ ? getUrlOfHtmlOfDigitalVersion (
345+ Book . getHarvesterBaseUrl ( book ) ,
346+ "index.htm"
347+ ) || "working"
348+ : "working" ; // url=working shows a loading icon
197349
198350 // use the bloomplayer.htm we copy into our public/ folder, where CRA serves from
199351 // TODO: this isn't working with react-router, but I don't know how RR even gets run inside of this iframe
@@ -304,7 +456,7 @@ const ReadBookPage: React.FunctionComponent<IReadBookPageProps> = (props) => {
304456 // });
305457 return (
306458 < React . Fragment >
307- { url === "working" || (
459+ { url === "working" || ! iframeInterceptionReady || (
308460 < iframe
309461 title = "bloom player"
310462 css = { css `
@@ -339,32 +491,5 @@ function exitFullscreen() {
339491 }
340492}
341493
342- // eslint-disable-next-line @typescript-eslint/no-unused-vars
343- function getHarvesterBaseUrl ( book : Book ) {
344- // typical input url:
345- // https://s3.amazonaws.com/BloomLibraryBooks-Sandbox/ken%40example.com%2faa647178-ed4d-4316-b8bf-0dc94536347d%2fsign+language+test%2f
346- // want:
347- // https://s3.amazonaws.com/bloomharvest-sandbox/ken%40example.com%2faa647178-ed4d-4316-b8bf-0dc94536347d/
348- // We come up with that URL by
349- // (a) changing BloomLibraryBooks{-Sandbox} to bloomharvest{-sandbox}
350- // (b) strip off everything after the next-to-final slash
351- let folderWithoutLastSlash = book . baseUrl ;
352- if ( book . baseUrl . endsWith ( "%2f" ) ) {
353- folderWithoutLastSlash = book . baseUrl . substring (
354- 0 ,
355- book . baseUrl . length - 3
356- ) ;
357- }
358- const index = folderWithoutLastSlash . lastIndexOf ( "%2f" ) ;
359- const pathWithoutBookName = folderWithoutLastSlash . substring ( 0 , index ) ;
360- return (
361- pathWithoutBookName
362- . replace ( "BloomLibraryBooks-Sandbox" , "bloomharvest-sandbox" )
363- . replace ( "BloomLibraryBooks" , "bloomharvest" ) + "/"
364- ) ;
365- // Using slash rather than %2f at the end helps us download as the filename we want.
366- // Otherwise, the filename can be something like ken@example.com_007b3c03-52b7-4689-80bd-06fd4b6f9f28_Fox+and+Frog.bloompub
367- }
368-
369494// though we normally don't like to export defaults, this is required for react.lazy (code splitting)
370495export default ReadBookPage ;
0 commit comments