@internetarchive/ia-book-actions 1.4.4-alpha8 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,7 +20,7 @@ import { LoanTokenPoller } from './core/services/loan-token-poller.js';
20
20
  import { LoanRenewHelper } from './core/services/loan-renew-helper.js';
21
21
  import log from './core/services/log.js';
22
22
  import { URLHelper } from './core/config/url-helper.js';
23
- import { infoIcon } from './assets/data/info.js';
23
+ import { infoIcon } from './assets/data/info-icon.js';
24
24
 
25
25
  export const events = {
26
26
  browseExpired: 'IABookReader:BrowsingHasExpired',
@@ -55,12 +55,15 @@ export default class IABookActions extends LitElement {
55
55
  type: Function,
56
56
  attribute: false,
57
57
  },
58
+ reloadPageImages: {
59
+ type: Function,
60
+ attribute: false,
61
+ },
58
62
  barType: { type: String },
59
63
  sharedObserver: { attribute: false },
60
64
  disableActionGroup: { type: Boolean },
61
65
  modal: { Object },
62
66
  tokenDelay: { type: Number },
63
- tokenPollerRenewalDelay: { type: Number },
64
67
  timerExecutionSeconds: { type: Number },
65
68
  localCache: { type: Object },
66
69
  loanRenewTimeConfig: { type: Object },
@@ -78,6 +81,7 @@ export default class IABookActions extends LitElement {
78
81
  this.width = 0;
79
82
  this.bwbPurchaseUrl = '';
80
83
  this.lendingBarPostInit = () => {};
84
+ this.reloadPageImages = () => {};
81
85
  this.barType = 'action'; // 'title'|'action'
82
86
  this.sharedObserver = undefined;
83
87
  this.disableActionGroup = false;
@@ -98,34 +102,26 @@ export default class IABookActions extends LitElement {
98
102
  this.loanRenewInProgress = false;
99
103
 
100
104
  /**
101
- * True while a renewal in flight is recovering from a loan that
102
- * genuinely lapsed (set by autoRenewExpiredLoan), as opposed to a
103
- * routine pre-expiry top-up. Only the recovery case needs BookReader
104
- * re-initialized once the renewal is confirmed — see
105
+ * True while recovering from a loan that genuinely lapsed (set by
106
+ * autoRenewExpiredLoan), as opposed to a routine pre-expiry top-up.
107
+ * Only the recovery case needs BookReader re-initialized — see
105
108
  * handleLoanAutoRenewed.
106
109
  */
107
110
  this.recoveringFromLoanExpiry = false;
108
111
 
109
112
  /**
110
- * Buffer (ms) before the first create_token call that follows a
111
- * confirmed recovery renewal — Lending::do_renew() (backend) deletes
112
- * the old loan and writes a brand-new one to the datanode storage, so
113
- * create_token's lookup can otherwise race that write's propagation.
114
- * Exposed as a property (rather than a hardcoded constant) so it's
115
- * configurable per-deployment and overridable to 0 in tests.
113
+ * True right after a confirmed renewal (any kind), until the token
114
+ * poller restarts. renew_loan's own response already carries a valid
115
+ * access token, so that restart should skip its normal immediate
116
+ * create_token call — see handleLoanAutoRenewed/
117
+ * setupLendingToolbarActions.
116
118
  */
117
- this.tokenPollerRenewalDelay = 2000;
119
+ this.skipNextInitialTokenCall = false;
118
120
 
119
121
  this.warningModalOpen = false;
120
122
 
121
- /**
122
- * Once the patron has seen & dismissed the informational warning modal,
123
- * don't nag them again with the same modal every timer tick — only a
124
- * real renewal (via interacting with the book) clears this, giving the
125
- * next pre-expiry window its own single warning.
126
- * @see loanRenewAttempt
127
- * @see handleLoanAutoRenewed
128
- */
123
+ /** Once dismissed, don't re-show the warning modal every tick — only
124
+ * a real renewal (handleLoanAutoRenewed) re-arms it. */
129
125
  this.warningModalDismissed = false;
130
126
 
131
127
  /**
@@ -159,9 +155,9 @@ export default class IABookActions extends LitElement {
159
155
  }
160
156
 
161
157
  disconnectedCallback() {
162
- // clear all intervals for lending system
158
+ super.disconnectedCallback();
163
159
  window?.IALendingIntervals?.clearAll();
164
-
160
+ this.tokenPoller?.disconnectedCallback();
165
161
  this.sentryCaptureMsg(sentryLogs.disconnectedCallback);
166
162
  this.disconnectResizeObserver();
167
163
  }
@@ -200,12 +196,8 @@ export default class IABookActions extends LitElement {
200
196
  }
201
197
 
202
198
  if (changed.has('loanRenewResult') && this.loanRenewResult.renewNow) {
203
- // Any renewal — not just the auto-return recovery path — pauses the
204
- // countdown for the renew_loan round-trip. Flag it here, centrally,
205
- // so every path (interaction during active reading, periodic
206
- // checker, auto-return recovery) gets the same "renewing" signal
207
- // instead of just looking frozen until handleLoanAutoRenewed() clears
208
- // this and restarts the timer.
199
+ // Pause the countdown for the renew_loan round-trip, for every
200
+ // renewal path — cleared by handleLoanAutoRenewed().
209
201
  this.loanRenewInProgress = true;
210
202
  window.IALendingIntervals.clearAll();
211
203
  }
@@ -243,17 +235,53 @@ export default class IABookActions extends LitElement {
243
235
  }
244
236
  /** End SharedObserver resize handler */
245
237
 
238
+ /**
239
+ * Recompute the action bar (title, buttons, colors, borrowType) from the
240
+ * current lendingStatus.
241
+ *
242
+ * @returns {boolean} false when there are no actions to apply, in which
243
+ * case callers should bail out rather than continue with stale state.
244
+ */
245
+ applyLendingActions() {
246
+ this.lendingOptions = new GetLendingActions(
247
+ this.userid,
248
+ this.identifier,
249
+ this.lendingStatus,
250
+ this.bwbPurchaseUrl
251
+ );
252
+ const actions = this.lendingOptions.getCurrentLendingActions();
253
+
254
+ if (!actions) return false;
255
+
256
+ this.primaryTitle = actions.primaryTitle;
257
+ this.primaryActions = actions.primaryActions?.filter(action => {
258
+ return action != null;
259
+ });
260
+ this.primaryColor = actions.primaryColor;
261
+ this.secondaryActions = actions.secondaryActions?.filter(action => {
262
+ return action != null;
263
+ });
264
+
265
+ this.borrowType = actions.borrowType ? actions.borrowType : null;
266
+
267
+ return true;
268
+ }
269
+
246
270
  async setupLendingToolbarActions() {
247
271
  const hasExpired =
248
272
  'browsingExpired' in this.lendingStatus &&
249
273
  this.lendingStatus?.browsingExpired;
250
274
  if (hasExpired) {
251
- // Intentionally do NOT recompute primaryActions/primaryColor/borrowType
252
- // here — per the ticket, auto-return must not visibly change anything.
253
- // The action bar stays exactly as it looked while reading; only a
254
- // successful silent renewal or the "unavailable" error modal are
255
- // allowed to change what's shown next.
256
- log('[IABookActions] browsing expired — leaving action bar untouched');
275
+ // Auto-return must not visibly change the bar — unless nothing's
276
+ // rendered yet (fresh load of an already-expired loan).
277
+ if (this.primaryActions?.length) {
278
+ log('[IABookActions] browsing expired — leaving action bar untouched');
279
+ } else {
280
+ log(
281
+ '[IABookActions] browsing expired on first render — populating action bar'
282
+ );
283
+ this.applyLendingActions();
284
+ }
257
285
 
258
286
  if (!this.tokenPoller) {
259
287
  this.sentryCaptureMsg(sentryLogs.bookWasExpired);
@@ -273,32 +301,20 @@ export default class IABookActions extends LitElement {
273
301
  return;
274
302
  }
275
303
 
276
- this.lendingOptions = new GetLendingActions(
277
- this.userid,
278
- this.identifier,
279
- this.lendingStatus,
280
- this.bwbPurchaseUrl
281
- );
282
- const actions = this.lendingOptions.getCurrentLendingActions();
283
-
284
- if (!actions) return;
304
+ if (!this.applyLendingActions()) return;
285
305
 
286
- this.primaryTitle = actions.primaryTitle;
287
- this.primaryActions = actions.primaryActions?.filter(action => {
288
- return action != null;
289
- });
290
- this.primaryColor = actions.primaryColor;
291
- this.secondaryActions = actions.secondaryActions?.filter(action => {
292
- return action != null;
293
- });
294
-
295
- this.borrowType = actions.borrowType ? actions.borrowType : null;
306
+ // primaryActions/borrowType/etc are plain fields, not reactive
307
+ // properties, so nothing pushes them to the rendered template (and
308
+ // from there to <collapsible-action-group>) on its own. Do that now,
309
+ // unconditionally — the early returns below (not borrowed, title bar)
310
+ // must not skip it, or the action bar visibly goes stale (e.g. a
311
+ // create_token failure resetting to Borrow never actually shows it).
312
+ this.requestUpdate();
296
313
 
297
- if (this.borrowType === 'browsed') {
298
- // start timer for loan-renew
314
+ // Don't (re)start the countdown mid-renewal — secondsLeftOnLoan could
315
+ // still be stale until handleLoanAutoRenewed() confirms it.
316
+ if (this.borrowType === 'browsed' && !this.loanRenewInProgress) {
299
317
  await this.startTimerCountdown();
300
-
301
- // start timer for browsed.
302
318
  await this.startBrowseTimer();
303
319
  }
304
320
 
@@ -308,40 +324,35 @@ export default class IABookActions extends LitElement {
308
324
  return;
309
325
  }
310
326
 
311
- /**
312
- * tokenPoller determines if user has loan token for this book
313
- * - if book is going to renew, need to wait until renew is completed:
314
- * loanRenewInProgress is set before renew_loan is even dispatched
315
- * (including the optimistic pre-confirmation window) and only
316
- * clears once the renewal is confirmed, so starting the poller here
317
- * mid-renewal would call create_token against the loan record
318
- * before it's actually renewed.
319
- */
327
+ // Wait until any in-flight renewal is confirmed before (re)starting the
328
+ // poller, so create_token isn't called against a not-yet-renewed loan.
320
329
  setTimeout(() => {
321
330
  if (
322
331
  !hasExpired &&
323
332
  !this.loanRenewInProgress &&
324
333
  !window.IALendingIntervals.tokenPoller
325
334
  ) {
326
- // Lending::do_renew() (backend) doesn't extend the existing loan —
327
- // it DELETES it and writes a brand-new loan record to the datanode
328
- // storage. create_token's lookup for that record can race the
329
- // write's propagation and come back empty ("you do not currently
330
- // have this book borrowed"), even though the renewal that was just
331
- // confirmed genuinely succeeded moments earlier. Give the write a
332
- // moment to settle before the first create_token call that follows
333
- // a real recovery — same underlying datanode-propagation concern
334
- // the existing 50-second buffer in loanRenewAttempt() guards
335
- // against for the routine renewal path.
336
- const tokenPollerDelay = this.recoveringFromLoanExpiry
337
- ? this.tokenPollerRenewalDelay
338
- : 0;
335
+ // Any confirmed renewal's response already minted a valid access
336
+ // token (see handleLoanAutoRenewed) — the poller only needs to
337
+ // start its recurring check, not fire an immediate confirming
338
+ // create_token call too.
339
+ const skipInitialCall = this.skipNextInitialTokenCall;
340
+ this.skipNextInitialTokenCall = false;
339
341
  this.recoveringFromLoanExpiry = false;
340
- setTimeout(() => this.startLoanTokenPoller(), tokenPollerDelay);
342
+ this.startLoanTokenPoller(skipInitialCall);
341
343
  }
342
344
  }, 100);
345
+ }
343
346
 
344
- this.requestUpdate();
347
+ /**
348
+ * Is this BookReader's own init-time jump to the last-read page, rather
349
+ * than a real patron interaction (which fires the identical event)? Only
350
+ * an explicit `false` counts; a missing flag must be treated as real.
351
+ * @param {Event} event - the BookReader:userAction event
352
+ * @returns {boolean}
353
+ */
354
+ isBookReaderInitAction(event) {
355
+ return event?.detail?.props?.init?.initComplete === false;
345
356
  }
346
357
 
347
358
  /**
@@ -354,15 +365,17 @@ export default class IABookActions extends LitElement {
354
365
  /**
355
366
  * dispatched this event from bookreader page changed
356
367
  */
357
- window.addEventListener('BookReader:userAction', () => {
358
- // Capture before autoRenewExpiredLoan() runs — it synchronously flips
359
- // lendingStatus.browsingExpired to false as an optimistic UI update,
360
- // so checking the live property afterward would always see it as
361
- // false and wrongly let autoLoanRenewChecker() run too. That second
362
- // call would then overwrite loanRenewResult.renewNow (set true by
363
- // autoRenewExpiredLoan) back to false, since the loanTime cache entry
364
- // was already deleted by browseHasExpired() — silently killing the
365
- // in-flight renewal and leaving the countdown stuck.
368
+ window.addEventListener('BookReader:userAction', event => {
369
+ if (this.isBookReaderInitAction(event)) {
370
+ log(
371
+ '[IABookActions] BookReader:userAction ignored — fired by BookReader init'
372
+ );
373
+ return;
374
+ }
375
+
376
+ // Capture before autoRenewExpiredLoan() runs — it optimistically
377
+ // flips browsingExpired to false, which would otherwise also let
378
+ // autoLoanRenewChecker() run and clobber the in-flight renewal.
366
379
  const wasExpired = this.lendingStatus.browsingExpired;
367
380
 
368
381
  log('[IABookActions] BookReader:userAction received', {
@@ -370,7 +383,6 @@ export default class IABookActions extends LitElement {
370
383
  browsingExpired: wasExpired,
371
384
  });
372
385
 
373
- // If the loan expired while the tab stayed visible, auto-renew on page turn
374
386
  if (wasExpired) {
375
387
  this.autoRenewExpiredLoan();
376
388
  }
@@ -380,18 +392,18 @@ export default class IABookActions extends LitElement {
380
392
  }
381
393
  });
382
394
 
383
- /**
384
- * auto-renew interval may stale when tab is in background,
385
- * so when user open that tab again,
386
- * the [visibilitychange] event can trigger timer to show relavent messages
387
- */
395
+ // A tab in the background can have its intervals throttled/paused, so
396
+ // re-check status when the patron comes back to it.
388
397
  document.addEventListener('visibilitychange', async () => {
389
398
  if (document.hidden) {
390
399
  log('[IABookActions] visibilitychange: tab backgrounded');
391
400
  return;
392
401
  }
393
402
 
394
- log('[IABookActions] visibilitychange: tab foregrounded', this.borrowType);
403
+ log(
404
+ '[IABookActions] visibilitychange: tab foregrounded',
405
+ this.borrowType
406
+ );
395
407
 
396
408
  try {
397
409
  // Loan already expired while tab was hidden — try to silently renew
@@ -413,9 +425,11 @@ export default class IABookActions extends LitElement {
413
425
  if (secondsLeft >= this.timerExecutionSeconds) {
414
426
  this.loanStatusCheckInterval(Number(secondsLeft));
415
427
  } else {
416
- // Loan expired while user was away — try to silently renew
428
+ // Loan expired while away — silently renew. Only clear
429
+ // intervals; disconnectedCallback() would drop the resize
430
+ // observer for a session that's still continuing.
431
+ window?.IALendingIntervals?.clearAll();
417
432
  this.autoRenewExpiredLoan();
418
- this.disconnectedCallback();
419
433
  }
420
434
  }
421
435
  } catch (error) {
@@ -433,14 +447,8 @@ export default class IABookActions extends LitElement {
433
447
  * @param {Boolean} hasPageChanged
434
448
  */
435
449
  async autoLoanRenewChecker(hasPageChanged = false) {
436
- // Guards against re-entrancy the same way autoRenewExpiredLoan() does.
437
- // BookReader:userAction can fire several times in quick succession
438
- // (e.g. a single scroll gesture), and this method has no debouncing of
439
- // its own — without this guard, each event would spin up its own
440
- // LoanRenewHelper concurrently, and whichever's async localCache reads
441
- // resolve last would clobber this.loanRenewResult, potentially
442
- // re-triggering the whole renew_loan/create_token flow while a
443
- // renewal from an earlier event is already in flight or just landed.
450
+ // Re-entrancy guard against rapid BookReader:userAction events (e.g. a
451
+ // single scroll firing several in quick succession).
444
452
  if (this.loanRenewInProgress) return;
445
453
 
446
454
  this.loanRenewHelper = new LoanRenewHelper(
@@ -455,28 +463,31 @@ export default class IABookActions extends LitElement {
455
463
  }
456
464
 
457
465
  /**
458
- * Attempt to automatically renew a browse loan that has expired.
459
- * Called on visibilitychange (user returns to tab) or on page turn when
460
- * the loan has expired but the book may still be available.
461
- *
462
- * Reuses the same renew_loan path as the automatic loan-renew checker.
463
- * On success: handleLoanAutoRenewed() resets the timer seamlessly.
464
- * On error: handleLendingActionError() shows showLoanUnavailableModal().
466
+ * Silently attempt to renew a browse loan that has expired, on
467
+ * visibilitychange or a page turn. handleLoanAutoRenewed()/
468
+ * handleLendingActionError() handle the outcome either way.
465
469
  */
466
470
  autoRenewExpiredLoan() {
467
- if (this.loanRenewInProgress) return;
471
+ if (this.loanRenewInProgress) {
472
+ log(
473
+ '[IABookActions] autoRenewExpiredLoan: skipped, renewal already in progress',
474
+ { identifier: this.identifier }
475
+ );
476
+ return;
477
+ }
478
+ log('[IABookActions] autoRenewExpiredLoan: starting silent renewal', {
479
+ identifier: this.identifier,
480
+ });
468
481
  this.loanRenewInProgress = true;
469
482
  this.recoveringFromLoanExpiry = true;
470
483
 
471
484
  this.modal?.closeModal();
472
485
 
473
- // Optimistically flip browsingExpired back to false so the action bar
474
- // stays red (patronIsReadingAction) while the renew_loan request is in
475
- // flight, instead of showing the blue "Borrow" state.
486
+ // Optimistically flip browsingExpired so the bar stays in the reading
487
+ // state during the renew_loan round-trip, instead of showing "Borrow".
476
488
  this.lendingStatus = {
477
489
  ...this.lendingStatus,
478
490
  browsingExpired: false,
479
- secondsLeftOnLoan: this.loanRenewTimeConfig.loanTotalTime,
480
491
  };
481
492
 
482
493
  // Defer renewNow until after lendingStatus timer setup completes.
@@ -507,7 +518,6 @@ export default class IABookActions extends LitElement {
507
518
 
508
519
  log('[IABookActions] showWarningModal');
509
520
 
510
- // Capture texts and secondsLeft before resetting loanRenewResult
511
521
  const {
512
522
  texts: warningTexts,
513
523
  secondsLeft: rawSecondsLeft,
@@ -519,7 +529,6 @@ export default class IABookActions extends LitElement {
519
529
  secondsLeft = secondsLeft > 60 ? secondsLeft : 60;
520
530
  }
521
531
 
522
- // clear modal and reset renew state
523
532
  this.modal.customModalContent = nothing;
524
533
  this.modal?.closeModal();
525
534
  this.loanRenewResult = { texts: '', renewNow: false };
@@ -529,19 +538,14 @@ export default class IABookActions extends LitElement {
529
538
  headerColor: '#194880',
530
539
  showCloseButton: false,
531
540
  closeOnBackdropClick: false,
532
- message: html`<span
533
- style="display:inline-flex;align-items:center;flex-wrap:nowrap;gap:4px;"
534
- >
535
- <span>${this.loanRenewHelper?.getMessageTexts(
536
- warningTexts,
537
- secondsLeft
538
- )}</span>
541
+ message: html`<span>
542
+ ${this.loanRenewHelper?.getMessageTexts(warningTexts, secondsLeft)}
539
543
  <a
540
544
  href="https://help.archive.org/help/borrowing-from-the-lending-library"
541
545
  target="_blank"
542
546
  title="Get more info on borrowing from The Lending Library"
543
547
  data-event-click-tracking="BookReader|BrowsableMoreInfo"
544
- style="display:inline-flex;flex-shrink:0;line-height:0;"
548
+ style="display:inline-block;vertical-align:middle;line-height:0;margin-left:4px;"
545
549
  >
546
550
  ${infoIcon}
547
551
  </a>
@@ -559,7 +563,8 @@ export default class IABookActions extends LitElement {
559
563
  >
560
564
  Okay
561
565
  </button>
562
- </div> `;
566
+ </div>
567
+ `;
563
568
 
564
569
  this.modal.setAttribute('aria-live', 'assertive');
565
570
  await this.modal?.showModal({ config, customModalContent });
@@ -576,7 +581,10 @@ export default class IABookActions extends LitElement {
576
581
  * Execute when loan is expired
577
582
  */
578
583
  async browseHasExpired() {
579
- log('[IABookActions] browseHasExpired');
584
+ log('[IABookActions] browseHasExpired', {
585
+ identifier: this.identifier,
586
+ loanRenewInProgress: this.loanRenewInProgress,
587
+ });
580
588
  window?.IALendingIntervals?.clearAll();
581
589
 
582
590
  const currStatus = {
@@ -589,6 +597,12 @@ export default class IABookActions extends LitElement {
589
597
  // remove respected key:value for loan-renew
590
598
  await this.localCache.delete(`${this.identifier}-loanTime`);
591
599
  await this.localCache.delete(`${this.identifier}-pageChangedTime`);
600
+ log(
601
+ '[IABookActions] browseHasExpired: cleared loanTime/pageChangedTime cache',
602
+ {
603
+ identifier: this.identifier,
604
+ }
605
+ );
592
606
 
593
607
  // show message after browsed book is expired.
594
608
  this.loanRenewResult.renewNow = false;
@@ -596,6 +610,9 @@ export default class IABookActions extends LitElement {
596
610
  'This book has been returned due to inactivity.';
597
611
 
598
612
  this.modal?.closeModal();
613
+ // Release the warning-modal re-entrancy guard, or it stays latched for
614
+ // the life of the component.
615
+ this.warningModalOpen = false;
599
616
 
600
617
  this.sentryCaptureMsg(sentryLogs.browseHasExpired);
601
618
  }
@@ -702,23 +719,31 @@ export default class IABookActions extends LitElement {
702
719
  }
703
720
 
704
721
  /**
705
- * Execute after auto loan renewed is completed
706
- * - show success message
707
- * - then change the remaining time
708
- * - reset timer state
722
+ * Runs after a loan renewal completes (successfully or not): shows the
723
+ * outcome, updates the remaining time, and resets renewal-in-flight state.
709
724
  * @param {object} event
710
725
  */
711
726
  async handleLoanAutoRenewed({ detail }) {
712
727
  const activeLoan = detail?.data?.loan;
713
- const errorMessage = `Whoops, seems we hit a hiccup with renewing this book. Please refresh & retry. --- (Debug: ${detail?.data?.error})`;
714
- if (!activeLoan) {
728
+
729
+ // Treat anything but a confirmed renewal as a failure, or
730
+ // loanRenewInProgress stays latched forever (autoLoanRenewChecker/
731
+ // autoRenewExpiredLoan both no-op on it).
732
+ if (!activeLoan?.renewal) {
733
+ this.loanRenewInProgress = false;
715
734
  this.recoveringFromLoanExpiry = false;
716
- this.showErrorModal(errorMessage, 'handleLoanAutoRenewed');
735
+ this.warningModalOpen = false;
736
+
737
+ // dispatchActionError() has already reported this and shown the
738
+ // relevant modal; don't stack a second one on top.
739
+ log('[IABookActions] handleLoanAutoRenewed: not a confirmed renewal', {
740
+ identifier: this.identifier,
741
+ activeLoan,
742
+ });
717
743
  return;
718
744
  }
719
745
 
720
746
  if (this.loanRenewResult.renewNow) {
721
- // Now, let's reset loan duration & this.lendingStatus
722
747
  const loanTime = await this.localCache.get(`${this.identifier}-loanTime`);
723
748
 
724
749
  // number of seconds left in current loan
@@ -735,18 +760,28 @@ export default class IABookActions extends LitElement {
735
760
  ajaxResponse: detail?.data,
736
761
  });
737
762
 
763
+ // BookLoanService::attempt_to_renew_loan() mints a valid access
764
+ // token as part of EVERY renew_loan response, not just a recovery
765
+ // from expiry — so the poller restart below never needs to fire a
766
+ // redundant confirming create_token call, regardless of which path
767
+ // triggered this renewal.
768
+ this.skipNextInitialTokenCall = true;
769
+
770
+ // A page image requested in the gap between expiry and this renewal
771
+ // landing can come back broken — browsers don't retry a failed <img>
772
+ // on their own. Retry once, right when access is confirmed good
773
+ // again, rather than leaving it broken until the patron reloads.
774
+ this.reloadPageImages();
775
+
738
776
  if (this.recoveringFromLoanExpiry) {
739
- // The loan genuinely lapsed, and renew_loan has now CONFIRMED it's
740
- // renewed server-side — only now is it safe to let BookReader
741
- // re-initialize (create_token was minting its access cookie off
742
- // the loan's expiry; doing this before confirmation risked reading
743
- // the still-expired record). loanRenewInProgress already kept the
744
- // token poller from restarting early, so it'll pick up fresh here
745
- // once lendingStatus below re-triggers setupLendingToolbarActions().
746
- // NOTE: recoveringFromLoanExpiry itself is consumed/reset there,
747
- // not here — it decides whether that restart needs a datanode
748
- // propagation buffer (see setupLendingToolbarActions).
749
- this.postInitComplete = false;
777
+ // Only now is it safe to re-initialize BookReader — not for a
778
+ // routine top-up, where that would be disruptive. Call it
779
+ // directly rather than waiting on a confirming create_token
780
+ // success: BookLoanService::attempt_to_renew_loan() already
781
+ // minted a valid access token as part of this renew_loan
782
+ // response, so there's nothing left to confirm.
783
+ this.lendingBarPostInit();
784
+ this.postInitComplete = true;
750
785
  }
751
786
 
752
787
  const currStatus = {
@@ -769,67 +804,55 @@ export default class IABookActions extends LitElement {
769
804
  this.loanRenewInProgress = false;
770
805
  }
771
806
 
772
- /**
773
- * start timer countdown interval after loan auto-renewed
774
- *
775
- * @memberof IABookActions
776
- */
807
+ /** Start the countdown interval; ticks read the live secondsLeftOnLoan
808
+ * each time rather than a value frozen at start, so drift can't compound
809
+ * across ticks (see loanStatusCheckInterval). */
777
810
  async startTimerCountdown() {
778
811
  window?.IALendingIntervals?.clearTimerCountdown();
779
-
780
- const secondsLeft = Number(this.lendingStatus.secondsLeftOnLoan);
781
812
  this.timeWhenTimerStart = new Date();
782
813
 
783
814
  window.IALendingIntervals.timerCountdown = setInterval(async () => {
784
- // interval execution block
785
- await this.loanStatusCheckInterval(secondsLeft);
815
+ await this.loanStatusCheckInterval(
816
+ Number(this.lendingStatus.secondsLeftOnLoan)
817
+ );
786
818
  }, this.timerExecutionSeconds * 1000);
787
819
  }
788
820
 
789
821
  /**
790
- * setInterval execution function do following things
791
- * - if setInterval timer gone off, let's resync
792
- * - loan-renew attempt to renew the loan OR show relavant message
793
- * - if loan has expired, clear interval timers
794
- *
822
+ * Runs on every countdown tick: resyncs against wall-clock time, attempts
823
+ * a renewal near expiry, and clears the timers once the loan expires.
795
824
  * @param {Number} secondsLeftOnLoan
796
825
  */
797
826
  async loanStatusCheckInterval(secondsLeftOnLoan) {
798
827
  let secondsLeft = secondsLeftOnLoan;
799
828
  secondsLeft -= this.timerExecutionSeconds;
800
- secondsLeft = Math.round(secondsLeft); // round number
829
+ secondsLeft = Math.round(secondsLeft);
801
830
 
802
- // re-sync timer if gone off because of background window
803
- // side effect: updates this.lendingStatus & kicks off lifecycle,
804
- // if really updated, escape from here
805
831
  const resyncd = this.reSyncTimerIfGoneOff(secondsLeft);
806
-
807
832
  if (resyncd.hasSynced) {
808
833
  secondsLeft = resyncd.whatShouldLeft;
809
- log('[IABookActions] timer: timer re-synced', {
810
- secondsLeft,
811
- });
834
+ log('[IABookActions] timer: timer re-synced', { secondsLeft });
812
835
  }
813
836
 
814
837
  log('[IABookActions] timer', {
815
- secondsLeft,
816
- loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
817
- pageChangedInLast: this.loanRenewTimeConfig.pageChangedInLast,
838
+ whatShouldLeft: resyncd.whatShouldLeft,
839
+ whatIsleft: secondsLeft,
818
840
  });
819
841
 
820
- /**
821
- * execute from last 10th minutes to 0th minute
822
- * - 10th - to check if user has viewed
823
- * - till 0th - to show warning msg with remaining time to auto expired
824
- * @see IABookActions::bindLoanRenewEvents
825
- */
842
+ // Re-anchor every tick so drift from wall-clock time can't compound
843
+ // across ticks that don't happen to trigger a resync above.
844
+ this.timeWhenTimerStart = new Date();
845
+ this.lendingStatus = { ...this.lendingStatus, secondsLeftOnLoan: secondsLeft };
846
+
847
+ // 10 minutes out: start checking for a renewal. 0: show the "about to
848
+ // auto-return" warning. @see IABookActions::bindLoanRenewEvents
826
849
  if (secondsLeft <= this.loanRenewTimeConfig.loanRenewAtLast) {
827
850
  await this.loanRenewAttempt(secondsLeft);
828
851
  }
829
852
 
830
- // clear interval in secondsLeft if less
831
853
  if (secondsLeft <= this.timerExecutionSeconds) {
832
- this.disconnectedCallback();
854
+ window?.IALendingIntervals?.clearAll();
855
+ this.tokenPoller?.disconnectedCallback();
833
856
  this.sentryCaptureMsg(sentryLogs.clearOneHourTimer);
834
857
  }
835
858
  }
@@ -843,6 +866,15 @@ export default class IABookActions extends LitElement {
843
866
  reSyncTimerIfGoneOff(timerSecondsLeft) {
844
867
  const currentTime = new Date();
845
868
 
869
+ // No prior anchor to compare against -- e.g. visibilitychange calling
870
+ // loanStatusCheckInterval() directly before startTimerCountdown() ever
871
+ // ran, which setupLendingToolbarActions() now skips while a renewal is
872
+ // in flight. Nothing to resync yet; anchor now and report no drift.
873
+ if (!this.timeWhenTimerStart) {
874
+ this.timeWhenTimerStart = currentTime;
875
+ return { hasSynced: false, whatShouldLeft: Math.round(timerSecondsLeft) };
876
+ }
877
+
846
878
  // current time - loan time
847
879
  const diffInSeconds =
848
880
  currentTime.getTime() / 1000 - this.timeWhenTimerStart.getTime() / 1000;
@@ -884,13 +916,8 @@ export default class IABookActions extends LitElement {
884
916
  */
885
917
  async loanRenewAttempt(secondsLeft) {
886
918
  let loanSecondsLeft = secondsLeft;
887
- /**
888
- * auto-renew is not possible in last seconds (let say 50 second) because,
889
- * 1. less time to execute ajax call
890
- * 2. less time to write loan on datanodes
891
- * 3. less time to load images by create_token api
892
- * so if seconds left is < 50, just expire the loan
893
- */
919
+ // Under 50s left there isn't enough time for the renew_loan round-trip
920
+ // and create_token to load images, so just expire the loan.
894
921
  if (loanSecondsLeft < 50) {
895
922
  log('[IABookActions] loanRenewAttempt: < 50s left, expiring loan');
896
923
  await this.browseHasExpired();
@@ -899,14 +926,13 @@ export default class IABookActions extends LitElement {
899
926
 
900
927
  await this.autoLoanRenewChecker(false);
901
928
 
902
- // show warning modal with remaining time to auto returned it.
903
- // once the patron has dismissed it, don't re-show it on every subsequent
904
- // tick — only an actual renewal (see handleLoanAutoRenewed) re-arms it.
905
- if (this.loanRenewResult.renewNow === false && !this.warningModalDismissed) {
906
- /**
907
- * so compensate for the 50 second buffer to handle above race conditions
908
- * let's reduce 1 min from warning texts and early return the book when 1 min left.
909
- */
929
+ // Once dismissed, don't re-show the warning on every subsequent tick —
930
+ // only an actual renewal (handleLoanAutoRenewed) re-arms it.
931
+ if (
932
+ this.loanRenewResult.renewNow === false &&
933
+ !this.warningModalDismissed
934
+ ) {
935
+ // Compensate for the 50s buffer above by warning a minute early.
910
936
  loanSecondsLeft -= 60;
911
937
  this.loanRenewResult.secondsLeft = loanSecondsLeft;
912
938
 
@@ -916,9 +942,10 @@ export default class IABookActions extends LitElement {
916
942
 
917
943
  /**
918
944
  * enable access of borrowed/browsed books
945
+ * @param {boolean} [skipInitialCall] - see LoanTokenPoller's constructor doc
919
946
  * @see LoanTokenPoller
920
947
  */
921
- startLoanTokenPoller() {
948
+ startLoanTokenPoller(skipInitialCall = false) {
922
949
  const successCallback = () => {
923
950
  if (!this.postInitComplete) {
924
951
  this.lendingBarPostInit();
@@ -929,24 +956,17 @@ export default class IABookActions extends LitElement {
929
956
  this.handleLendingActionError(eventObj);
930
957
  };
931
958
 
932
- /**
933
- * LoanTokenPoller is a class that polls the loan token
934
- * it takes 5 params
935
- * 1. this.identifier
936
- * 2. this.borrowType
937
- * 3. successCallback - it used to
938
- * - disptach lendingFlow::PostInit event
939
- * - initialize bookreader using br.init()
940
- * 4. errorCallback
941
- * 5. tokenPollerDelay
942
- */
943
- this.tokenPoller = new LoanTokenPoller(
944
- this.identifier,
945
- this.borrowType,
959
+ // Tear down any previous poller first, so only one is ever controlling
960
+ // window.IALendingIntervals.tokenPoller at a time.
961
+ this.tokenPoller?.disconnectedCallback();
962
+ this.tokenPoller = new LoanTokenPoller({
963
+ identifier: this.identifier,
964
+ borrowType: this.borrowType,
946
965
  successCallback,
947
966
  errorCallback,
948
- this.tokenDelay // in seconds
949
- );
967
+ pollerDelay: this.tokenDelay, // in seconds
968
+ skipInitialCall,
969
+ });
950
970
  }
951
971
 
952
972
  /*
@@ -959,45 +979,63 @@ export default class IABookActions extends LitElement {
959
979
  }
960
980
 
961
981
  /**
962
- * handle lending errors occure during different operation like
963
- * browse book, borrow book, borrowed book token etc...
964
- *
982
+ * Handles lending errors from any action (browse_book, borrow_book,
983
+ * create_token, renew_loan, etc).
965
984
  * @event IABookActions#lendingActionError
966
- * @param {Object} event - The employee who is responsible for the project
967
- * @param {string} event.detail.action - action when error occurred like 'browseBook', 'borrowBook'
968
- * @param {string} event.detail.data.error - error message
985
+ * @param {Object} event
986
+ * @param {string} event.detail.action
987
+ * @param {string} event.detail.data.error
969
988
  */
970
989
  handleLendingActionError(event) {
971
990
  this.disableActionGroup = false;
972
991
 
973
992
  const action = event?.detail?.action;
974
- const errorMsg = event?.detail?.data?.error;
993
+ // handleLoanRenewNow's failure path sets `.error` to a boolean flag and
994
+ // puts the real text in `.message` — only take `.error` when it's
995
+ // actually a string, or the modal renders the literal word "true".
996
+ const rawErrorMsg = event?.detail?.data?.error;
997
+ const errorMsg =
998
+ typeof rawErrorMsg === 'string' ? rawErrorMsg : undefined;
999
+ const isInitial = event?.detail?.isInitial === true;
1000
+
1001
+ log('[IABookActions] handleLendingActionError', {
1002
+ identifier: this.identifier,
1003
+ action,
1004
+ errorMsg,
1005
+ isInitial,
1006
+ loanRenewInProgress: this.loanRenewInProgress,
1007
+ recoveringFromLoanExpiry: this.recoveringFromLoanExpiry,
1008
+ });
975
1009
 
976
1010
  if (action === 'create_token') {
977
- // A create_token hiccup only affects BookReader's page-image access
978
- // token — it says nothing about how much time is left on the loan
979
- // itself, so it must not stop the reading countdown. Only clear the
980
- // token poller so it can be retried.
981
- window?.IALendingIntervals?.clearTokenPoller();
982
-
983
- // The loan itself is fine — just this token refresh failed — but
984
- // the action bar must reflect that it isn't safely browsable right
985
- // now rather than silently doing nothing.
1011
+ // Any create_token failure — initial or a routine interval refresh —
1012
+ // means access can no longer be confirmed (e.g. a stale loan-record
1013
+ // cache on some other node can make this request fail even though
1014
+ // the loan is fine, or the loan may genuinely have just been
1015
+ // returned via BookReaderImages.php's revokeAccess()). Don't retry
1016
+ // and don't stay silent either way: reset the bar to Borrow and the
1017
+ // timer to 0 rather than leaving a stale countdown for a session
1018
+ // that isn't accessible.
1019
+ window?.IALendingIntervals?.clearAll();
1020
+ this.tokenPoller?.disconnectedCallback();
986
1021
  this.lendingStatus = {
987
1022
  ...this.lendingStatus,
988
1023
  user_has_browsed: false,
989
1024
  available_to_browse: true,
1025
+ secondsLeftOnLoan: 0,
990
1026
  };
1027
+
1028
+ // showErrorModal has dedicated create_token messaging (refresh
1029
+ // button + support email) and shows regardless of whether a
1030
+ // specific error string came back.
1031
+ this.showErrorModal(errorMsg, action);
991
1032
  } else if (action === 'renew_loan') {
992
1033
  window?.IALendingIntervals?.clearAll();
993
1034
  this.loanRenewInProgress = false;
994
1035
  this.recoveringFromLoanExpiry = false;
995
1036
 
996
- // The loan was NOT actually renewed — reflect that immediately
997
- // (show Borrow, and clear the stale timer value) instead of leaving
998
- // the action bar showing "Return now" with a frozen countdown from
999
- // before the failed attempt, which falsely implies the book is
1000
- // still actively being read.
1037
+ // The loan was NOT renewed — reflect that immediately (show Borrow,
1038
+ // clear the stale timer) rather than leaving a frozen "Return now".
1001
1039
  this.lendingStatus = {
1002
1040
  ...this.lendingStatus,
1003
1041
  user_has_browsed: false,
@@ -1005,11 +1043,8 @@ export default class IABookActions extends LitElement {
1005
1043
  secondsLeftOnLoan: 0,
1006
1044
  };
1007
1045
 
1008
- // Show the real error and refresh the page on dismissal (the
1009
- // modal's Okay button) so the client picks up whatever the server
1010
- // now authoritatively considers true — matching on "not available"
1011
- // to guess the reason was fragile since the server can return any
1012
- // message (e.g. a lending limit).
1046
+ // Refresh the page on dismissal so the client picks up whatever the
1047
+ // server now authoritatively considers true (any error message).
1013
1048
  this.showLoanUnavailableModal(errorMsg);
1014
1049
  } else {
1015
1050
  // Every other action failure genuinely affects loan state.
@@ -1022,9 +1057,15 @@ export default class IABookActions extends LitElement {
1022
1057
  // update action bar state if book is not available to browse or borrow.
1023
1058
  if (errorMsg.match(/not available to borrow/gm)) {
1024
1059
  if (action === 'browse_book') {
1025
- this.lendingStatus = { ...this.lendingStatus, available_to_browse: false };
1060
+ this.lendingStatus = {
1061
+ ...this.lendingStatus,
1062
+ available_to_browse: false,
1063
+ };
1026
1064
  } else if (action === 'borrow_book') {
1027
- this.lendingStatus = { ...this.lendingStatus, available_to_borrow: false };
1065
+ this.lendingStatus = {
1066
+ ...this.lendingStatus,
1067
+ available_to_borrow: false,
1068
+ };
1028
1069
  }
1029
1070
  }
1030
1071
  }
@@ -1055,7 +1096,7 @@ export default class IABookActions extends LitElement {
1055
1096
  .identifier}"
1056
1097
  >info@archive.org</a
1057
1098
  ><br /><br />
1058
- <code>errorLog: ${errorMsg}</code>`;
1099
+ ${errorMsg ? html`<code>errorLog: ${errorMsg}</code>` : nothing}`;
1059
1100
  }
1060
1101
 
1061
1102
  await this.modal?.showModal({
@@ -1109,6 +1150,10 @@ export default class IABookActions extends LitElement {
1109
1150
  justify-content: center;
1110
1151
  flex-wrap: wrap;
1111
1152
  }
1153
+
1154
+ #action-bar-modal {
1155
+ --modalWidth: 36rem;
1156
+ }
1112
1157
  `;
1113
1158
  }
1114
1159
  }