@internetarchive/ia-book-actions 1.4.3 → 1.4.4-alpha10

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,6 +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
24
 
24
25
  export const events = {
25
26
  browseExpired: 'IABookReader:BrowsingHasExpired',
@@ -93,10 +94,28 @@ export default class IABookActions extends LitElement {
93
94
  this.browseTimer = undefined; // timeout
94
95
  this.timeWhenTimerStart = undefined;
95
96
 
97
+ this.loanRenewInProgress = false;
98
+
96
99
  /**
97
- * when user click on [return the book] button on warning modal
100
+ * True while a renewal in flight is recovering from a loan that
101
+ * genuinely lapsed (set by autoRenewExpiredLoan), as opposed to a
102
+ * routine pre-expiry top-up. Only the recovery case needs BookReader
103
+ * re-initialized once the renewal is confirmed — see
104
+ * handleLoanAutoRenewed.
98
105
  */
99
- this.returnNow = false;
106
+ this.recoveringFromLoanExpiry = false;
107
+
108
+ this.warningModalOpen = false;
109
+
110
+ /**
111
+ * Once the patron has seen & dismissed the informational warning modal,
112
+ * don't nag them again with the same modal every timer tick — only a
113
+ * real renewal (via interacting with the book) clears this, giving the
114
+ * next pre-expiry window its own single warning.
115
+ * @see loanRenewAttempt
116
+ * @see handleLoanAutoRenewed
117
+ */
118
+ this.warningModalDismissed = false;
100
119
 
101
120
  /**
102
121
  * contains one hour auto-loan-renew time configuration
@@ -141,7 +160,6 @@ export default class IABookActions extends LitElement {
141
160
  * @param {string} msg
142
161
  */
143
162
  sentryCaptureMsg(msg) {
144
- log(window?.Sentry);
145
163
  window?.Sentry?.captureMessage(msg);
146
164
  }
147
165
 
@@ -171,6 +189,13 @@ export default class IABookActions extends LitElement {
171
189
  }
172
190
 
173
191
  if (changed.has('loanRenewResult') && this.loanRenewResult.renewNow) {
192
+ // Any renewal — not just the auto-return recovery path — pauses the
193
+ // countdown for the renew_loan round-trip. Flag it here, centrally,
194
+ // so every path (interaction during active reading, periodic
195
+ // checker, auto-return recovery) gets the same "renewing" signal
196
+ // instead of just looking frozen until handleLoanAutoRenewed() clears
197
+ // this and restarts the timer.
198
+ this.loanRenewInProgress = true;
174
199
  window.IALendingIntervals.clearAll();
175
200
  }
176
201
  }
@@ -208,32 +233,16 @@ export default class IABookActions extends LitElement {
208
233
  /** End SharedObserver resize handler */
209
234
 
210
235
  async setupLendingToolbarActions() {
211
- this.lendingOptions = new GetLendingActions(
212
- this.userid,
213
- this.identifier,
214
- this.lendingStatus,
215
- this.bwbPurchaseUrl
216
- );
217
- const actions = this.lendingOptions.getCurrentLendingActions();
218
-
219
- if (!actions) return;
220
-
221
- this.primaryTitle = actions.primaryTitle;
222
- this.primaryActions = actions.primaryActions?.filter(action => {
223
- return action != null;
224
- });
225
- this.primaryColor = actions.primaryColor;
226
- this.secondaryActions = actions.secondaryActions?.filter(action => {
227
- return action != null;
228
- });
229
-
230
- this.borrowType = actions.borrowType ? actions.borrowType : null;
231
-
232
236
  const hasExpired =
233
237
  'browsingExpired' in this.lendingStatus &&
234
238
  this.lendingStatus?.browsingExpired;
235
239
  if (hasExpired) {
236
- log('setupLendingToolbarActions > hasExpired --- ');
240
+ // Intentionally do NOT recompute primaryActions/primaryColor/borrowType
241
+ // here — per the ticket, auto-return must not visibly change anything.
242
+ // The action bar stays exactly as it looked while reading; only a
243
+ // successful silent renewal or the "unavailable" error modal are
244
+ // allowed to change what's shown next.
245
+ log('[IABookActions] browsing expired — leaving action bar untouched');
237
246
 
238
247
  if (!this.tokenPoller) {
239
248
  this.sentryCaptureMsg(sentryLogs.bookWasExpired);
@@ -253,6 +262,27 @@ export default class IABookActions extends LitElement {
253
262
  return;
254
263
  }
255
264
 
265
+ this.lendingOptions = new GetLendingActions(
266
+ this.userid,
267
+ this.identifier,
268
+ this.lendingStatus,
269
+ this.bwbPurchaseUrl
270
+ );
271
+ const actions = this.lendingOptions.getCurrentLendingActions();
272
+
273
+ if (!actions) return;
274
+
275
+ this.primaryTitle = actions.primaryTitle;
276
+ this.primaryActions = actions.primaryActions?.filter(action => {
277
+ return action != null;
278
+ });
279
+ this.primaryColor = actions.primaryColor;
280
+ this.secondaryActions = actions.secondaryActions?.filter(action => {
281
+ return action != null;
282
+ });
283
+
284
+ this.borrowType = actions.borrowType ? actions.borrowType : null;
285
+
256
286
  if (this.borrowType === 'browsed') {
257
287
  // start timer for loan-renew
258
288
  await this.startTimerCountdown();
@@ -269,11 +299,22 @@ export default class IABookActions extends LitElement {
269
299
 
270
300
  /**
271
301
  * tokenPoller determines if user has loan token for this book
272
- * - if book is going to renew, need to wait until renew is completed
302
+ * - if book is going to renew, need to wait until renew is completed:
303
+ * loanRenewInProgress is set before renew_loan is even dispatched
304
+ * (including the optimistic pre-confirmation window) and only
305
+ * clears once the renewal is confirmed, so starting the poller here
306
+ * mid-renewal would call create_token against the loan record
307
+ * before it's actually renewed.
273
308
  */
274
309
  setTimeout(() => {
275
- if (!hasExpired && !window.IALendingIntervals.tokenPoller)
310
+ if (
311
+ !hasExpired &&
312
+ !this.loanRenewInProgress &&
313
+ !window.IALendingIntervals.tokenPoller
314
+ ) {
315
+ this.recoveringFromLoanExpiry = false;
276
316
  this.startLoanTokenPoller();
317
+ }
277
318
  }, 100);
278
319
 
279
320
  this.requestUpdate();
@@ -290,8 +331,27 @@ export default class IABookActions extends LitElement {
290
331
  * dispatched this event from bookreader page changed
291
332
  */
292
333
  window.addEventListener('BookReader:userAction', () => {
293
- log('IABookActions:BookReader:userAction');
294
- if (this.borrowType === 'browsed') {
334
+ // Capture before autoRenewExpiredLoan() runs — it synchronously flips
335
+ // lendingStatus.browsingExpired to false as an optimistic UI update,
336
+ // so checking the live property afterward would always see it as
337
+ // false and wrongly let autoLoanRenewChecker() run too. That second
338
+ // call would then overwrite loanRenewResult.renewNow (set true by
339
+ // autoRenewExpiredLoan) back to false, since the loanTime cache entry
340
+ // was already deleted by browseHasExpired() — silently killing the
341
+ // in-flight renewal and leaving the countdown stuck.
342
+ const wasExpired = this.lendingStatus.browsingExpired;
343
+
344
+ log('[IABookActions] BookReader:userAction received', {
345
+ borrowType: this.borrowType,
346
+ browsingExpired: wasExpired,
347
+ });
348
+
349
+ // If the loan expired while the tab stayed visible, auto-renew on page turn
350
+ if (wasExpired) {
351
+ this.autoRenewExpiredLoan();
352
+ }
353
+
354
+ if (this.borrowType === 'browsed' && !wasExpired) {
295
355
  this.autoLoanRenewChecker(true);
296
356
  }
297
357
  });
@@ -302,18 +362,23 @@ export default class IABookActions extends LitElement {
302
362
  * the [visibilitychange] event can trigger timer to show relavent messages
303
363
  */
304
364
  document.addEventListener('visibilitychange', async () => {
305
- if (!document.hidden) {
306
- log(
307
- 'visibilitychange event execute:------------------ ',
308
- new Date().getMinutes(),
309
- new Date().getSeconds(),
310
- this.borrowType
311
- );
312
-
313
- if (
314
- this.borrowType === 'browsed' &&
315
- this.lendingStatus.browsingExpired === false
316
- ) {
365
+ if (document.hidden) {
366
+ log('[IABookActions] visibilitychange: tab backgrounded');
367
+ return;
368
+ }
369
+
370
+ log('[IABookActions] visibilitychange: tab foregrounded', this.borrowType);
371
+
372
+ try {
373
+ // Loan already expired while tab was hidden — try to silently renew
374
+ if (this.lendingStatus.browsingExpired === true) {
375
+ this.autoRenewExpiredLoan();
376
+ return;
377
+ }
378
+
379
+ if (this.borrowType !== 'browsed') return;
380
+
381
+ if (this.lendingStatus.browsingExpired === false) {
317
382
  const loanTime = await this.localCache.get(
318
383
  `${this.identifier}-loanTime`
319
384
  );
@@ -324,10 +389,15 @@ export default class IABookActions extends LitElement {
324
389
  if (secondsLeft >= this.timerExecutionSeconds) {
325
390
  this.loanStatusCheckInterval(Number(secondsLeft));
326
391
  } else {
327
- this.browseHasExpired();
392
+ // Loan expired while user was away — try to silently renew
393
+ this.autoRenewExpiredLoan();
328
394
  this.disconnectedCallback();
329
395
  }
330
396
  }
397
+ } catch (error) {
398
+ // Surface localCache failures instead of an unhandled rejection.
399
+ log('[IABookActions] visibilitychange handler failed', error);
400
+ this.sentryCaptureMsg(`visibilitychange handler failed: ${error}`);
331
401
  }
332
402
  });
333
403
  }
@@ -339,6 +409,16 @@ export default class IABookActions extends LitElement {
339
409
  * @param {Boolean} hasPageChanged
340
410
  */
341
411
  async autoLoanRenewChecker(hasPageChanged = false) {
412
+ // Guards against re-entrancy the same way autoRenewExpiredLoan() does.
413
+ // BookReader:userAction can fire several times in quick succession
414
+ // (e.g. a single scroll gesture), and this method has no debouncing of
415
+ // its own — without this guard, each event would spin up its own
416
+ // LoanRenewHelper concurrently, and whichever's async localCache reads
417
+ // resolve last would clobber this.loanRenewResult, potentially
418
+ // re-triggering the whole renew_loan/create_token flow while a
419
+ // renewal from an earlier event is already in flight or just landed.
420
+ if (this.loanRenewInProgress) return;
421
+
342
422
  this.loanRenewHelper = new LoanRenewHelper(
343
423
  hasPageChanged,
344
424
  this.identifier,
@@ -350,6 +430,37 @@ export default class IABookActions extends LitElement {
350
430
  this.loanRenewResult = this.loanRenewHelper.result;
351
431
  }
352
432
 
433
+ /**
434
+ * Attempt to automatically renew a browse loan that has expired.
435
+ * Called on visibilitychange (user returns to tab) or on page turn when
436
+ * the loan has expired but the book may still be available.
437
+ *
438
+ * Reuses the same renew_loan path as the automatic loan-renew checker.
439
+ * On success: handleLoanAutoRenewed() resets the timer seamlessly.
440
+ * On error: handleLendingActionError() shows showLoanUnavailableModal().
441
+ */
442
+ autoRenewExpiredLoan() {
443
+ if (this.loanRenewInProgress) return;
444
+ this.loanRenewInProgress = true;
445
+ this.recoveringFromLoanExpiry = true;
446
+
447
+ this.modal?.closeModal();
448
+
449
+ // Optimistically flip browsingExpired back to false so the action bar
450
+ // stays red (patronIsReadingAction) while the renew_loan request is in
451
+ // flight, instead of showing the blue "Borrow" state.
452
+ this.lendingStatus = {
453
+ ...this.lendingStatus,
454
+ browsingExpired: false,
455
+ secondsLeftOnLoan: this.loanRenewTimeConfig.loanTotalTime,
456
+ };
457
+
458
+ // Defer renewNow until after lendingStatus timer setup completes.
459
+ setTimeout(() => {
460
+ this.loanRenewResult = { texts: '', renewNow: true, renewType: 'auto' };
461
+ }, 0);
462
+ }
463
+
353
464
  /**
354
465
  * Required as sibling on page
355
466
  * @returns HTMLElement
@@ -362,158 +473,86 @@ export default class IABookActions extends LitElement {
362
473
  }
363
474
 
364
475
  /**
365
- * Show the waring modal to ask user if they are still reading
476
+ * Show the informational modal warning the patron their loan will expire
477
+ * soon. This is acknowledgement-only — closing it does not renew the loan.
478
+ * Only interacting with the book itself (e.g. turning a page) renews it.
366
479
  */
367
480
  async showWarningModal() {
368
- log('****** showWarningModal ******');
369
- // clear modal
370
- this.modal.customModalContent = nothing;
371
- this.modal?.closeModal();
372
- this.loanRenewResult = { texts: '', renewNow: false };
373
-
374
- // if secondsLeft < 60, consider it 1 minute
375
- let { secondsLeft } = this.loanRenewResult;
376
- if (secondsLeft === undefined) {
481
+ if (this.warningModalOpen) return;
482
+ this.warningModalOpen = true;
483
+
484
+ log('[IABookActions] showWarningModal');
485
+
486
+ // Capture texts and secondsLeft before resetting loanRenewResult
487
+ const {
488
+ texts: warningTexts,
489
+ secondsLeft: rawSecondsLeft,
490
+ } = this.loanRenewResult;
491
+ let secondsLeft = rawSecondsLeft;
492
+ if (secondsLeft === undefined || secondsLeft <= 0) {
377
493
  secondsLeft = this.lendingStatus.secondsLeftOnLoan;
378
494
  } else {
379
495
  secondsLeft = secondsLeft > 60 ? secondsLeft : 60;
380
496
  }
381
497
 
498
+ // clear modal and reset renew state
499
+ this.modal.customModalContent = nothing;
500
+ this.modal?.closeModal();
501
+ this.loanRenewResult = { texts: '', renewNow: false };
502
+
382
503
  const config = new ModalConfig({
383
- headline: 'Are you still reading?',
504
+ headline: 'Are you still there?',
384
505
  headerColor: '#194880',
385
506
  showCloseButton: false,
386
507
  closeOnBackdropClick: false,
387
- message: this.loanRenewHelper?.getMessageTexts(
388
- this.loanRenewResult.texts,
389
- secondsLeft
390
- ),
391
- });
392
-
393
- const customModalContent = html`<br />
394
- <div
395
- id="book-action-bar-custom-buttons"
396
- style="display:flex;justify-content:center;"
508
+ message: html`<span
509
+ style="display:inline-flex;align-items:center;flex-wrap:nowrap;gap:4px;"
397
510
  >
398
- <button
399
- style="${modalButtonStyle.iaButton} ${modalButtonStyle.renew}"
400
- @click=${() => this.patronWantsToRenewBook()}
401
- >
402
- Keep reading
403
- </button>
404
- <button
405
- style="${modalButtonStyle.iaButton} ${modalButtonStyle.return}"
406
- @click=${() => this.patronWantsToReturnBook()}
511
+ <span>${this.loanRenewHelper?.getMessageTexts(
512
+ warningTexts,
513
+ secondsLeft
514
+ )}</span>
515
+ <a
516
+ href="https://help.archive.org/help/borrowing-from-the-lending-library"
517
+ target="_blank"
518
+ title="Get more info on borrowing from The Lending Library"
519
+ data-event-click-tracking="BookReader|BrowsableMoreInfo"
520
+ style="display:inline-flex;flex-shrink:0;line-height:0;"
407
521
  >
408
- Return the book
409
- </button>
410
- </div> `;
411
-
412
- this.modal.setAttribute('aria-live', 'assertive');
413
- await this.modal?.showModal({ config, customModalContent });
414
- }
415
-
416
- /** @param { 'renewBook' | 'returnBook' } buttonToDisable */
417
- async showWarningDisabledModal(buttonToDisable = 'renewBook') {
418
- // if secondsLeft < 60, consider it 1 minute
419
- let { secondsLeft } = this.loanRenewResult;
420
- if (secondsLeft === undefined) {
421
- secondsLeft = this.lendingStatus.secondsLeftOnLoan;
422
- } else {
423
- secondsLeft = secondsLeft > 60 ? secondsLeft : 60;
424
- }
425
-
426
- const config = new ModalConfig({
427
- headline: 'Are you still reading?',
428
- headerColor: '#194880',
429
- showCloseButton: false,
430
- closeOnBackdropClick: false,
431
- message: this.loanRenewHelper?.getMessageTexts(
432
- this.loanRenewResult.texts,
433
- secondsLeft
434
- ),
522
+ ${infoIcon}
523
+ </a>
524
+ </span>`,
435
525
  });
436
526
 
437
- const customModalContent = html`<br />
527
+ const customModalContent = html`
438
528
  <div
439
- id="disabled-book-action-bar-custom-buttons"
440
- style="display:flex;justify-content:center; opacity:0.8; pointer-events:none;"
529
+ id="book-action-bar-custom-buttons"
530
+ style="display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;margin-top:10px;"
441
531
  >
442
532
  <button
443
- disabled
444
533
  style="${modalButtonStyle.iaButton} ${modalButtonStyle.renew}"
534
+ @click=${() => this.dismissWarningModal()}
445
535
  >
446
- ${buttonToDisable === 'renewBook'
447
- ? html`<ia-activity-indicator
448
- mode="processing"
449
- style=${modalButtonStyle.loaderIcon}
450
- ></ia-activity-indicator>`
451
- : 'Keep reading'}
452
- </button>
453
- <span
454
- style="position: absolute; visibility: none; height: 1px; width: 1px; overflow: hidden;"
455
- >Renewing loan, one moment please.</span
456
- >
457
- <button
458
- disabled
459
- style="${modalButtonStyle.iaButton} ${modalButtonStyle.return}"
460
- >
461
- ${buttonToDisable === 'returnBook'
462
- ? html`<ia-activity-indicator
463
- mode="processing"
464
- style=${modalButtonStyle.loaderIcon}
465
- ></ia-activity-indicator>`
466
- : 'Return the book'}
536
+ Okay
467
537
  </button>
468
538
  </div> `;
469
539
 
540
+ this.modal.setAttribute('aria-live', 'assertive');
470
541
  await this.modal?.showModal({ config, customModalContent });
471
542
  }
472
543
 
473
- /** Handles Renew action in warning modal */
474
- async patronWantsToRenewBook() {
475
- this.showWarningDisabledModal();
476
- this.loanRenewResult = { texts: '', renewNow: true, renewType: 'manual' };
477
- }
478
-
479
- async patronWantsToReturnBook() {
480
- this.showWarningDisabledModal('returnBook');
481
- document.querySelector('ia-book-actions').disableActionGroup = true;
482
- this.returnNow = true;
483
- }
484
-
485
- /**
486
- * Show modal when book is auto returned
487
- */
488
- async showExpiredModal() {
489
- const config = new ModalConfig({
490
- headline: '',
491
- showCloseButton: false,
492
- closeOnBackdropClick: false,
493
- headerColor: '#194880',
494
- message: 'This book has been returned due to inactivity.',
495
- });
496
-
497
- const customModalContent = html`<br />
498
- <div style="text-align: center">
499
- <button
500
- style="${modalButtonStyle.iaButton} ${modalButtonStyle.renew}"
501
- @click=${() => {
502
- URLHelper.goToUrl(this.returnUrl, true);
503
- }}
504
- >
505
- Okay
506
- </button>
507
- </div> `;
508
-
509
- await this.modal?.showModal({ config, customModalContent });
544
+ /** Acknowledges the warning modal — closes it without renewing the loan */
545
+ dismissWarningModal() {
546
+ this.modal?.closeModal();
547
+ this.warningModalOpen = false;
548
+ this.warningModalDismissed = true;
510
549
  }
511
550
 
512
551
  /**
513
552
  * Execute when loan is expired
514
553
  */
515
554
  async browseHasExpired() {
516
- log('BrowseHasExpired ---');
555
+ log('[IABookActions] browseHasExpired');
517
556
  window?.IALendingIntervals?.clearAll();
518
557
 
519
558
  const currStatus = {
@@ -532,31 +571,53 @@ export default class IABookActions extends LitElement {
532
571
  this.loanRenewResult.texts =
533
572
  'This book has been returned due to inactivity.';
534
573
 
535
- await this.showExpiredModal();
574
+ this.modal?.closeModal();
536
575
 
537
576
  this.sentryCaptureMsg(sentryLogs.browseHasExpired);
538
577
  }
539
578
 
579
+ /**
580
+ * Show modal when the book can no longer be automatically renewed
581
+ * because another patron has checked it out.
582
+ */
583
+ async showLoanUnavailableModal(errorMsg) {
584
+ const config = new ModalConfig({
585
+ headline: '',
586
+ showCloseButton: false,
587
+ closeOnBackdropClick: false,
588
+ headerColor: '#d9534f',
589
+ message:
590
+ errorMsg ||
591
+ 'Due to inactivity, this book was returned, and someone else has now borrowed it. Please try again later.',
592
+ });
593
+
594
+ const customModalContent = html`<br />
595
+ <div style="text-align: center">
596
+ <button
597
+ style="${modalButtonStyle.iaButton} ${modalButtonStyle.return}"
598
+ @click=${() => URLHelper.goToUrl(this.returnUrl, true)}
599
+ >
600
+ Okay
601
+ </button>
602
+ </div>`;
603
+
604
+ await this.modal?.showModal({ config, customModalContent });
605
+ }
606
+
540
607
  async startBrowseTimer() {
541
608
  window?.IALendingIntervals?.clearBrowseExpireTimeout();
542
609
 
543
- const { browsingExpired, user_has_browsed, secondsLeftOnLoan } =
544
- this.lendingStatus;
610
+ const {
611
+ browsingExpired,
612
+ user_has_browsed,
613
+ secondsLeftOnLoan,
614
+ } = this.lendingStatus;
545
615
 
546
616
  if (!user_has_browsed || browsingExpired) {
547
- log('startBrowseTimer --- !user_has_browsed || browsingExpired', {
548
- user_has_browsed,
549
- browsingExpired,
550
- secondsLeftOnLoan,
551
- });
552
617
  return;
553
618
  }
554
619
 
555
620
  window.IALendingIntervals.browseExpireTimeout = setTimeout(() => {
556
- log(
557
- 'startBrowseTimer > browseExpireTimeout --- will expire loan',
558
- secondsLeftOnLoan
559
- );
560
621
  this.browseHasExpired();
561
622
  }, secondsLeftOnLoan * 1000);
562
623
  }
@@ -602,7 +663,6 @@ export default class IABookActions extends LitElement {
602
663
  ?disabled=${this.disableActionGroup}
603
664
  ?autoRenew=${this.loanRenewResult.renewNow}
604
665
  ?autoReturn=${this.lendingStatus.browsingExpired}
605
- ?returnNow=${this.returnNow}
606
666
  @loanAutoRenewed=${this.handleLoanAutoRenewed}
607
667
  @lendingActionError=${this.handleLendingActionError}
608
668
  @toggleActionGroup=${this.handleToggleActionGroup}
@@ -628,6 +688,7 @@ export default class IABookActions extends LitElement {
628
688
  const activeLoan = detail?.data?.loan;
629
689
  const errorMessage = `Whoops, seems we hit a hiccup with renewing this book. Please refresh & retry. --- (Debug: ${detail?.data?.error})`;
630
690
  if (!activeLoan) {
691
+ this.recoveringFromLoanExpiry = false;
631
692
  this.showErrorModal(errorMessage, 'handleLoanAutoRenewed');
632
693
  return;
633
694
  }
@@ -637,15 +698,36 @@ export default class IABookActions extends LitElement {
637
698
  const loanTime = await this.localCache.get(`${this.identifier}-loanTime`);
638
699
 
639
700
  // number of seconds left in current loan
640
- const secondsLeft = Math.round((loanTime - new Date()) / 1000);
641
- log(loanTime, secondsLeft);
642
-
643
- log('IABookActions: handleLoanAutoRenewed --- ', {
701
+ const rawSecondsLeft = Math.round((loanTime - new Date()) / 1000);
702
+ // Guard against a stale/missing loanTime read producing NaN, which
703
+ // would get the loan stuck "active" with a countdown that never moves.
704
+ const secondsLeft =
705
+ Number.isFinite(rawSecondsLeft) && rawSecondsLeft > 0
706
+ ? rawSecondsLeft
707
+ : this.loanRenewTimeConfig.loanTotalTime;
708
+ log('[IABookActions] handleLoanAutoRenewed', {
709
+ secondsLeft,
710
+ rawSecondsLeft,
644
711
  ajaxResponse: detail?.data,
645
- loanRenewResult: this.loanRenewResult,
646
- secondsLeftOnLoan: secondsLeft,
647
712
  });
648
713
 
714
+ if (this.recoveringFromLoanExpiry) {
715
+ // The loan genuinely lapsed, and renew_loan has now CONFIRMED it's
716
+ // renewed server-side — only now is it safe to let BookReader
717
+ // re-initialize (create_token was minting its access cookie off
718
+ // the loan's expiry; doing this before confirmation risked reading
719
+ // the still-expired record). loanRenewInProgress already kept the
720
+ // token poller from restarting early, so it'll pick up fresh here
721
+ // once lendingStatus below re-triggers setupLendingToolbarActions().
722
+ // recoveringFromLoanExpiry itself is consumed/reset there, not
723
+ // here — this ONLY applies to an actual recovery, not a routine
724
+ // renewal (re-running br.init() on every routine top-up would be
725
+ // needlessly disruptive). create_token's own retry logic (see
726
+ // LoanTokenPoller) handles the datanode write-propagation race
727
+ // for both recovery and routine renewals.
728
+ this.postInitComplete = false;
729
+ }
730
+
649
731
  const currStatus = {
650
732
  ...this.lendingStatus,
651
733
  user_has_browsed: true,
@@ -659,7 +741,11 @@ export default class IABookActions extends LitElement {
659
741
  this.modal.removeAttribute('id');
660
742
  this.modal.customModalContent = nothing;
661
743
  this.sentryCaptureMsg(sentryLogs.bookHasRenewed);
744
+ this.warningModalDismissed = false;
662
745
  }
746
+
747
+ this.warningModalOpen = false;
748
+ this.loanRenewInProgress = false;
663
749
  }
664
750
 
665
751
  /**
@@ -695,29 +781,20 @@ export default class IABookActions extends LitElement {
695
781
  // re-sync timer if gone off because of background window
696
782
  // side effect: updates this.lendingStatus & kicks off lifecycle,
697
783
  // if really updated, escape from here
698
- const resyncd = await this.reSyncTimerIfGoneOff(secondsLeft);
784
+ const resyncd = this.reSyncTimerIfGoneOff(secondsLeft);
699
785
 
700
786
  if (resyncd.hasSynced) {
701
787
  secondsLeft = resyncd.whatShouldLeft;
702
- log('startTimerCountdown --- stale, timer has resyncd', {
788
+ log('[IABookActions] timer: timer re-synced', {
703
789
  secondsLeft,
704
790
  });
705
791
  }
706
792
 
707
- log(
708
- 'startTimerCountdown --- countdown still valid. continue...',
709
- {
710
- resyncd,
711
- secondsLeft,
712
- loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
713
- },
714
- 'time: ',
715
- new Date().getMinutes(),
716
- ':',
717
- new Date().getSeconds(),
718
- ', timerDelay: ',
719
- this.timerExecutionSeconds
720
- );
793
+ log('[IABookActions] timer', {
794
+ secondsLeft,
795
+ loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
796
+ pageChangedInLast: this.loanRenewTimeConfig.pageChangedInLast,
797
+ });
721
798
 
722
799
  /**
723
800
  * execute from last 10th minutes to 0th minute
@@ -742,7 +819,7 @@ export default class IABookActions extends LitElement {
742
819
  * @param {number} timerSecondsLeft - actual seconds left get from setInterval
743
820
  * @returns {Object} { hasSynced: [boolean], whatShouldLeft: [number] }
744
821
  */
745
- async reSyncTimerIfGoneOff(timerSecondsLeft) {
822
+ reSyncTimerIfGoneOff(timerSecondsLeft) {
746
823
  const currentTime = new Date();
747
824
 
748
825
  // current time - loan time
@@ -752,27 +829,11 @@ export default class IABookActions extends LitElement {
752
829
  const secondsShouldLeft =
753
830
  this.lendingStatus.secondsLeftOnLoan - diffInSeconds;
754
831
 
755
- log('currentTime: ', currentTime);
756
- log('timeWhenTimerStart: ', this.timeWhenTimerStart);
757
- log('diffInSeconds: ', diffInSeconds);
758
- log('secondsShouldLeft: ', secondsShouldLeft);
759
- log(
760
- 'this.lendingStatus.secondsLeftOnLoan: ',
761
- this.lendingStatus.secondsLeftOnLoan
762
- );
763
-
764
832
  // convert in minutes
765
833
  const whatIsleft = Math.round(timerSecondsLeft);
766
834
  const whatShouldLeft = Math.round(secondsShouldLeft);
767
835
  const timerElSeconds = this.timerCountdownEl.secondsLeftOnLoan || 0;
768
836
 
769
- log(`reSyncTimerIfGoneOff?`, {
770
- whatIsleft,
771
- whatShouldLeft,
772
- timerElSeconds,
773
- timeLeftInMin: Math.ceil(timerSecondsLeft / 60),
774
- });
775
-
776
837
  if (timerElSeconds !== whatShouldLeft || whatIsleft !== whatShouldLeft) {
777
838
  // set lending status with new time to update decrementor
778
839
  const currStatus = {
@@ -783,7 +844,9 @@ export default class IABookActions extends LitElement {
783
844
  }
784
845
 
785
846
  if (whatIsleft !== whatShouldLeft) {
786
- log(`reSyncTimerIfGoneOff --- let's re-sync.`);
847
+ log(
848
+ `[IABookActions] reSyncTimerIfGoneOff ${whatIsleft} - ${whatShouldLeft}: re-syncing timer`
849
+ );
787
850
  return { hasSynced: true, whatShouldLeft };
788
851
  }
789
852
 
@@ -799,10 +862,6 @@ export default class IABookActions extends LitElement {
799
862
  * @memberof IABookActions
800
863
  */
801
864
  async loanRenewAttempt(secondsLeft) {
802
- log('loanRenewAttempt ---', {
803
- secondsLeft,
804
- loanRenewResult: this.loanRenewResult,
805
- });
806
865
  let loanSecondsLeft = secondsLeft;
807
866
  /**
808
867
  * auto-renew is not possible in last seconds (let say 50 second) because,
@@ -812,7 +871,7 @@ export default class IABookActions extends LitElement {
812
871
  * so if seconds left is < 50, just expire the loan
813
872
  */
814
873
  if (loanSecondsLeft < 50) {
815
- log('loanRenewAttempt --- loanSecondsLeft < 50, will expire');
874
+ log('[IABookActions] loanRenewAttempt: < 50s left, expiring loan');
816
875
  await this.browseHasExpired();
817
876
  return;
818
877
  }
@@ -820,7 +879,9 @@ export default class IABookActions extends LitElement {
820
879
  await this.autoLoanRenewChecker(false);
821
880
 
822
881
  // show warning modal with remaining time to auto returned it.
823
- if (this.loanRenewResult.renewNow === false) {
882
+ // once the patron has dismissed it, don't re-show it on every subsequent
883
+ // tick — only an actual renewal (see handleLoanAutoRenewed) re-arms it.
884
+ if (this.loanRenewResult.renewNow === false && !this.warningModalDismissed) {
824
885
  /**
825
886
  * so compensate for the 50 second buffer to handle above race conditions
826
887
  * let's reduce 1 min from warning texts and early return the book when 1 min left.
@@ -888,42 +949,71 @@ export default class IABookActions extends LitElement {
888
949
  handleLendingActionError(event) {
889
950
  this.disableActionGroup = false;
890
951
 
891
- // clear all intervals for lending system when error occured
892
- window?.IALendingIntervals?.clearAll();
893
-
894
952
  const action = event?.detail?.action;
895
953
  const errorMsg = event?.detail?.data?.error;
896
954
 
897
- // template not show create_token errors
898
- if (errorMsg && action !== 'create_token')
899
- this.showErrorModal(errorMsg, action);
900
-
901
- // if error related to loan token
902
- // - set user_has_browsed to `false`
903
955
  if (action === 'create_token') {
904
- const currStatus = {
956
+ // A create_token hiccup only affects BookReader's page-image access
957
+ // token — it says nothing about how much time is left on the loan
958
+ // itself, so it must not stop the reading countdown. Only clear the
959
+ // token poller so it can be retried.
960
+ window?.IALendingIntervals?.clearTokenPoller();
961
+
962
+ // The loan itself is fine — just this token refresh failed — but
963
+ // the action bar must reflect that it isn't safely browsable right
964
+ // now rather than silently doing nothing.
965
+ this.lendingStatus = {
905
966
  ...this.lendingStatus,
906
967
  user_has_browsed: false,
907
968
  available_to_browse: true,
908
969
  };
909
- this.lendingStatus = currStatus;
910
- }
911
970
 
912
- // update action bar state if book is not available to browse or borrow.
913
- if (errorMsg && errorMsg.match(/not available to borrow/gm)) {
914
- let currStatus = this.lendingStatus;
915
- if (action === 'browse_book') {
916
- currStatus = {
917
- ...this.lendingStatus,
918
- available_to_browse: false,
919
- };
920
- } else if (action === 'borrow_book') {
921
- currStatus = {
922
- ...this.lendingStatus,
923
- available_to_borrow: false,
924
- };
971
+ // By the time this handler runs, LoanTokenPoller has already
972
+ // exhausted its retries for the transient datanode-propagation
973
+ // race (or the error wasn't that in the first place) — this is a
974
+ // genuine failure, so let the patron know instead of doing this
975
+ // silently. showErrorModal has dedicated create_token messaging
976
+ // (refresh button + support email).
977
+ if (errorMsg) this.showErrorModal(errorMsg, action);
978
+ } else if (action === 'renew_loan') {
979
+ window?.IALendingIntervals?.clearAll();
980
+ this.loanRenewInProgress = false;
981
+ this.recoveringFromLoanExpiry = false;
982
+
983
+ // The loan was NOT actually renewed — reflect that immediately
984
+ // (show Borrow, and clear the stale timer value) instead of leaving
985
+ // the action bar showing "Return now" with a frozen countdown from
986
+ // before the failed attempt, which falsely implies the book is
987
+ // still actively being read.
988
+ this.lendingStatus = {
989
+ ...this.lendingStatus,
990
+ user_has_browsed: false,
991
+ available_to_browse: true,
992
+ secondsLeftOnLoan: 0,
993
+ };
994
+
995
+ // Show the real error and refresh the page on dismissal (the
996
+ // modal's Okay button) so the client picks up whatever the server
997
+ // now authoritatively considers true — matching on "not available"
998
+ // to guess the reason was fragile since the server can return any
999
+ // message (e.g. a lending limit).
1000
+ this.showLoanUnavailableModal(errorMsg);
1001
+ } else {
1002
+ // Every other action failure genuinely affects loan state.
1003
+ window?.IALendingIntervals?.clearAll();
1004
+
1005
+ if (!errorMsg) return;
1006
+
1007
+ this.showErrorModal(errorMsg, action);
1008
+
1009
+ // update action bar state if book is not available to browse or borrow.
1010
+ if (errorMsg.match(/not available to borrow/gm)) {
1011
+ if (action === 'browse_book') {
1012
+ this.lendingStatus = { ...this.lendingStatus, available_to_browse: false };
1013
+ } else if (action === 'borrow_book') {
1014
+ this.lendingStatus = { ...this.lendingStatus, available_to_borrow: false };
1015
+ }
925
1016
  }
926
- this.lendingStatus = currStatus;
927
1017
  }
928
1018
  }
929
1019