@internetarchive/ia-book-actions 1.4.4-alpha1 → 1.4.4-alpha11
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.
- package/index.html +25 -6
- package/package.json +1 -1
- package/src/core/services/actions-handler/actions-handler-service.js +2 -2
- package/src/core/services/actions-handler/actions-handler.js +21 -16
- package/src/core/services/loan-token-poller.js +63 -16
- package/src/ia-book-actions.js +170 -55
- package/test/core/services/actions-handler.test.js +110 -1
- package/test/core/services/loan-token-poller.test.js +76 -0
- package/test/ia-book-actions.test.js +561 -0
package/index.html
CHANGED
|
@@ -97,6 +97,7 @@
|
|
|
97
97
|
border: 2px solid #194880;
|
|
98
98
|
border-radius: 10px;
|
|
99
99
|
background: white;
|
|
100
|
+
height: 50px;
|
|
100
101
|
}
|
|
101
102
|
.timer-presets button.active-preset {
|
|
102
103
|
background: #194880;
|
|
@@ -137,6 +138,9 @@
|
|
|
137
138
|
.loan-status-badge.status-active {
|
|
138
139
|
background: #2e7d32; /* green: loan is currently checked out */
|
|
139
140
|
}
|
|
141
|
+
.loan-status-badge.status-renewing {
|
|
142
|
+
background: #b8860b; /* amber: a renew_loan attempt is in flight */
|
|
143
|
+
}
|
|
140
144
|
.loan-status-badge.status-returned {
|
|
141
145
|
background: #d9534f; /* red: auto-returned / not on loan */
|
|
142
146
|
}
|
|
@@ -216,7 +220,7 @@
|
|
|
216
220
|
console.log = (...consoleArgs) => {
|
|
217
221
|
__nativeConsoleLog(...consoleArgs);
|
|
218
222
|
|
|
219
|
-
const loggedPrefixes = ['[
|
|
223
|
+
const loggedPrefixes = ['[IABookActions]', '[IABookActions]', '[Demo]'];
|
|
220
224
|
if (typeof consoleArgs[0] !== 'string' || !loggedPrefixes.some((prefix) => consoleArgs[0].startsWith(prefix))) {
|
|
221
225
|
return;
|
|
222
226
|
}
|
|
@@ -280,7 +284,7 @@
|
|
|
280
284
|
const timerPresets = {
|
|
281
285
|
'15s': { loanTotalTime: 15, loanRenewAtLast: 13, pageChangedInLast: 5 },
|
|
282
286
|
'30s': { loanTotalTime: 30, loanRenewAtLast: 27, pageChangedInLast: 10 },
|
|
283
|
-
'2': { loanTotalTime: 120, loanRenewAtLast:
|
|
287
|
+
'2': { loanTotalTime: 120, loanRenewAtLast: 95, pageChangedInLast: 15 },
|
|
284
288
|
'5': { loanTotalTime: 300, loanRenewAtLast: 240, pageChangedInLast: 60 },
|
|
285
289
|
'10': { loanTotalTime: 600, loanRenewAtLast: 480, pageChangedInLast: 120 },
|
|
286
290
|
'30': { loanTotalTime: 1800, loanRenewAtLast: 1500, pageChangedInLast: 600 },
|
|
@@ -439,6 +443,14 @@
|
|
|
439
443
|
await iaBookActions.updateComplete;
|
|
440
444
|
}
|
|
441
445
|
|
|
446
|
+
// showWarningModal() reads its message text from
|
|
447
|
+
// loanRenewHelper.getMessageTexts(loanRenewResult.texts, ...) — that
|
|
448
|
+
// helper only exists, and loanRenewResult.texts only gets populated,
|
|
449
|
+
// once autoLoanRenewChecker() has run at least once (this is what the
|
|
450
|
+
// real periodic timer calls before ever showing the modal). Run it
|
|
451
|
+
// here too so the button shows the same populated message a real
|
|
452
|
+
// warning would.
|
|
453
|
+
await iaBookActions.autoLoanRenewChecker(false);
|
|
442
454
|
iaBookActions.showWarningModal();
|
|
443
455
|
});
|
|
444
456
|
|
|
@@ -578,6 +590,13 @@
|
|
|
578
590
|
function computeLoanStatus() {
|
|
579
591
|
const lendingStatus = iaBookActions.lendingStatus || {};
|
|
580
592
|
|
|
593
|
+
// Checked first: autoRenewExpiredLoan() optimistically flips
|
|
594
|
+
// browsingExpired back to false the moment a retry starts (before the
|
|
595
|
+
// renew_loan call has actually landed), so without this check the
|
|
596
|
+
// badge would misleadingly jump straight to ACTIVE during the retry.
|
|
597
|
+
if (iaBookActions.loanRenewInProgress) {
|
|
598
|
+
return { badge: 'RENEWING', className: 'status-renewing' };
|
|
599
|
+
}
|
|
581
600
|
if (lendingStatus.browsingExpired) {
|
|
582
601
|
return { badge: 'RETURNED', className: 'status-returned' };
|
|
583
602
|
}
|
|
@@ -662,7 +681,10 @@
|
|
|
662
681
|
<button id="show_expired_modal">2. Auto-return due to inactivity<br>(no modal, page stays put)</button>
|
|
663
682
|
</td>
|
|
664
683
|
<td class="action-button">
|
|
665
|
-
<button class="pageChangedEvent">
|
|
684
|
+
<button class="pageChangedEvent">
|
|
685
|
+
3. Interact with book (turn page)<br>
|
|
686
|
+
silently renew while active or after auto-return
|
|
687
|
+
</button>
|
|
666
688
|
</td>
|
|
667
689
|
<td class="action-button">
|
|
668
690
|
<button id="resync_timer">4. Return to tab after time away<br>(visibilitychange — silent retry if expired)</button>
|
|
@@ -688,9 +710,6 @@
|
|
|
688
710
|
<td class="searchParam">
|
|
689
711
|
<label><input type="checkbox" name="search" value="Search Param" />Search Param</label>
|
|
690
712
|
</td>
|
|
691
|
-
<td>
|
|
692
|
-
<label class="ia-button"><button class="pageChangedEvent">Page Change Event</button></label>
|
|
693
|
-
</td>
|
|
694
713
|
</tr>
|
|
695
714
|
</table>
|
|
696
715
|
</fieldset>
|
package/package.json
CHANGED
|
@@ -84,10 +84,10 @@ export default async function ActionsHandlerService(options) {
|
|
|
84
84
|
.then(data => {
|
|
85
85
|
// `data` is the parsed version of the JSON returned from the above endpoint.
|
|
86
86
|
if (!data?.error) {
|
|
87
|
-
log(`[
|
|
87
|
+
log(`[IABookActions] ✓ ${option.action} succeeded`, data);
|
|
88
88
|
option?.success(data);
|
|
89
89
|
} else {
|
|
90
|
-
log(`[
|
|
90
|
+
log(`[IABookActions] ✗ ${option.action} failed`, data);
|
|
91
91
|
option?.error(data);
|
|
92
92
|
}
|
|
93
93
|
});
|
|
@@ -53,20 +53,8 @@ export default class ActionsHandler extends LitElement {
|
|
|
53
53
|
);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
this.addEventListener('autoRenew',
|
|
57
|
-
this.handleLoanRenewNow();
|
|
58
|
-
await this.loanAnanlytics?.storeLoanStatsCount(
|
|
59
|
-
this.identifier,
|
|
60
|
-
'autorenew'
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
const analyticsLabel = detail?.renewType === 'auto' ? analyticsLabels.browseAutoRenew : analyticsLabels.browseManualRenew;
|
|
64
|
-
this.loanAnanlytics?.sendEvent(
|
|
65
|
-
analyticsCategories.browse,
|
|
66
|
-
analyticsActions.browseRenew,
|
|
67
|
-
analyticsLabel,
|
|
68
|
-
this.identifier
|
|
69
|
-
);
|
|
56
|
+
this.addEventListener('autoRenew', ({ detail }) => {
|
|
57
|
+
this.handleLoanRenewNow(detail?.renewType);
|
|
70
58
|
});
|
|
71
59
|
|
|
72
60
|
this.addEventListener('autoReturn', async () => {
|
|
@@ -177,14 +165,14 @@ export default class ActionsHandler extends LitElement {
|
|
|
177
165
|
});
|
|
178
166
|
}
|
|
179
167
|
|
|
180
|
-
handleLoanRenewNow() {
|
|
168
|
+
handleLoanRenewNow(renewType) {
|
|
181
169
|
const action = 'renew_loan';
|
|
182
170
|
|
|
183
171
|
ActionsHandlerService({
|
|
184
172
|
action,
|
|
185
173
|
identifier: this.identifier,
|
|
186
174
|
success: async data => {
|
|
187
|
-
log('RENEW_LOAN --- ', data,
|
|
175
|
+
log('RENEW_LOAN --- ', data, this.identifier);
|
|
188
176
|
const activeLoan = data.loan ? data.loan : undefined;
|
|
189
177
|
const isRenewal = activeLoan.renewal;
|
|
190
178
|
|
|
@@ -192,6 +180,23 @@ export default class ActionsHandler extends LitElement {
|
|
|
192
180
|
// Await — loanAutoRenewed listeners read this same cache key
|
|
193
181
|
// immediately; dispatching before the write lands was a race.
|
|
194
182
|
await this.setBrowseTimeSession();
|
|
183
|
+
|
|
184
|
+
// Only record the renew analytics event once the renewal has
|
|
185
|
+
// actually succeeded — not merely attempted.
|
|
186
|
+
await this.loanAnanlytics?.storeLoanStatsCount(
|
|
187
|
+
this.identifier,
|
|
188
|
+
'autorenew'
|
|
189
|
+
);
|
|
190
|
+
const analyticsLabel =
|
|
191
|
+
renewType === 'auto'
|
|
192
|
+
? analyticsLabels.browseAutoRenew
|
|
193
|
+
: analyticsLabels.browseManualRenew;
|
|
194
|
+
this.loanAnanlytics?.sendEvent(
|
|
195
|
+
analyticsCategories.browse,
|
|
196
|
+
analyticsActions.browseRenew,
|
|
197
|
+
analyticsLabel,
|
|
198
|
+
this.identifier
|
|
199
|
+
);
|
|
195
200
|
} else {
|
|
196
201
|
log('RENEW_LOAN ERROR --- ', {
|
|
197
202
|
action,
|
|
@@ -18,6 +18,18 @@ export class LoanTokenPoller {
|
|
|
18
18
|
|
|
19
19
|
this.loanTokenInterval = undefined;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Lending::do_renew() (backend) deletes the old loan record and
|
|
23
|
+
* writes a brand-new one on every renewal — this poller's initial
|
|
24
|
+
* create_token call (fired right after a renewal re-establishes the
|
|
25
|
+
* reading session) can race that write's propagation and come back
|
|
26
|
+
* with "you do not currently have this book borrowed" even though
|
|
27
|
+
* the loan is genuinely valid. Retry a few times with backoff instead
|
|
28
|
+
* of giving up on the first attempt.
|
|
29
|
+
*/
|
|
30
|
+
this.maxInitialTokenRetries = 3;
|
|
31
|
+
this.initialTokenRetryDelay = 1000; // ms, multiplied by attempt number
|
|
32
|
+
|
|
21
33
|
/**
|
|
22
34
|
* loan analytics instance
|
|
23
35
|
* @see loan-analytics.js
|
|
@@ -58,29 +70,64 @@ export class LoanTokenPoller {
|
|
|
58
70
|
}
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
|
|
73
|
+
/**
|
|
74
|
+
* @param {boolean} isInitial - the first create_token call right after
|
|
75
|
+
* bookAccessed()/a renewal, as opposed to a routine interval tick.
|
|
76
|
+
* @param {number} retryCount - internal, counts retries of the initial call.
|
|
77
|
+
*/
|
|
78
|
+
async handleLoanTokenPoller(isInitial = false, retryCount = 0) {
|
|
62
79
|
const action = 'create_token';
|
|
63
80
|
ActionsHandlerService({
|
|
64
81
|
identifier: this.identifier,
|
|
65
82
|
action,
|
|
66
|
-
error: data =>
|
|
67
|
-
this.errorCallback({ detail: { action, data } });
|
|
68
|
-
|
|
69
|
-
// send error to Sentry
|
|
70
|
-
window?.Sentry?.captureMessage(
|
|
71
|
-
`${sentryLogs.handleLoanTokenPoller} - Error: ${JSON.stringify(data)}`
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
// send LendingServiceError to GA
|
|
75
|
-
this.loanAnalytics?.sendEvent(
|
|
76
|
-
'LendingServiceLoanError',
|
|
77
|
-
action,
|
|
78
|
-
this.identifier
|
|
79
|
-
);
|
|
80
|
-
},
|
|
83
|
+
error: data => this.handleTokenError(data, isInitial, retryCount),
|
|
81
84
|
success: () => {
|
|
82
85
|
if (isInitial) this.successCallback();
|
|
83
86
|
},
|
|
84
87
|
});
|
|
85
88
|
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Decide whether a create_token failure should be retried (a stale
|
|
92
|
+
* read of the loan record right after a renewal, see the constructor
|
|
93
|
+
* comment) or reported via errorCallback. Split out from
|
|
94
|
+
* handleLoanTokenPoller so it's directly testable without needing to
|
|
95
|
+
* mock the network call.
|
|
96
|
+
*
|
|
97
|
+
* @param {Object} data - the error payload from ActionsHandlerService.
|
|
98
|
+
* @param {boolean} isInitial
|
|
99
|
+
* @param {number} retryCount
|
|
100
|
+
*/
|
|
101
|
+
handleTokenError(data, isInitial, retryCount) {
|
|
102
|
+
const action = 'create_token';
|
|
103
|
+
const isStaleLoanReadError =
|
|
104
|
+
typeof data?.error === 'string' &&
|
|
105
|
+
/do not currently have this book borrowed/i.test(data.error);
|
|
106
|
+
|
|
107
|
+
if (
|
|
108
|
+
isInitial &&
|
|
109
|
+
isStaleLoanReadError &&
|
|
110
|
+
retryCount < this.maxInitialTokenRetries
|
|
111
|
+
) {
|
|
112
|
+
const delay = this.initialTokenRetryDelay * (retryCount + 1);
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
this.handleLoanTokenPoller(true, retryCount + 1);
|
|
115
|
+
}, delay);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.errorCallback({ detail: { action, data } });
|
|
120
|
+
|
|
121
|
+
// send error to Sentry
|
|
122
|
+
window?.Sentry?.captureMessage(
|
|
123
|
+
`${sentryLogs.handleLoanTokenPoller} - Error: ${JSON.stringify(data)}`
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// send LendingServiceError to GA
|
|
127
|
+
this.loanAnalytics?.sendEvent(
|
|
128
|
+
'LendingServiceLoanError',
|
|
129
|
+
action,
|
|
130
|
+
this.identifier
|
|
131
|
+
);
|
|
132
|
+
}
|
|
86
133
|
}
|
package/src/ia-book-actions.js
CHANGED
|
@@ -61,6 +61,7 @@ export default class IABookActions extends LitElement {
|
|
|
61
61
|
modal: { Object },
|
|
62
62
|
tokenDelay: { type: Number },
|
|
63
63
|
timerExecutionSeconds: { type: Number },
|
|
64
|
+
userActionGracePeriod: { type: Number },
|
|
64
65
|
localCache: { type: Object },
|
|
65
66
|
loanRenewTimeConfig: { type: Object },
|
|
66
67
|
loanRenewResult: { type: Object },
|
|
@@ -83,6 +84,14 @@ export default class IABookActions extends LitElement {
|
|
|
83
84
|
this.tokenDelay = 120; // in seconds
|
|
84
85
|
this.timerExecutionSeconds = 30;
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* How long (ms) after bindLoanRenewEvents() runs to ignore
|
|
89
|
+
* BookReader:userAction events — BookReader fires that event on its
|
|
90
|
+
* own init-time jump to the last-read page, not just on genuine
|
|
91
|
+
* interaction. @see bindLoanRenewEvents
|
|
92
|
+
*/
|
|
93
|
+
this.userActionGracePeriod = 2000;
|
|
94
|
+
|
|
86
95
|
// private props
|
|
87
96
|
this.postInitComplete = false;
|
|
88
97
|
this.primaryActions = [];
|
|
@@ -96,6 +105,15 @@ export default class IABookActions extends LitElement {
|
|
|
96
105
|
|
|
97
106
|
this.loanRenewInProgress = false;
|
|
98
107
|
|
|
108
|
+
/**
|
|
109
|
+
* True while a renewal in flight is recovering from a loan that
|
|
110
|
+
* genuinely lapsed (set by autoRenewExpiredLoan), as opposed to a
|
|
111
|
+
* routine pre-expiry top-up. Only the recovery case needs BookReader
|
|
112
|
+
* re-initialized once the renewal is confirmed — see
|
|
113
|
+
* handleLoanAutoRenewed.
|
|
114
|
+
*/
|
|
115
|
+
this.recoveringFromLoanExpiry = false;
|
|
116
|
+
|
|
99
117
|
this.warningModalOpen = false;
|
|
100
118
|
|
|
101
119
|
/**
|
|
@@ -180,6 +198,13 @@ export default class IABookActions extends LitElement {
|
|
|
180
198
|
}
|
|
181
199
|
|
|
182
200
|
if (changed.has('loanRenewResult') && this.loanRenewResult.renewNow) {
|
|
201
|
+
// Any renewal — not just the auto-return recovery path — pauses the
|
|
202
|
+
// countdown for the renew_loan round-trip. Flag it here, centrally,
|
|
203
|
+
// so every path (interaction during active reading, periodic
|
|
204
|
+
// checker, auto-return recovery) gets the same "renewing" signal
|
|
205
|
+
// instead of just looking frozen until handleLoanAutoRenewed() clears
|
|
206
|
+
// this and restarts the timer.
|
|
207
|
+
this.loanRenewInProgress = true;
|
|
183
208
|
window.IALendingIntervals.clearAll();
|
|
184
209
|
}
|
|
185
210
|
}
|
|
@@ -267,7 +292,14 @@ export default class IABookActions extends LitElement {
|
|
|
267
292
|
|
|
268
293
|
this.borrowType = actions.borrowType ? actions.borrowType : null;
|
|
269
294
|
|
|
270
|
-
|
|
295
|
+
// Don't (re)start the countdown while a renewal is in flight — the
|
|
296
|
+
// optimistic browsingExpired flip in autoRenewExpiredLoan() (which
|
|
297
|
+
// triggers this same setupLendingToolbarActions() call) happens before
|
|
298
|
+
// renewal is confirmed, so secondsLeftOnLoan could still be a stale
|
|
299
|
+
// value from before the renewal. Wait for handleLoanAutoRenewed() to
|
|
300
|
+
// confirm the real value and clear loanRenewInProgress before showing
|
|
301
|
+
// a countdown again.
|
|
302
|
+
if (this.borrowType === 'browsed' && !this.loanRenewInProgress) {
|
|
271
303
|
// start timer for loan-renew
|
|
272
304
|
await this.startTimerCountdown();
|
|
273
305
|
|
|
@@ -283,11 +315,22 @@ export default class IABookActions extends LitElement {
|
|
|
283
315
|
|
|
284
316
|
/**
|
|
285
317
|
* tokenPoller determines if user has loan token for this book
|
|
286
|
-
* - if book is going to renew, need to wait until renew is completed
|
|
318
|
+
* - if book is going to renew, need to wait until renew is completed:
|
|
319
|
+
* loanRenewInProgress is set before renew_loan is even dispatched
|
|
320
|
+
* (including the optimistic pre-confirmation window) and only
|
|
321
|
+
* clears once the renewal is confirmed, so starting the poller here
|
|
322
|
+
* mid-renewal would call create_token against the loan record
|
|
323
|
+
* before it's actually renewed.
|
|
287
324
|
*/
|
|
288
325
|
setTimeout(() => {
|
|
289
|
-
if (
|
|
326
|
+
if (
|
|
327
|
+
!hasExpired &&
|
|
328
|
+
!this.loanRenewInProgress &&
|
|
329
|
+
!window.IALendingIntervals.tokenPoller
|
|
330
|
+
) {
|
|
331
|
+
this.recoveringFromLoanExpiry = false;
|
|
290
332
|
this.startLoanTokenPoller();
|
|
333
|
+
}
|
|
291
334
|
}, 100);
|
|
292
335
|
|
|
293
336
|
this.requestUpdate();
|
|
@@ -300,21 +343,40 @@ export default class IABookActions extends LitElement {
|
|
|
300
343
|
* 2. IABookActions:loanRenew - dispatched from timer-countdown component
|
|
301
344
|
*/
|
|
302
345
|
bindLoanRenewEvents() {
|
|
346
|
+
this.userActionReadyAt = Date.now() + this.userActionGracePeriod;
|
|
347
|
+
|
|
303
348
|
/**
|
|
304
349
|
* dispatched this event from bookreader page changed
|
|
305
350
|
*/
|
|
306
351
|
window.addEventListener('BookReader:userAction', () => {
|
|
352
|
+
if (Date.now() < this.userActionReadyAt) {
|
|
353
|
+
log(
|
|
354
|
+
'[IABookActions] BookReader:userAction ignored — within startup grace period'
|
|
355
|
+
);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Capture before autoRenewExpiredLoan() runs — it synchronously flips
|
|
360
|
+
// lendingStatus.browsingExpired to false as an optimistic UI update,
|
|
361
|
+
// so checking the live property afterward would always see it as
|
|
362
|
+
// false and wrongly let autoLoanRenewChecker() run too. That second
|
|
363
|
+
// call would then overwrite loanRenewResult.renewNow (set true by
|
|
364
|
+
// autoRenewExpiredLoan) back to false, since the loanTime cache entry
|
|
365
|
+
// was already deleted by browseHasExpired() — silently killing the
|
|
366
|
+
// in-flight renewal and leaving the countdown stuck.
|
|
367
|
+
const wasExpired = this.lendingStatus.browsingExpired;
|
|
368
|
+
|
|
307
369
|
log('[IABookActions] BookReader:userAction received', {
|
|
308
370
|
borrowType: this.borrowType,
|
|
309
|
-
browsingExpired:
|
|
371
|
+
browsingExpired: wasExpired,
|
|
310
372
|
});
|
|
311
373
|
|
|
312
374
|
// If the loan expired while the tab stayed visible, auto-renew on page turn
|
|
313
|
-
if (
|
|
375
|
+
if (wasExpired) {
|
|
314
376
|
this.autoRenewExpiredLoan();
|
|
315
377
|
}
|
|
316
378
|
|
|
317
|
-
if (this.borrowType === 'browsed' && !
|
|
379
|
+
if (this.borrowType === 'browsed' && !wasExpired) {
|
|
318
380
|
this.autoLoanRenewChecker(true);
|
|
319
381
|
}
|
|
320
382
|
});
|
|
@@ -372,6 +434,16 @@ export default class IABookActions extends LitElement {
|
|
|
372
434
|
* @param {Boolean} hasPageChanged
|
|
373
435
|
*/
|
|
374
436
|
async autoLoanRenewChecker(hasPageChanged = false) {
|
|
437
|
+
// Guards against re-entrancy the same way autoRenewExpiredLoan() does.
|
|
438
|
+
// BookReader:userAction can fire several times in quick succession
|
|
439
|
+
// (e.g. a single scroll gesture), and this method has no debouncing of
|
|
440
|
+
// its own — without this guard, each event would spin up its own
|
|
441
|
+
// LoanRenewHelper concurrently, and whichever's async localCache reads
|
|
442
|
+
// resolve last would clobber this.loanRenewResult, potentially
|
|
443
|
+
// re-triggering the whole renew_loan/create_token flow while a
|
|
444
|
+
// renewal from an earlier event is already in flight or just landed.
|
|
445
|
+
if (this.loanRenewInProgress) return;
|
|
446
|
+
|
|
375
447
|
this.loanRenewHelper = new LoanRenewHelper(
|
|
376
448
|
hasPageChanged,
|
|
377
449
|
this.identifier,
|
|
@@ -395,16 +467,23 @@ export default class IABookActions extends LitElement {
|
|
|
395
467
|
autoRenewExpiredLoan() {
|
|
396
468
|
if (this.loanRenewInProgress) return;
|
|
397
469
|
this.loanRenewInProgress = true;
|
|
470
|
+
this.recoveringFromLoanExpiry = true;
|
|
398
471
|
|
|
399
472
|
this.modal?.closeModal();
|
|
400
473
|
|
|
401
474
|
// Optimistically flip browsingExpired back to false so the action bar
|
|
402
475
|
// stays red (patronIsReadingAction) while the renew_loan request is in
|
|
403
|
-
// flight, instead of showing the blue "Borrow" state.
|
|
476
|
+
// flight, instead of showing the blue "Borrow" state. Deliberately NOT
|
|
477
|
+
// resetting secondsLeftOnLoan here — the renewal hasn't been confirmed
|
|
478
|
+
// yet (it can take several seconds, or genuinely fail), so showing a
|
|
479
|
+
// full hour before we know the outcome would be misleading. The real
|
|
480
|
+
// value is set once handleLoanAutoRenewed() confirms success;
|
|
481
|
+
// loanRenewInProgress (already true above) is the signal consumers
|
|
482
|
+
// should use to show a "renewing" state instead of trusting the
|
|
483
|
+
// countdown during this window.
|
|
404
484
|
this.lendingStatus = {
|
|
405
485
|
...this.lendingStatus,
|
|
406
486
|
browsingExpired: false,
|
|
407
|
-
secondsLeftOnLoan: this.loanRenewTimeConfig.loanTotalTime,
|
|
408
487
|
};
|
|
409
488
|
|
|
410
489
|
// Defer renewNow until after lendingStatus timer setup completes.
|
|
@@ -457,19 +536,14 @@ export default class IABookActions extends LitElement {
|
|
|
457
536
|
headerColor: '#194880',
|
|
458
537
|
showCloseButton: false,
|
|
459
538
|
closeOnBackdropClick: false,
|
|
460
|
-
message: html`<span
|
|
461
|
-
|
|
462
|
-
>
|
|
463
|
-
<span>${this.loanRenewHelper?.getMessageTexts(
|
|
464
|
-
warningTexts,
|
|
465
|
-
secondsLeft
|
|
466
|
-
)}</span>
|
|
539
|
+
message: html`<span>
|
|
540
|
+
${this.loanRenewHelper?.getMessageTexts(warningTexts, secondsLeft)}
|
|
467
541
|
<a
|
|
468
542
|
href="https://help.archive.org/help/borrowing-from-the-lending-library"
|
|
469
543
|
target="_blank"
|
|
470
544
|
title="Get more info on borrowing from The Lending Library"
|
|
471
545
|
data-event-click-tracking="BookReader|BrowsableMoreInfo"
|
|
472
|
-
style="display:inline-
|
|
546
|
+
style="display:inline-block;vertical-align:middle;line-height:0;margin-left:4px;"
|
|
473
547
|
>
|
|
474
548
|
${infoIcon}
|
|
475
549
|
</a>
|
|
@@ -532,20 +606,21 @@ export default class IABookActions extends LitElement {
|
|
|
532
606
|
* Show modal when the book can no longer be automatically renewed
|
|
533
607
|
* because another patron has checked it out.
|
|
534
608
|
*/
|
|
535
|
-
async showLoanUnavailableModal() {
|
|
609
|
+
async showLoanUnavailableModal(errorMsg) {
|
|
536
610
|
const config = new ModalConfig({
|
|
537
611
|
headline: '',
|
|
538
612
|
showCloseButton: false,
|
|
539
613
|
closeOnBackdropClick: false,
|
|
540
|
-
headerColor: '#
|
|
614
|
+
headerColor: '#d9534f',
|
|
541
615
|
message:
|
|
616
|
+
errorMsg ||
|
|
542
617
|
'Due to inactivity, this book was returned, and someone else has now borrowed it. Please try again later.',
|
|
543
618
|
});
|
|
544
619
|
|
|
545
620
|
const customModalContent = html`<br />
|
|
546
621
|
<div style="text-align: center">
|
|
547
622
|
<button
|
|
548
|
-
style="${modalButtonStyle.iaButton} ${modalButtonStyle.
|
|
623
|
+
style="${modalButtonStyle.iaButton} ${modalButtonStyle.return}"
|
|
549
624
|
@click=${() => URLHelper.goToUrl(this.returnUrl, true)}
|
|
550
625
|
>
|
|
551
626
|
Okay
|
|
@@ -639,6 +714,7 @@ export default class IABookActions extends LitElement {
|
|
|
639
714
|
const activeLoan = detail?.data?.loan;
|
|
640
715
|
const errorMessage = `Whoops, seems we hit a hiccup with renewing this book. Please refresh & retry. --- (Debug: ${detail?.data?.error})`;
|
|
641
716
|
if (!activeLoan) {
|
|
717
|
+
this.recoveringFromLoanExpiry = false;
|
|
642
718
|
this.showErrorModal(errorMessage, 'handleLoanAutoRenewed');
|
|
643
719
|
return;
|
|
644
720
|
}
|
|
@@ -661,6 +737,23 @@ export default class IABookActions extends LitElement {
|
|
|
661
737
|
ajaxResponse: detail?.data,
|
|
662
738
|
});
|
|
663
739
|
|
|
740
|
+
if (this.recoveringFromLoanExpiry) {
|
|
741
|
+
// The loan genuinely lapsed, and renew_loan has now CONFIRMED it's
|
|
742
|
+
// renewed server-side — only now is it safe to let BookReader
|
|
743
|
+
// re-initialize (create_token was minting its access cookie off
|
|
744
|
+
// the loan's expiry; doing this before confirmation risked reading
|
|
745
|
+
// the still-expired record). loanRenewInProgress already kept the
|
|
746
|
+
// token poller from restarting early, so it'll pick up fresh here
|
|
747
|
+
// once lendingStatus below re-triggers setupLendingToolbarActions().
|
|
748
|
+
// recoveringFromLoanExpiry itself is consumed/reset there, not
|
|
749
|
+
// here — this ONLY applies to an actual recovery, not a routine
|
|
750
|
+
// renewal (re-running br.init() on every routine top-up would be
|
|
751
|
+
// needlessly disruptive). create_token's own retry logic (see
|
|
752
|
+
// LoanTokenPoller) handles the datanode write-propagation race
|
|
753
|
+
// for both recovery and routine renewals.
|
|
754
|
+
this.postInitComplete = false;
|
|
755
|
+
}
|
|
756
|
+
|
|
664
757
|
const currStatus = {
|
|
665
758
|
...this.lendingStatus,
|
|
666
759
|
user_has_browsed: true,
|
|
@@ -718,14 +811,15 @@ export default class IABookActions extends LitElement {
|
|
|
718
811
|
|
|
719
812
|
if (resyncd.hasSynced) {
|
|
720
813
|
secondsLeft = resyncd.whatShouldLeft;
|
|
721
|
-
log('[IABookActions]
|
|
814
|
+
log('[IABookActions] timer: timer re-synced', {
|
|
722
815
|
secondsLeft,
|
|
723
816
|
});
|
|
724
817
|
}
|
|
725
818
|
|
|
726
|
-
log('[IABookActions]
|
|
819
|
+
log('[IABookActions] timer', {
|
|
727
820
|
secondsLeft,
|
|
728
821
|
loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
|
|
822
|
+
pageChangedInLast: this.loanRenewTimeConfig.pageChangedInLast,
|
|
729
823
|
});
|
|
730
824
|
|
|
731
825
|
/**
|
|
@@ -881,50 +975,71 @@ export default class IABookActions extends LitElement {
|
|
|
881
975
|
handleLendingActionError(event) {
|
|
882
976
|
this.disableActionGroup = false;
|
|
883
977
|
|
|
884
|
-
// clear all intervals for lending system when error occured
|
|
885
|
-
window?.IALendingIntervals?.clearAll();
|
|
886
|
-
|
|
887
978
|
const action = event?.detail?.action;
|
|
888
979
|
const errorMsg = event?.detail?.data?.error;
|
|
889
980
|
|
|
890
|
-
// For renew_loan failures after auto-renew, show the patron-facing message
|
|
891
|
-
if (
|
|
892
|
-
errorMsg &&
|
|
893
|
-
action === 'renew_loan' &&
|
|
894
|
-
errorMsg.match(/not available/)
|
|
895
|
-
) {
|
|
896
|
-
this.loanRenewInProgress = false;
|
|
897
|
-
this.showLoanUnavailableModal();
|
|
898
|
-
} else if (errorMsg && action !== 'create_token') {
|
|
899
|
-
this.showErrorModal(errorMsg, action);
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// if error related to loan token
|
|
903
|
-
// - set user_has_browsed to `false`
|
|
904
981
|
if (action === 'create_token') {
|
|
905
|
-
|
|
982
|
+
// A create_token hiccup only affects BookReader's page-image access
|
|
983
|
+
// token — it says nothing about how much time is left on the loan
|
|
984
|
+
// itself, so it must not stop the reading countdown. Only clear the
|
|
985
|
+
// token poller so it can be retried.
|
|
986
|
+
window?.IALendingIntervals?.clearTokenPoller();
|
|
987
|
+
|
|
988
|
+
// The loan itself is fine — just this token refresh failed — but
|
|
989
|
+
// the action bar must reflect that it isn't safely browsable right
|
|
990
|
+
// now rather than silently doing nothing.
|
|
991
|
+
this.lendingStatus = {
|
|
906
992
|
...this.lendingStatus,
|
|
907
993
|
user_has_browsed: false,
|
|
908
994
|
available_to_browse: true,
|
|
909
995
|
};
|
|
910
|
-
this.lendingStatus = currStatus;
|
|
911
|
-
}
|
|
912
996
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
997
|
+
// By the time this handler runs, LoanTokenPoller has already
|
|
998
|
+
// exhausted its retries for the transient datanode-propagation
|
|
999
|
+
// race (or the error wasn't that in the first place) — this is a
|
|
1000
|
+
// genuine failure, so let the patron know instead of doing this
|
|
1001
|
+
// silently. showErrorModal has dedicated create_token messaging
|
|
1002
|
+
// (refresh button + support email).
|
|
1003
|
+
if (errorMsg) this.showErrorModal(errorMsg, action);
|
|
1004
|
+
} else if (action === 'renew_loan') {
|
|
1005
|
+
window?.IALendingIntervals?.clearAll();
|
|
1006
|
+
this.loanRenewInProgress = false;
|
|
1007
|
+
this.recoveringFromLoanExpiry = false;
|
|
1008
|
+
|
|
1009
|
+
// The loan was NOT actually renewed — reflect that immediately
|
|
1010
|
+
// (show Borrow, and clear the stale timer value) instead of leaving
|
|
1011
|
+
// the action bar showing "Return now" with a frozen countdown from
|
|
1012
|
+
// before the failed attempt, which falsely implies the book is
|
|
1013
|
+
// still actively being read.
|
|
1014
|
+
this.lendingStatus = {
|
|
1015
|
+
...this.lendingStatus,
|
|
1016
|
+
user_has_browsed: false,
|
|
1017
|
+
available_to_browse: true,
|
|
1018
|
+
secondsLeftOnLoan: 0,
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
// Show the real error and refresh the page on dismissal (the
|
|
1022
|
+
// modal's Okay button) so the client picks up whatever the server
|
|
1023
|
+
// now authoritatively considers true — matching on "not available"
|
|
1024
|
+
// to guess the reason was fragile since the server can return any
|
|
1025
|
+
// message (e.g. a lending limit).
|
|
1026
|
+
this.showLoanUnavailableModal(errorMsg);
|
|
1027
|
+
} else {
|
|
1028
|
+
// Every other action failure genuinely affects loan state.
|
|
1029
|
+
window?.IALendingIntervals?.clearAll();
|
|
1030
|
+
|
|
1031
|
+
if (!errorMsg) return;
|
|
1032
|
+
|
|
1033
|
+
this.showErrorModal(errorMsg, action);
|
|
1034
|
+
|
|
1035
|
+
// update action bar state if book is not available to browse or borrow.
|
|
1036
|
+
if (errorMsg.match(/not available to borrow/gm)) {
|
|
1037
|
+
if (action === 'browse_book') {
|
|
1038
|
+
this.lendingStatus = { ...this.lendingStatus, available_to_browse: false };
|
|
1039
|
+
} else if (action === 'borrow_book') {
|
|
1040
|
+
this.lendingStatus = { ...this.lendingStatus, available_to_borrow: false };
|
|
1041
|
+
}
|
|
926
1042
|
}
|
|
927
|
-
this.lendingStatus = currStatus;
|
|
928
1043
|
}
|
|
929
1044
|
}
|
|
930
1045
|
|