File size: 43,546 Bytes
f14ef69
 
 
 
 
e79873b
7badcff
8fb90d0
afd13cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f5ba40
afd13cb
135c452
e79873b
f14ef69
 
 
 
afd13cb
f14ef69
 
 
 
 
 
 
 
 
 
135c452
c298f95
f14ef69
 
afd13cb
f14ef69
e79873b
7a278ee
f14ef69
7a278ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135c452
 
7a278ee
 
 
 
 
 
 
 
 
c298f95
7a278ee
c298f95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135c452
 
 
 
 
 
 
 
c298f95
 
 
 
 
 
 
 
 
 
 
 
 
 
7a278ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c298f95
 
7a278ee
 
 
 
 
 
 
 
 
 
e79873b
 
f14ef69
 
 
 
e79873b
f14ef69
 
e79873b
 
 
 
f14ef69
e79873b
7a278ee
f14ef69
 
 
e79873b
f14ef69
 
 
6f5ba40
f14ef69
 
6f5ba40
f14ef69
6f5ba40
f14ef69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f5ba40
afd13cb
 
135c452
afd13cb
f14ef69
7badcff
f14ef69
 
6f5ba40
7badcff
135c452
f14ef69
135c452
 
 
f14ef69
e79873b
135c452
e79873b
135c452
e79873b
135c452
 
 
 
 
 
 
f14ef69
 
6f5ba40
 
 
 
7badcff
 
6f5ba40
 
 
7badcff
 
f14ef69
 
 
 
 
 
 
 
 
 
6f5ba40
8fb90d0
6f5ba40
7badcff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6f5ba40
 
 
 
 
7badcff
6f5ba40
 
 
 
 
 
afd13cb
 
 
 
 
 
 
 
8fb90d0
6f5ba40
 
afd13cb
 
bc8af21
6f5ba40
 
 
bc8af21
6f5ba40
afd13cb
6f5ba40
 
 
afd13cb
 
 
 
6f5ba40
 
 
f14ef69
e79873b
 
 
 
 
 
 
 
 
 
 
f14ef69
c298f95
f14ef69
e79873b
 
 
 
 
f14ef69
af5d4de
e79873b
 
 
af5d4de
e79873b
 
 
 
 
8fb90d0
e79873b
 
af5d4de
f14ef69
af5d4de
e79873b
af5d4de
 
e79873b
 
f14ef69
e79873b
 
f14ef69
e79873b
 
 
 
af5d4de
e79873b
 
af5d4de
 
 
 
 
 
 
 
 
 
 
 
 
f14ef69
e79873b
f14ef69
 
 
 
 
af5d4de
f14ef69
 
 
af5d4de
 
 
 
 
 
 
7a278ee
af5d4de
f14ef69
 
 
e79873b
f14ef69
 
 
 
 
 
 
 
 
 
e79873b
 
af5d4de
e79873b
 
 
 
 
f14ef69
 
 
 
e79873b
 
f14ef69
 
 
 
e79873b
 
 
 
135c452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f14ef69
 
 
7a278ee
f14ef69
 
 
7badcff
f14ef69
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
/* Same-account handwritten answers. Every async continuation checks its account
   and view before changing the UI. The server is authoritative for all states. */
(()=>{
const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const fmt=s=>esc(s).replace(/\$([^$]+)\$/g,'<span class="m">$1</span>');
const states={waiting:"ממתינים לטלפון",confirmed:"ממתינים לטלפון",uploading:"מעלים את התשובה…",received:"התשובה התקבלה",failed:"ההעלאה נכשלה",expired:"בקשת הצילום פגה",superseded:"הבקשה הוחלפה",declined:"הבקשה נדחתה בטלפון"};
const errors={expired:"בקשת הצילום פגה. צרו בקשה חדשה ונסו שוב.",superseded:"הבקשה הוחלפה. בחרו את הבקשה העדכנית.",upload_in_progress:"העלאה כבר מתבצעת. ממתינים לעדכון…",confirmation_required:"יש לאשר את בקשת הצילום תחילה.",invalid_upload:"ההעלאה הופסקה. נסו שוב.",question_changed:"השאלה השתנתה. רעננו את העמוד ופתחו אותה מחדש.",invalid_image:"לא ניתן לקרוא את התמונה. צלמו שוב.",image_too_large:"התמונה גדולה מדי. צלמו שוב.",unsupported_image:"בחרו תמונת JPEG, PNG או WebP.",answer_storage_unavailable:"שמירת תשובות אינה זמינה כרגע. נסו שוב מאוחר יותר.",sign_in_required:"יש להיכנס לאותו חשבון בשני המכשירים.",review_limit:"יש כבר 5 בקשות פתוחות למורה. אפשר לבקש שוב אחרי שהמורה יענה.",message_too_long:"ההודעה ארוכה מדי (עד 300 תווים).",invalid_message:"ההודעה אינה תקינה.",answer_required:"צריך לצלם את התשובה קודם.",review_unavailable:"הבקשה למורה אינה זמינה כרגע. נסו שוב מאוחר יותר."};
Object.assign(errors,{grading_unavailable:"בדיקת תשובות אינה זמינה כרגע באתר.",grading_failed:"הבדיקה נכשלה. התמונה נשמרה; אפשר לנסות שוב.",grading_interrupted:"הבדיקה הופסקה. אפשר לנסות שוב.",grading_limit:"הגעתם למכסת 20 הבדיקות היומית. נסו שוב מחר.",grading_busy:"כבר מתבצעות בדיקות בחשבון. נסו שוב בעוד רגע.",answer_required:"יש לצלם ולשלוח תשובה לפני הבדיקה.",queue_unavailable:"שליחת תשובות לבדיקת המורה אינה זמינה כרגע.",request_taken:"המורה כבר טיפל בבקשה. רעננו את העמוד.",teacher_ai_limit:"הגעתם למכסה של 3 בקשות AI ביום. אפשר לבדוק שוב מחר."});
/* Why an AI check failed (the server's `reason`, app/math_photo.py: failure_reason), for the people who can fix it:
   a teacher sending from the AI queue, or checking their own answer. Each is what happened and what to do. */
const reasons={
  missing_key:["חסר מפתח API בשרת","משתנה הסביבה ANTHROPIC_API_KEY לא מוגדר בשרת. הוסיפו אותו להגדרות השרת (Secrets או קובץ .env) והפעילו את השרת מחדש."],
  api_key_invalid:["מפתח ה-API לא תקין","Anthropic דחתה את המפתח (401). בדקו שהערך של ANTHROPIC_API_KEY הועתק במלואו, ושהמפתח לא בוטל ב-Anthropic Console."],
  permission:["אין הרשאה","למפתח ה-API אין הרשאה למודל או לפעולה הזו (403). בדקו את הרשאות המפתח וה-Workspace ב-Anthropic Console."],
  model_not_found:["המודל לא נמצא","המודל שמוגדר בשרת (MATH_MODEL) לא קיים או לא זמין למפתח הזה (404). בדקו את שם המודל, או מחקו את MATH_MODEL כדי לחזור לברירת המחדל."],
  no_credit:["נגמר הקרדיט","אין מספיק קרדיט בחשבון Anthropic. הוסיפו קרדיט ב-Anthropic Console, בעמוד Billing."],
  rate_limited:["יותר מדי בקשות","הגעתם למגבלת הקצב של ה-API (429). חכו דקה ונסו שוב."],
  overloaded:["שירות ה-AI עמוס","השרתים של Anthropic עמוסים כרגע. נסו שוב בעוד כמה דקות."],
  no_connection:["אין חיבור ל-AI","השרת לא הצליח להתחבר ל-API של Anthropic (רשת או זמן המתנה ארוך מדי). בדקו את החיבור של השרת ונסו שוב."],
  bad_request:["ה-API דחה את הבקשה","Anthropic החזירה שגיאה 400. הפרטים הטכניים למטה מסבירים למה."],
  refused:["ה-AI סירב לבדוק","המודל סירב לענות על הבקשה הזו. אפשר לנסות שוב או לבקש מהתלמיד לצלם מחדש."],
  incomplete:["הבדיקה נקטעה","ה-AI הפסיק לפני שסיים את הבדיקה. נסו שוב."],
  bad_output:["תשובה לא תקינה מה-AI","ה-AI החזיר תשובה שלא עברה את בדיקות השרת, ולכן לא ניתן ציון. נסו שוב."],
  provider_error:["שגיאה בשירות ה-AI","Anthropic החזירה שגיאה לא צפויה. נסו שוב, ואם זה חוזר, ראו את הפרטים הטכניים."],
  server_error:["שגיאה בשרת שלנו","משהו נכשל בשרת של Mathly. הפרטים הטכניים למטה, וגם ביומן השגיאות בלשונית ניטור."],
  answer_storage_unavailable:["האחסון לא זמין","לא הצלחנו לקרוא את התמונה השמורה. בדקו את Supabase או את MATHLY_ANSWER_BUCKET ו-HF_TOKEN."],
  open_ended_locked:["שאלות פתוחות נעולות","בשרת מוגדר MATHLY_OPEN_ENDED_LOCKED=1. שנו ל-0 והפעילו מחדש כדי לאפשר בדיקה."]};
// a "פירוט השגיאה" button that opens what happened, what to do, and the technical line; "" without a reason
function errorDetails(reason,detail){
  if(!reason&&!detail)return "";
  const [title,say]=reasons[reason]||["שגיאה לא מוכרת","לא זיהינו את סוג השגיאה. הפרטים הטכניים למטה."];
  return `<details class="mx-error-details"><summary>פירוט השגיאה</summary><p><b>${esc(title)}</b></p><p>${esc(say)}</p>${detail?`<code dir="ltr">${esc(detail)}</code>`:""}</details>`;
}
const verdicts={correct:"נכון! הפתרון מלא",partial:"פתרון נכון בחלקו",incorrect:"יש לתקן את הפתרון",unreadable:"לא ניתן לקרוא את התשובה",needs_review:"נדרשת הבהרה לפני מתן ציון"};
let queued=false;   // a student: an answer is checked only after their teacher sends it to the AI (app/ai_queue.py)
let api=null,account=null,epoch=0,question=null,inbox=false,inboxTimer=null,inboxPoll=null,capture=null,visibleRoot=null,onGraded=null;
const handled=new Set();   // requests this phone already answered (photographed or declined): not shown again
const live=(e)=>e===epoch&&!!account;
async function json(url,options={}){
  const r=await api(url,{cache:"no-store",...options});
  const d=await r.json().catch(()=>({}));
  if(!r.ok){if(r.status===401)reset();const error=Error(errors[d.detail]||"החיבור נקטע. נסו שוב.");error.status=r.status;error.code=d.detail;error.reason=d.reason||"";error.detail=d.error_detail||"";throw error;}
  return d;
}
const post=(url,body={})=>json(url,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
function closeQuestion(){
  if(question){clearTimeout(question.timer);if(question.image)URL.revokeObjectURL(question.image);question=null;}
}
function closeCapture(){
  if(capture){if(capture.url)URL.revokeObjectURL(capture.url);capture.node.remove();capture.returnFocus?.focus?.();capture=null;}
}
function reset(){
  epoch++;if(question)question.node.replaceChildren();closeQuestion();closeCapture();clearTimeout(inboxTimer);inboxTimer=null;
  inbox=false;account=null;onGraded=null;PUSH_MODE="hidden";paintPush();document.querySelector(".mx-push-ask")?.remove();
}
function configure(options){
  reset();api=options.api;account=options.enabled?options.account:null;visibleRoot=options.root;onGraded=options.onGraded;queued=!!options.queued;
  if(!account)return;
  // The phone (the responsive app/PWA) watches for requests from the computer and pops them up.
  if(matchMedia("(max-width: 700px)").matches||navigator.maxTouchPoints){inbox=true;pollInbox(epoch);pushSync(epoch);}
}
/* ---------- the phone: a notification when the computer asks for a photo (app/push.py) ----------
   Only the phone subscribes, so the computer that made the request is not notified. The notification just brings the
   student here; the look for requests above shows the request itself. On an iPhone only the app added to the home
   screen may get notifications. A tap while the app is open comes as a message from the service worker. */
const PUSH_IOS=/iPhone|iPad|iPod/.test(navigator.userAgent)||(navigator.platform==="MacIntel"&&navigator.maxTouchPoints>1);
const PUSH_STANDALONE=matchMedia("(display-mode: standalone)").matches||navigator.standalone===true;
const PUSH_CAN="serviceWorker" in navigator&&"PushManager" in window&&"Notification" in window;
const PUSH_OFF="mathly.pushOff";   // turned off here: not turned on again by itself
const pushOptOut=v=>{try{if(v===undefined)return localStorage.getItem(PUSH_OFF)==="1";v?localStorage.setItem(PUSH_OFF,"1"):localStorage.removeItem(PUSH_OFF);}catch{return false;}};
let pushKey=null,pushReg=null;
function pushWorker(){
  if(!("serviceWorker" in navigator))return Promise.resolve(null);
  return pushReg||=navigator.serviceWorker.register("/service-worker.js").then(()=>navigator.serviceWorker.ready).catch(()=>null);
}
async function pushState(){   // {key, sub}: key is empty when the server sends no notifications
  if(!PUSH_CAN||!account)return {key:"",sub:null};
  if(pushKey===null)pushKey=await json("/api/push").then(d=>d.key||"").catch(()=>"");
  const reg=await pushWorker();
  const sub=reg?await reg.pushManager.getSubscription().catch(()=>null):null;
  return {key:pushKey,sub};
}
const subscribeBody=sub=>({method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(sub.toJSON())});
async function pushOn(key){   // asks the permission (call it from a tap) and subscribes; the subscription, or null
  if(await Notification.requestPermission()!=="granted")return null;
  const reg=await pushWorker();if(!reg)throw Error("no worker");
  const raw=atob(key.replace(/-/g,"+").replace(/_/g,"/")+"=".repeat((4-key.length%4)%4));
  const sub=await reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:Uint8Array.from(raw,c=>c.charCodeAt(0))});
  const r=await api("/api/push/subscribe",subscribeBody(sub)).catch(()=>null);
  if(!r?.ok){await sub.unsubscribe().catch(()=>{});const e=Error("not kept");e.server=r?r.status:0;throw e;}
  pushOptOut(false);return sub;
}
async function pushOff(sub){
  await api("/api/push/unsubscribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({endpoint:sub.endpoint})}).catch(()=>{});
  await sub.unsubscribe().catch(()=>{});
}
/* A phone's subscription belongs to the account signed in on it: on sign-in it moves to that account (or, notifications
   being allowed already, is made again after a sign-out), unless it was turned off here. */
async function pushSync(e){
  if(!PUSH_CAN){pushMode(PUSH_IOS&&!PUSH_STANDALONE?"ios":"hidden");return;}
  try{
    const st=await pushState();if(!live(e))return;
    if(!st.key){pushMode("hidden");return;}
    let sub=st.sub;
    if(sub)await api("/api/push/subscribe",subscribeBody(sub));
    else if(Notification.permission==="granted"&&!pushOptOut())sub=await pushOn(st.key);
    if(!live(e))return;
    pushMode(sub?"on":Notification.permission==="denied"?"blocked":"off");
    if(!sub&&Notification.permission==="default"&&!pushOptOut()&&!pushAsked())askPush(e);
  }catch{if(live(e))pushMode(Notification.permission==="denied"?"blocked":"off");}
}
/* The 🔔 in the top bar (phones only): its state is kept here and painted into every .mx-push-btn, so the page can
   draw it in its templates (pushButton) and it changes in place when the state does. */
let PUSH_MODE="hidden";   // hidden | on | off | blocked | ios
const PUSH_LOOK={on:["🔔","התראות פעילות. לחצו לכיבוי"],off:["🔕","התראות כבויות. לחצו להפעלה"],
  blocked:["🔕","ההתראות חסומות בדפדפן"],ios:["🔕","התראות באייפון: מהאפליקציה שעל מסך הבית"]};
function pushMode(m){PUSH_MODE=account&&inbox?m:"hidden";paintPush();}
function pushButton(){   // drawn as it is now; paintPush changes it in place later
  const look=PUSH_LOOK[PUSH_MODE];
  return look?`<button class="mx-btn mx-push-btn" data-act="push" type="button" title="${look[1]}" aria-label="${look[1]}" aria-pressed="${PUSH_MODE==="on"}">${look[0]}</button>`
    :'<button class="mx-btn mx-push-btn" data-act="push" type="button" hidden></button>';
}
function paintPush(){
  for(const b of document.querySelectorAll(".mx-push-btn")){
    const look=PUSH_LOOK[PUSH_MODE];b.hidden=!look;if(!look)continue;
    b.textContent=look[0];b.title=look[1];b.setAttribute("aria-label",look[1]);b.setAttribute("aria-pressed",String(PUSH_MODE==="on"));
  }
}
const pushAsked=()=>{try{return localStorage.getItem("mathly.pushAsked")==="1";}catch{return true;}};
function pushNote(title,html,buttons){   // a small window on the phone: the question, or how to unblock
  document.querySelector(".mx-push-ask")?.remove();
  const node=document.createElement("div");node.className="mx-scan-overlay mx-push-ask";node.setAttribute("role","dialog");node.setAttribute("aria-modal","true");node.setAttribute("aria-labelledby","pushTitle");
  node.innerHTML=`<div class="mx-scan-card"><h2 id="pushTitle">${title}</h2><p>${html}</p><p data-status role="status" aria-live="polite"></p><div class="mx-scan-actions"></div></div>`;
  const row=node.querySelector(".mx-scan-actions"),close=()=>node.remove();
  for(const [label,solid,act] of buttons){
    const b=document.createElement("button");b.type="button";b.className="mx-btn"+(solid?" solid":" mx-btn-quiet");b.textContent=label;
    b.onclick=async()=>{for(const x of row.children)x.disabled=true;try{if(await act?.(node)!==false)close();}finally{for(const x of row.children)x.disabled=false;}};row.append(b);
  }
  node.addEventListener("keydown",ev=>{if(ev.key==="Escape")close();});
  (visibleRoot||document.body).append(node);row.firstChild?.focus();
}
async function enablePush(node){   // "אישור": the browser's own question follows; false keeps the window open with why
  const say=t=>{const s=node?.querySelector("[data-status]");if(s)s.textContent=t;};
  try{
    const st=await pushState();const sub=st.key?await pushOn(st.key):null;
    pushMode(sub?"on":Notification.permission==="denied"?"blocked":"off");
    if(!sub){say(Notification.permission==="denied"?"ההתראות נחסמו. אפשר לאשר אותן בהגדרות האתר בדפדפן.":"ההתראות לא אושרו.");return false;}
  }catch(error){
    // the server could not keep the subscription (503: not set up there yet), or the phone's push service refused
    say(error.server===503?"ההתראות עוד לא מוכנות בשרת. נסו שוב מאוחר יותר."
      :error.server===0?"אין חיבור לשרת. בדקו את האינטרנט ונסו שוב."
      :error.server?"השרת לא שמר את ההרשמה. נסו שוב מאוחר יותר."
      :"הטלפון לא הצליח להירשם להתראות. בדקו שהתראות הדפדפן מותרות בהגדרות הטלפון ונסו שוב.");
    return false;
  }
}
function askPush(e){   // once per phone, after sign-in; "לא, תודה" is final (the 🔔 still turns them on)
  if(capture||document.querySelector(".mx-push-ask"))return;
  const done=()=>{try{localStorage.setItem("mathly.pushAsked","1");}catch{}};
  pushNote("🔔 התראות מהמחשב","כשהמחשב מבקש לצלם תשובה, הטלפון יקבל התראה, גם כשהאפליקציה סגורה.",[
    ["אישור",true,async node=>{done();if(!live(e))return;return enablePush(node);}],
    ["לא, תודה",false,()=>{done();pushOptOut(true);pushMode("off");}]]);
}
async function togglePush(){   // the 🔔
  if(PUSH_MODE==="ios")return pushNote("📱 התראות באייפון","באייפון ההתראות מגיעות רק מהאפליקציה שעל מסך הבית: לחצו על <b>שיתוף</b> ⬆️ ← <b>„הוספה למסך הבית”</b>, ופתחו את האפליקציה משם.",[["הבנתי",true]]);
  if(PUSH_MODE==="blocked")return pushNote("🔕 ההתראות חסומות","הדפדפן חוסם התראות מהאתר. כדי לאשר: בכרום לחצו על הסמל שליד הכתובת ← <b>הרשאות</b> ← <b>התראות</b> ← <b>אישור</b>. באייפון: <b>הגדרות</b> ← <b>עדכונים</b> ← <b>לומדים בכיף</b>.",[["הבנתי",true]]);
  if(PUSH_MODE==="on")return pushNote("🔔 לכבות התראות?","הטלפון לא יקבל התראה כשהמחשב מבקש צילום. אפשר להפעיל שוב מכאן.",[
    ["כיבוי",true,async()=>{const st=await pushState().catch(()=>({}));if(st.sub)await pushOff(st.sub);pushOptOut(true);pushMode("off");}],["ביטול",false]]);
  if(PUSH_MODE==="off")return pushNote("🔔 להפעיל התראות?","כשהמחשב מבקש לצלם תשובה, הטלפון יקבל התראה, גם כשהאפליקציה סגורה.",[["אישור",true,enablePush],["ביטול",false]]);
}
async function forgetPush(){   // signing out: the next account on this phone does not get this one's requests
  try{const st=await pushState();if(st.sub)await pushOff(st.sub);}catch{}
}
async function clearScanNotices(){   // the app is open and shows requests itself: the tray's ones are old news
  try{const reg=await navigator.serviceWorker?.getRegistration();for(const n of await reg?.getNotifications()||[])if(String(n.tag).startsWith("scan-"))n.close();}catch{}
}
/* The offer, at the end of a request on the phone: "next time, tell me". Nothing is shown when notifications cannot
   work here or are blocked; an iPhone's browser gets the home-screen hint instead. */
function pushOffer(node,e){
  const box=document.createElement("div");box.className="mx-push-offer";box.hidden=true;node.append(box);
  const draw=(text,button,act)=>{
    box.replaceChildren();const p=document.createElement("small");p.textContent=text;box.append(p);
    if(button){const b=document.createElement("button");b.className="mx-btn";b.type="button";b.textContent=button;b.onclick=async()=>{
      b.disabled=true;try{await act();}catch{p.textContent="לא הצלחנו להפעיל התראות. נסו שוב מאוחר יותר.";}finally{b.disabled=false;}};box.append(b);}
    box.hidden=false;
  };
  if(!PUSH_CAN){
    if(PUSH_IOS&&!PUSH_STANDALONE)draw("📱 רוצים שהטלפון יצלצל כשהמחשב מבקש צילום? באייפון זה עובד רק מהאפליקציה שעל מסך הבית: שיתוף ← „הוספה למסך הבית”.");
    return;
  }
  pushState().then(st=>{
    if(!live(e)||!st.key||Notification.permission==="denied")return;
    const show=sub=>sub
      ?draw("🔔 התראה תגיע לטלפון כשהמחשב מבקש צילום.","כיבוי ההתראות",async()=>{await pushOff(sub);pushOptOut(true);pushMode("off");show(null);})
      :draw("רוצים שהטלפון יודיע לכם בפעם הבאה שהמחשב מבקש צילום, גם כשהאפליקציה סגורה?","🔔 כן, להודיע לי",async()=>{const s=await pushOn(st.key);if(live(e)){pushMode(s?"on":Notification.permission==="denied"?"blocked":"off");show(s);}});
    show(st.sub);
  });
}
if("serviceWorker" in navigator){
  navigator.serviceWorker.addEventListener("message",ev=>{
    if(ev.data?.type!=="notified"||!account||!inbox)return;
    clearScanNotices();pollInbox(epoch);
  });
}
if(new URLSearchParams(location.search).get("from")==="push-scan")history.replaceState(null,"",location.pathname);
/* The phone looks for requests every few seconds while the app is open. A new request pops up as a window with its
   4-digit code (the same code the computer shows); the student photographs the answer or declines. */
async function pollInbox(e){
  clearTimeout(inboxTimer);if(!live(e)||!inbox||inboxPoll===e)return;
  if(document.hidden){inboxTimer=setTimeout(()=>pollInbox(e),5000);return;}
  inboxPoll=e;
  let delay=3000;
  try{
    const list=await json("/api/math/answer-scans");if(!live(e)||!inbox)return;
    if(capture&&!capture.ended){
      const mine=list.find(r=>r.attempt.id===capture.result.attempt.id);
      // withdrawn on the computer (or replaced there by a new request): this window has nothing left to answer
      if(!mine||mine.scan.id!==capture.result.scan.id&&!capture.busy)capture.withdrawn?.(mine);
    }
    const ask=list.find(r=>["waiting","confirmed","failed"].includes(r.scan.state)&&!handled.has(r.scan.id));
    if(ask&&!capture){openCapture(ask);clearScanNotices();}
  }catch(error){
    if(!live(e))return;
    if(error.status===401){reset();return;}
    delay=15000;
  }finally{if(inboxPoll===e)inboxPoll=null;}
  if(live(e))inboxTimer=setTimeout(()=>pollInbox(e),delay);
}
async function mountQuestion(node,id,version,fresh=false){
  closeQuestion();const e=epoch;
  if(!account){node.innerHTML='<p>כדי לסרוק תשובה בטלפון, היכנסו לאותו חשבון בשני המכשירים. שמירת תשובות צריכה להיות זמינה באתר.</p>';return;}
  const q=question={node,id,version,e,timer:null,image:null,busy:false,result:null,grading:false};
  node.innerHTML='<p role="status">טוענים את ניסיון התשובה…</p>';
  try{const r=await post("/api/math/answer-attempts",{question_id:id,question_version:version,new:fresh});if(!current(q))return;q.result=r;await renderQuestion(q);schedule(q);}
  catch(error){if(current(q)){node.innerHTML=`<p role="status">${esc(error.message)}</p><button class="mx-btn" data-reload>ניסיון נוסף</button>`;node.querySelector("button").onclick=()=>error.code==="question_changed"?location.reload():mountQuestion(node,id,version);}}
}
const current=q=>question===q&&live(q.e)&&q.node.isConnected;
function schedule(q,delay=2000){clearTimeout(q.timer);if(current(q))q.timer=setTimeout(()=>pollQuestion(q),delay);}
async function pollQuestion(q){
  if(!current(q)||q.polling)return;
  if(document.hidden){schedule(q,5000);return;}
  q.polling=true;
  try{
    const r=await json(`/api/math/answer-attempts/${q.result.attempt.id}`);if(!current(q))return;
    q.result=r;await renderQuestion(q);schedule(q);
  }catch(error){if(current(q)){q.node.querySelector("[role=status]").textContent=error.message;schedule(q,8000);}}
  finally{q.polling=false;}
}
async function renderQuestion(q){
  if(!current(q))return;
  const r=q.result,s=r.scan,state=s?.state,received=!!r.attachment,g=r.grading;
  const checking=q.grading||g?.state==="grading", req=r.ai_request;
  const waiting=!checking&&g?.state!=="completed"&&waitingTeacher(req);
  // while the phone has the request, its code and progress are in the phone box itself (phoneLive)
  const statusText=checking?(q.grading&&queued?"שולחים למורה…":"בודקים את התשובה…"):waiting?"התשובה מחכה למורה":g?.state==="completed"?verdicts[g.result.verdict]:g?.state==="failed"?errors[g.error]||errors.grading_failed:!received&&phonePending(state)?"":states[state]||"כתבו את הפתרון על דף וצלמו אותו בטלפון.";
  // Rebuilding controls on every poll would steal focus and race a button click.
  const signature=[state,s?.id,r.attachment?.id,g?.id,g?.state,r.grading_available,req?.id,req?.state,r.teacher_review?.created_at,r.review_request?.id,r.review_request?.state].join(":");
  if(q.signature!==signature){
    q.signature=signature;
    q.node.innerHTML=`<small>ניסיון ${esc(r.attempt.id.slice(0,8))}</small><p role="status" aria-live="polite">${esc(statusText)}</p>
      ${received?`<img class="mx-answer-image" alt="התשובה המצולמת שלכם" hidden>${teacherNote(r)}${gradingPanel(r,checking)}${reviewBox(r.attempt.id,r)}
        <button class="mx-btn" data-new-attempt ${checking?"disabled":""}>ניסיון חדש וצילום מחדש</button>`:phoneLive(s,`data-request`,`data-cancel-scan`,!s?"צילום התשובה בטלפון":"בקשת צילום חדשה לטלפון")}`;
    if(!received){
      const say=t=>{const el=q.node.querySelector("[role=status]");if(el)el.textContent=t;};
      const b=q.node.querySelector("[data-request]");
      if(b)b.onclick=async()=>{
        b.disabled=true;
        try{
          const result=await post(`/api/math/answer-attempts/${r.attempt.id}/scans`,
            {question_id:q.id,renew:["expired","superseded","failed","declined"].includes(q.result.scan?.state)});
          if(!current(q))return;q.result=result;await renderQuestion(q);schedule(q,1500);
        }
        catch(error){if(current(q)){say(error.message);b.disabled=false;}}
      };
      const cancel=q.node.querySelector("[data-cancel-scan]");
      if(cancel)cancel.onclick=async()=>{
        cancel.disabled=true;
        try{const result=await post(`/api/math/answer-scans/${q.result.scan.id}/decline`,{});if(!current(q))return;q.result=result;await renderQuestion(q);}
        catch(error){if(current(q)){say(error.message);cancel.disabled=false;}}
      };
    }
    if(received){
      const gradeButton=q.node.querySelector("[data-grade]");
      if(gradeButton)gradeButton.onclick=()=>checkAnswer(q);
      q.node.querySelector("[data-new-attempt]").onclick=()=>mountQuestion(q.node,q.id,q.version,true);
      const ask=q.node.querySelector("[data-ask-review]");
      if(ask)ask.onclick=async()=>{const sent=await askReview(ask);if(sent&&current(q)){q.result={...q.result,review_request:sent};q.signature=[q.signature,"asked"].join(":");}};
    }
  }else q.node.querySelector("[role=status]").textContent=statusText;
  if(g?.state==="completed"&&q.notified!==g.id){q.notified=g.id;onGraded?.({questionId:q.id,grading:g});}
  const t=r.teacher_review;   // the teacher's 100 counts like a correct check
  if(t?.score===100&&q.notifiedTeacher!==t.created_at){q.notifiedTeacher=t.created_at;onGraded?.({questionId:q.id,grading:{id:"teacher:"+t.created_at,finished_at:t.created_at,result:{verdict:"correct",score:100}}});}
  if(received&&!q.image&&!q.loadingImage){
    q.loadingImage=true;
    try{
      const response=await api(`/api/math/answer-attempts/${r.attempt.id}/image`,{cache:"no-store"});
      if(!response.ok)throw Error("לא ניתן לטעון את התמונה. מנסים שוב…");
      const blob=await response.blob();if(!current(q))return;
      q.image=URL.createObjectURL(blob);const img=q.node.querySelector("img");
      img.onload=()=>{if(current(q))img.hidden=false;};img.src=q.image;
    }catch(error){if(current(q))q.node.querySelector("[role=status]").textContent=error.message;}
    finally{q.loadingImage=false;}
  }else if(q.image&&q.node.querySelector("img")){const img=q.node.querySelector("img");img.src=q.image;img.hidden=false;}
  // Nothing is sent by itself: a student sends the answer to their teacher, a teacher checks it, each with a click.
}
/* The teacher's review of the answer (its own score and comment), shown above the automatic check, and the student's
   request to look again. Used by the answer panel here and by the unit quiz's result in math.js. */
function teacherNote(r){
  const t=r.teacher_review; if(!t)return "";
  return `<section class="mx-teacher-note" aria-label="הערת המורה"><h4>הערת המורה${t.score==null?"":` · <span class="m">${esc(t.score)}/100</span>`}</h4>${t.comment?`<p>${esc(t.comment).replace(/\n/g,"<br>")}</p>`:""}</section>`;
}
function reviewBox(attemptId,r){
  if(r.review_request?.state==="open")return '<p class="mx-small mx-ask-wait">ביקשתם מהמורה לבדוק שוב. הבקשה ממתינה.</p>';
  if(r.grading?.state!=="completed")return "";   // only a checked answer can be questioned
  return `<details class="mx-ask"><summary>לבקש מהמורה לבדוק שוב</summary>
    <textarea maxlength="300" rows="2" data-ask-msg aria-label="הודעה למורה (לא חובה)" placeholder="מה לא נראה לכם בבדיקה? (לא חובה)"></textarea>
    <button type="button" class="mx-btn" data-ask-review="${esc(attemptId)}">שליחה למורה</button></details>`;
}
// sends the request in the box the button is in; the box then says it waits
async function askReview(button){
  const box=button.closest(".mx-ask"),message=box.querySelector("[data-ask-msg]").value;
  button.disabled=true;box.querySelector(".mx-ask-error")?.remove();
  try{
    const sent=await post(`/api/math/answer-attempts/${button.dataset.askReview}/review-request`,{message});
    box.outerHTML='<p class="mx-small mx-ask-wait" role="status">ביקשתם מהמורה לבדוק שוב. הבקשה ממתינה.</p>';
    return sent.review_request;
  }catch(error){
    button.disabled=false;box.insertAdjacentHTML("beforeend",`<p class="mx-small mx-ask-error" role="alert">${esc(error.message)}</p>`);
    return null;
  }
}
function gradingPanel(r,checking){
  const g=r.grading;
  if(g?.state==="completed"){
    const a=g.result;
    return `<section class="mx-grade ${a.verdict==="correct"?"mx-grade-correct":""}" aria-label="תוצאות בדיקת התשובה">
      <h4>${r.teacher_review?"בדיקה אוטומטית: ":""}${esc(verdicts[a.verdict])}${a.score==null?"":` · <span class="m">${esc(a.score)}/100</span>`}</h4>
      <p>${fmt(a.feedback)}</p>${a.next_step?`<p><b>הצעד הבא: </b>${fmt(a.next_step)}</p>`:""}
      ${a.transcription?`<details><summary>מה קראנו בתמונה</summary><p>${fmt(a.transcription)}</p></details>`:""}
      ${a.solution?`<details><summary>הפתרון המלא</summary><p class="mx-grade-solution">${fmt(a.solution)}</p></details>`:""}
      <small>בדיקה אוטומטית באמצעות AI. בדקו שהתמלול תואם למה שכתבתם.</small></section>`;
  }
  if(!r.grading_available&&!checking)return '<p>התמונה נשמרה כתשובה. בדיקה אוטומטית אינה זמינה כרגע באתר.</p>';
  if(queued&&!checking){
    const req=r.ai_request;
    if(waitingTeacher(req))return '<p>התשובה נשלחה למורה. אחרי שהמורה יאשר את הבדיקה, התוצאה תופיע כאן.</p>';
    if(req?.state==="declined")return `<p>המורה לא אישר את הבדיקה.${req.note?` <b>הערת המורה:</b> ${esc(req.note)}`:""}</p>
      <button class="mx-btn" data-grade>שליחה מחדש למורה</button>`;
    return `<p>${g?.state==="failed"||req?.state==="failed"?"הבדיקה נכשלה. אפשר לשלוח שוב למורה.":"שלחו את התשובה למורה, והמורה יעביר אותה לבדיקה."}</p>
      <button class="mx-btn solid" data-grade>שליחה לבדיקת המורה</button>`;
  }
  return `<p>התשובה נשמרה. ${g?.state==="failed"?"הבדיקה נכשלה. אפשר לנסות שוב.":"לחצו כדי לבדוק אותה באמצעות AI."}</p>
    <button class="mx-btn solid" data-grade ${checking?"disabled":""}>${checking?"בודקים את התשובה…":g?.state==="failed"?"ניסיון בדיקה נוסף":"בדיקת התשובה"}</button>`;
}
// the answer's request is in the teacher's queue (a failed send stays there for the teacher to try again)
const waitingTeacher=req=>!!req&&["pending","sending","failed"].includes(req.state);
async function checkAnswer(q,automatic=false){
  if(!current(q)||q.grading)return;
  q.grading=true;q.signature=null;await renderQuestion(q);schedule(q);
  try{
    const r=await post(`/api/math/answer-attempts/${q.result.attempt.id}/grade`,{automatic});
    if(!current(q))return;q.result=r;
  }catch(error){if(current(q))q.gradeError=error;}
  finally{
    if(current(q)){
      q.grading=false;q.signature=null;await renderQuestion(q);
      if(q.gradeError){
        const status=q.node.querySelector("[role=status]");status.textContent=q.gradeError.message;
        status.insertAdjacentHTML("afterend",errorDetails(q.gradeError.reason,q.gradeError.detail));q.gradeError=null;
      }
      schedule(q);
    }
  }
}
const waitingState=st=>st==="waiting"||st==="confirmed";
const codeHTML=code=>`<div class="mx-scan-code" dir="ltr" aria-label="מספר מזהה ${esc([...String(code||"")].join(" "))}">${[...String(code||"----")].map(d=>`<span>${esc(d)}</span>`).join("")}</div>`;
// keeps Tab inside a window
function trapTab(node,event){
  if(event.key!=="Tab")return;
  const buttons=[...node.querySelectorAll("button")].filter(b=>!b.hidden&&!b.disabled&&b.offsetParent);
  if(event.shiftKey&&document.activeElement===buttons[0]){event.preventDefault();buttons.at(-1)?.focus();}
  else if(!event.shiftKey&&document.activeElement===buttons.at(-1)){event.preventDefault();buttons[0]?.focus();}
}

/* ---------- the phone: a window for a request from the computer ---------- */
function openCapture(result){
  closeCapture();document.querySelector(".mx-push-ask")?.remove();const e=epoch;   // the request comes first
  if(!live(e))return;
  const node=document.createElement("div");node.className="mx-scan-overlay";node.setAttribute("role","alertdialog");node.setAttribute("aria-modal","true");node.setAttribute("aria-labelledby","scanTitle");
  node.innerHTML=`<div class="mx-scan-card mx-scan-ask">
    <h2 id="scanTitle"><span aria-hidden="true">📷</span> בקשת צילום מהמחשב</h2>
    <p>בדקו שזה אותו מספר שמופיע במחשב:</p>${codeHTML(result.scan.code)}
    <div class="mx-scan-q"><small>השאלה</small><p>${fmt(result.attempt.snapshot.question)}</p></div>
    <img class="mx-answer-image" data-preview alt="תצוגה מקדימה של התשובה" hidden>
    <p data-status role="status" aria-live="polite">${window.MathlyBoard?"כתבו את הפתרון על דף וצלמו אותו, או כתבו אותו כאן בלוח.":"כתבו את הפתרון על דף וצלמו אותו."}</p>
    <input data-camera type="file" accept="image/*" capture="environment" hidden>
    <div class="mx-scan-actions">
      <button class="mx-btn solid" data-take>📷 צילום התשובה</button>
      ${window.MathlyBoard?'<button class="mx-btn solid" data-draw>✏️ כתיבה בלוח</button>':""}
      <button class="mx-btn solid" data-submit hidden>שליחה למחשב</button>
      <button class="mx-btn" data-renew hidden>בקשה חדשה</button>
      <button class="mx-btn mx-btn-quiet" data-decline>סירוב</button>
      <button class="mx-btn" data-close hidden>סגירה</button>
    </div>
    <small>הצילום יישמר כתשובה בשאלה במחשב. שם אפשר לשלוח אותו לבדיקה.</small></div>`;
  const c=capture={node,e,result,photo:null,url:null,key:null,busy:false,ended:false,confirming:null,returnFocus:document.activeElement};visibleRoot.append(node);
  const $=sel=>node.querySelector(sel);
  const status=$("[data-status]"),camera=$("[data-camera]"),take=$("[data-take]"),draw=$("[data-draw]"),send=$("[data-submit]"),preview=$("[data-preview]"),renew=$("[data-renew]"),decline=$("[data-decline]"),close=$("[data-close]");
  const valid=()=>capture===c&&live(e),say=message=>{if(valid())status.textContent=message;};
  const controls=busy=>{c.busy=busy;take.disabled=send.disabled=renew.disabled=decline.disabled=busy;if(draw)draw.disabled=busy;};
  // the request is over: only closing is left
  const finish=(message,offer=false)=>{c.ended=true;handled.add(c.result.scan.id);take.hidden=send.hidden=renew.hidden=decline.hidden=true;if(draw)draw.hidden=true;close.hidden=false;say(message);if(offer)pushOffer($(".mx-scan-actions"),e);close.focus();};
  const expired=()=>{take.hidden=send.hidden=true;if(draw)draw.hidden=true;renew.hidden=false;say(errors.expired);};
  c.withdrawn=mine=>{ if(c.photo&&mine&&waitingState(mine.scan.state)){c.result=mine;return;} finish("הבקשה בוטלה במחשב."); };
  close.onclick=()=>{closeCapture();if(valid())pollInbox(e);};
  node.onkeydown=event=>{
    if(event.key==="Escape"){event.preventDefault();event.stopPropagation();if(c.ended)close.click();}
    trapTab(node,event);
  };
  // confirming happens in the background, so the camera opens right from the tap
  const confirmed=()=>c.confirming||=(async()=>{
    const r=await post(`/api/math/answer-scans/${c.result.scan.id}/confirm`);if(valid())c.result=r;return r;
  })().catch(error=>{c.confirming=null;throw error;});
  const confirmNow=()=>confirmed().then(r=>{if(r.attachment&&valid())success();}).catch(error=>{if(valid()){say(error.message);if(error.status===410)expired();}});
  take.onclick=()=>{
    say("צלמו את הפתרון. אם המצלמה חסומה, אפשרו אותה בהגדרות האתר ונסו שוב.");camera.click();
    confirmNow();
  };
  // the board instead of paper: full screen; its hand-in becomes this answer's picture and is sent like a photo
  if(draw)draw.onclick=()=>{
    confirmNow();
    window.MathlyBoard.openFull({host:visibleRoot,key:`scan:${c.result.attempt.id}`,title:"התשובה למחשב",sendLabel:"שליחה למחשב",
      question:()=>node.querySelector(".mx-scan-q"),done:()=>{if(valid())(c.ended?close:draw).focus();},
      submit:async(_,picture)=>{
        if(!valid()||c.ended)return {error:"הבקשה כבר לא פעילה"};
        try{setPhoto(await window.MathlyPhoto.prepare(picture));}catch{return {error:"לא ניתן להכין את התמונה"};}
        await upload();
        return c.ended?{close:true}:{error:status.textContent};
      }});
  };
  camera.addEventListener("cancel",()=>say("הצילום בוטל או שלא ניתנה הרשאה. אפשר לצלם שוב או לסרב."));
  camera.onchange=async()=>{
    const file=camera.files[0];camera.value="";if(!file||!valid())return;
    controls(true);say("מכינים את התמונה…");
    try{
      const photo=await window.MathlyPhoto.prepare(file);if(!valid())return;
      setPhoto(photo);say("בדקו שכל הפתרון ברור, ואז שלחו למחשב.");send.focus();
    }catch(error){say("לא ניתן להכין את התמונה. נסו לצלם שוב.");}
    finally{if(valid())controls(false);}
  };
  // a picture to send: the camera's, or the board's
  function setPhoto(photo){
    if(photo.size>5000000)throw Error(errors.image_too_large);
    if(c.url)URL.revokeObjectURL(c.url);
    c.photo=photo;c.key=null;preview.src=c.url=URL.createObjectURL(photo);preview.hidden=false;
    take.textContent="צילום מחדש";take.classList.remove("solid");send.hidden=false;
  }
  const success=()=>{c.photo=null;finish("✓ התשובה נשלחה למחשב.",true);};
  const upload=async()=>{
    if(!c.photo||c.busy)return;
    controls(true);say(states.uploading);
    try{
      await confirmed();if(!valid())return;
      c.key=c.key||crypto.randomUUID();
      let b=await post(`/api/math/answer-scans/${c.result.scan.id}/uploads`,{key:c.key});if(!valid())return;
      if(b.attachment){success();return;}
      const form=new FormData();form.append("photo",c.photo,"answer.jpg");
      b=await json(`/api/math/answer-scans/${c.result.scan.id}/uploads/${b.lease_id}`,{method:"POST",body:form});
      if(!valid())return;c.result=b;success();
    }catch(error){
      if(!valid())return;say(error.message+" התמונה נשמרה לתצוגה מקדימה. אפשר לנסות שוב.");
      if(error.status===410||error.code==="superseded")expired();
      // Reuse the key after an uncertain response; the server returns prior success.
    }finally{if(valid()&&!c.ended)controls(false);}
  };
  send.onclick=upload;
  decline.onclick=async()=>{
    controls(true);
    try{await post(`/api/math/answer-scans/${c.result.scan.id}/decline`);if(!valid())return;finish("סירבתם לבקשה. המחשב קיבל הודעה.");}
    catch(error){if(!valid())return;if(error.code==="superseded"||error.status===404)finish("הבקשה כבר לא פעילה.");else say(error.message);}
    finally{if(valid()&&!c.ended)controls(false);}
  };
  renew.onclick=async()=>{
    controls(true);
    try{const r=await post(`/api/math/answer-attempts/${c.result.attempt.id}/scans`,{renew:true});if(!valid())return;
      c.result=r;c.key=null;c.confirming=null;renew.hidden=true;take.hidden=false;node.querySelector(".mx-scan-code").outerHTML=codeHTML(r.scan.code);
      say("נוצרה בקשה חדשה עם מספר חדש. אפשר לצלם.");}
    catch(error){say(error.message);}
    finally{if(valid())controls(false);}
  };
  if(result.scan.state==="expired")expired();
  if(navigator.vibrate)try{navigator.vibrate([60,40,60]);}catch{}
  take.focus();
}

/* ---------- the computer: the phone box ---------- */
/* No window over the question: the photo button itself becomes the request's box while the phone has it, with the
   4-digit code, how far the phone got, and a way to withdraw the request. The page's own look at the attempt (every
   couple of seconds) keeps it up to date; when the photo arrives the answer replaces it. `scan`: the attempt's last
   scan; `ask`/`cancel`: the attributes of its two buttons; `idle`: the button's words when there is no open request. */
const phonePending=st=>["waiting","confirmed","uploading","failed"].includes(st);
const PHONE_STEPS={waiting:"ממתינים לטלפון… פתחו את האפליקציה באותו חשבון",confirmed:"הטלפון אישר. ממתינים לצילום…",
  uploading:"מקבלים את התמונה מהטלפון…",failed:"ההעלאה נכשלה. נסו שוב בטלפון"};
function phoneLive(scan,ask,cancel,idle,disabled=false){
  const st=scan?.state;
  if(!phonePending(st))return `<button class="mx-btn solid mx-phone-cta" ${ask}${disabled?" disabled":""}><span aria-hidden="true">📱</span> ${esc(idle)}</button>`;
  return `<div class="mx-phone-live is-${esc(st)}" role="group" aria-label="צילום בטלפון">
    <div class="mx-phone-live-head"><span class="mx-scan-dot" aria-hidden="true"></span><span aria-live="polite">${esc(PHONE_STEPS[st])}</span></div>
    <div class="mx-phone-live-code"><small>המספר בטלפון:</small>${codeHTML(scan.code)}</div>
    ${st==="uploading"?"":`<button class="mx-link" ${cancel}>ביטול הבקשה</button>`}</div>`;
}
document.addEventListener("visibilitychange",()=>{
  if(document.hidden)return;
  if(account&&inbox){clearScanNotices();pollInbox(epoch);}
  if(question?.result)pollQuestion(question);
});
window.addEventListener("online",()=>{if(account&&inbox)pollInbox(epoch);if(question?.result)pollQuestion(question);});
window.MathlyAnswers={errorDetails,teacherNote,reviewBox,askReview,configure,mountQuestion,closeQuestion,reset,phoneLive,forgetPush,pushButton,paintPush,togglePush};
})();