3v324v23 commited on
Commit
466cf3a
ยท
1 Parent(s): 52ba002

feat(push): implement daily video reminder push notifications and user prompts

Browse files
Files changed (1) hide show
  1. static/index.html +76 -23
static/index.html CHANGED
@@ -325,6 +325,10 @@ body.memory-game{overflow:hidden}
325
  /* the way back to the path, at the head of the daily videos' screen (no tab of the foot's bar is lit there) */
326
  #vids button.back{display:inline-flex;align-self:flex-start;gap:6px;padding:6px 12px;border-radius:10px;background:var(--olive);border-color:var(--olive);color:var(--on-olive);font:700 15px var(--f-body)}
327
  #dailyDlg{width:min(92vw,380px)}
 
 
 
 
328
  /* A topic's end: every sentence practised. A medal pops, confetti falls when it all went well, and a bead a sentence
329
  shows how each one went (gold 75+, green 60+, red under 60 and wobbling: those are offered for one more round). */
330
  #topicDoneDlg{width:min(92vw,400px);text-align:center;overflow:hidden}
@@ -1717,6 +1721,12 @@ html.a11y-reduce-motion *,html.a11y-reduce-motion *::before,html.a11y-reduce-mot
1717
  <div class="picks" id="picks"></div>
1718
  <button class="btn" id="pickMates" hidden>ื”ืชืœืžื™ื“ื™ื ื‘ื›ื™ืชื”</button>
1719
  </dialog>
 
 
 
 
 
 
1720
  <dialog id="dailyDlg" aria-labelledby="dailyTitle">
1721
  <div class="dlg-head"><h2 id="dailyTitle"></h2></div>
1722
  <p class="dlg-text" id="dailyText"></p>
@@ -2019,49 +2029,91 @@ function endDaily(){ // past the session's last sentence: back to the path, an
2019
  The library: videos that are not on the path, the same ones for every level and class. */
2020
  const dailyVideos=()=>{ const d=today(), seen=new Set(); return (FREE?TEACHER_DAILIES:(MY_CLASS?[MY_CLASS]:MY_CLASSES).flatMap(k=>k.dailies||[])).filter(v=>v.date<=d&&!seen.has(v.id)&&seen.add(v.id)).sort((a,b)=>b.date.localeCompare(a.date)); };
2021
  const libVideos=()=>LIBRARY;
2022
- /* The daily video's morning reminder: a push notification the server sends (app/push.py), turned on here, on this device.
 
2023
  On an iPhone only the app added to the home screen may get them. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2024
  function pushRow(){
2025
  const row=mark("push-row",""), btn=mark("","","button"), note=mark("","","small"); row.append(btn,note);
2026
- const ios=/iPhone|iPad|iPod/.test(navigator.userAgent), standalone=matchMedia("(display-mode: standalone)").matches||navigator.standalone===true;
2027
  const set=(text,on,why="")=>{ btn.textContent=text; btn.classList.toggle("on",!!on); note.textContent=why; };
2028
- if(!("serviceWorker" in navigator)||!("PushManager" in window)||!("Notification" in window)){
2029
- if(ios&&!standalone){ btn.hidden=true; note.textContent="๐Ÿ“ฑ ื›ื“ื™ ืœืงื‘ืœ ืชื–ื›ื•ืจืช ื›ืœ ื‘ื•ืงืจ: ืฉื™ืชื•ืฃ โ† โ€žื”ื•ืกืคื” ืœืžืกืš ื”ื‘ื™ืชโ€, ๏ฟฝ๏ฟฝืคืชื—ื• ืืช ื”ืืคืœื™ืงืฆื™ื” ืžืฉื."; }
2030
  else row.hidden=true;
2031
  return row;
2032
  }
2033
  let key="", sub=null, hour=8;
2034
  const draw=()=>{
2035
- if(!key) return set("",false),btn.hidden=true,row.hidden=true;
2036
- btn.hidden=row.hidden=false;
2037
  if(Notification.permission==="denied") return set("๐Ÿ”• ื”ื”ืชืจืื•ืช ื—ืกื•ืžื•ืช",false,"ืืคืฉืจ ืœืืฉืจ ืื•ืชืŸ ื‘ื”ื’ื“ืจื•ืช ื”ืืชืจ ื‘ื“ืคื“ืคืŸ.");
2038
  if(sub) set("๐Ÿ”” ืชื–ื›ื•ืจืช ื›ืœ ื‘ื•ืงืจ ืคืขื™ืœื”",true,`ื‘ืฉืขื” ${hour}:00, ื‘ื™ื•ื ืฉื™ืฉ ืกืจื˜ื•ืŸ ยท ืœื—ืฆื• ืœื‘ื™ื˜ื•ืœ`);
2039
  else set("๐Ÿ”” ื”ื–ื›ื™ืจื• ืœื™ ื›ืœ ื‘ื•ืงืจ",false,"ื”ืชืจืื” ื›ืฉื™ืฉ ืกืจื˜ื•ืŸ ื—ื“ืฉ");
2040
  };
2041
  row.hidden=true;
2042
- Promise.all([api("/api/push").then(r=>r.ok?r.json():{}).catch(()=>({})),navigator.serviceWorker.ready.then(reg=>reg.pushManager.getSubscription()).catch(()=>null)])
2043
- .then(([info,s])=>{ key=info.key||""; hour=info.hour??8; sub=s; draw(); });
2044
  btn.onclick=async()=>{
2045
  btn.disabled=true;
2046
- try{
2047
- const reg=await navigator.serviceWorker.ready;
2048
- if(sub){
2049
- await api("/api/push/unsubscribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({endpoint:sub.endpoint})}).catch(()=>{});
2050
- await sub.unsubscribe().catch(()=>{}); sub=null;
2051
- }else{
2052
- if(await Notification.requestPermission()!=="granted") return draw();
2053
- const raw=atob(key.replace(/-/g,"+").replace(/_/g,"/")+"=".repeat((4-key.length%4)%4));
2054
- sub=await reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:Uint8Array.from(raw,c=>c.charCodeAt(0))});
2055
- const res=await api("/api/push/subscribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(sub.toJSON())});
2056
- if(!res.ok){ await sub.unsubscribe().catch(()=>{}); sub=null; draw(); note.textContent="ืœื ื”ืฆืœื—ื ื• ืœื”ืคืขื™ืœ ืืช ื”ืชื–ื›ื•ืจืช. ื ืกื• ืฉื•ื‘ ืžืื•ื—ืจ ื™ื•ืชืจ."; return; }
2057
- api("/api/push/test",{method:"POST"}).catch(()=>{}); // one now, so the student sees what it looks like
2058
- }
2059
- draw();
2060
- }catch(e){ draw(); note.textContent="ืœื ื”ืฆืœื—ื ื• ืœื”ืคืขื™ืœ ืืช ื”ืชื–ื›ื•ืจืช ื‘ื“ืคื“ืคืŸ ื”ื–ื”."; }
2061
  finally{ btn.disabled=false; }
2062
  };
2063
  return row;
2064
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2065
  const VERB_GAME_VISIBLE=false; // "ืคื•ืขืœ ื™ื•ืฆื" is still under development; flip to true to publish it again.
2066
  const EITAN_GAME_VISIBLE=false; // "ื‘ื•ื ื™ื ืคื•ืขืœ ืขื ืื™ืชืŸ" (ืคื•ืขืœ ื™ื•ืฆื's levels 6โ€“8, on their own): teachers see it; true shows it to students.
2067
  function drawNav(){ // the tabs, and the screen of videos when one is on show
@@ -7496,6 +7548,7 @@ function enter(){
7496
  if(MY_CLASSES.length>1&&!MY_CLASS) askClass(); // in several classes and none practised in before: which one?
7497
  if(AUTH_URL&&!can("path.use")){ if(can("games.use")||can("tutor.use")) showView("games"); else if(can("library.view")) showView("library"); }
7498
  if(new URLSearchParams(location.search).get("view")==="daily"){ history.replaceState(null,"",location.pathname); showView("daily"); } // from the morning reminder
 
7499
  }
7500
  $("startBtn").onclick=$("practiceBtn").onclick=enter;
7501
  $("libraryBtn").onclick=()=>{ enter(); showView("library"); };
 
325
  /* the way back to the path, at the head of the daily videos' screen (no tab of the foot's bar is lit there) */
326
  #vids button.back{display:inline-flex;align-self:flex-start;gap:6px;padding:6px 12px;border-radius:10px;background:var(--olive);border-color:var(--olive);color:var(--on-olive);font:700 15px var(--f-body)}
327
  #dailyDlg{width:min(92vw,380px)}
328
+ #pushDlg{width:min(92vw,380px);text-align:center}
329
+ #pushDlg .push-bell{font-size:44px;line-height:1;margin:4px 0 8px}
330
+ #pushDlg h2{font:400 24px/1.2 var(--f-display);margin:0 0 6px}
331
+ #pushDlg .row{justify-content:center}
332
  /* A topic's end: every sentence practised. A medal pops, confetti falls when it all went well, and a bead a sentence
333
  shows how each one went (gold 75+, green 60+, red under 60 and wobbling: those are offered for one more round). */
334
  #topicDoneDlg{width:min(92vw,400px);text-align:center;overflow:hidden}
 
1721
  <div class="picks" id="picks"></div>
1722
  <button class="btn" id="pickMates" hidden>ื”ืชืœืžื™ื“ื™ื ื‘ื›ื™ืชื”</button>
1723
  </dialog>
1724
+ <dialog id="pushDlg" aria-labelledby="pushTitle" aria-describedby="pushText">
1725
+ <div class="push-bell" aria-hidden="true">๐Ÿ””</div>
1726
+ <h2 id="pushTitle">ืชื–ื›ื•ืจืช ืœืกืจื˜ื•ืŸ ื”ื™ื•ืžื™</h2>
1727
+ <p class="dlg-text" id="pushText"></p>
1728
+ <div class="row"><button class="btn primary" id="pushYes">ื›ืŸ, ื”ื–ื›ื™ืจื• ืœื™</button><button class="btn" id="pushNo">ืœื ืขื›ืฉื™ื•</button></div>
1729
+ </dialog>
1730
  <dialog id="dailyDlg" aria-labelledby="dailyTitle">
1731
  <div class="dlg-head"><h2 id="dailyTitle"></h2></div>
1732
  <p class="dlg-text" id="dailyText"></p>
 
2029
  The library: videos that are not on the path, the same ones for every level and class. */
2030
  const dailyVideos=()=>{ const d=today(), seen=new Set(); return (FREE?TEACHER_DAILIES:(MY_CLASS?[MY_CLASS]:MY_CLASSES).flatMap(k=>k.dailies||[])).filter(v=>v.date<=d&&!seen.has(v.id)&&seen.add(v.id)).sort((a,b)=>b.date.localeCompare(a.date)); };
2031
  const libVideos=()=>LIBRARY;
2032
+ /* The daily video's morning reminder: a push notification the server sends (app/push.py), turned on on this device,
2033
+ from the prompt shown on entering (askPush) or from the button on the daily videos' screen (pushRow).
2034
  On an iPhone only the app added to the home screen may get them. */
2035
+ const PUSH_IOS=/iPhone|iPad|iPod/.test(navigator.userAgent)||(navigator.platform==="MacIntel"&&navigator.maxTouchPoints>1);
2036
+ const PUSH_STANDALONE=matchMedia("(display-mode: standalone)").matches||navigator.standalone===true;
2037
+ const PUSH_CAN="serviceWorker" in navigator&&"PushManager" in window&&"Notification" in window;
2038
+ let pushInfo=null; // the server's {key, hour}, read once
2039
+ async function pushState(){ // {key, hour, sub}: key is empty when the server sends no reminders
2040
+ if(!PUSH_CAN) return {key:"",hour:8,sub:null};
2041
+ pushInfo=pushInfo||await api("/api/push").then(r=>r.ok?r.json():{}).catch(()=>({}));
2042
+ const sub=await navigator.serviceWorker.ready.then(reg=>reg.pushManager.getSubscription()).catch(()=>null);
2043
+ return {key:pushInfo.key||"",hour:pushInfo.hour??8,sub};
2044
+ }
2045
+ async function pushOn(key){ // asks the permission (call it from a tap) and subscribes; the subscription, or null
2046
+ if(await Notification.requestPermission()!=="granted") return null;
2047
+ const reg=await navigator.serviceWorker.ready, raw=atob(key.replace(/-/g,"+").replace(/_/g,"/")+"=".repeat((4-key.length%4)%4));
2048
+ const sub=await reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:Uint8Array.from(raw,c=>c.charCodeAt(0))});
2049
+ const res=await api("/api/push/subscribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(sub.toJSON())});
2050
+ if(!res.ok){ await sub.unsubscribe().catch(()=>{}); throw new Error("not kept"); }
2051
+ api("/api/push/test",{method:"POST"}).catch(()=>{}); // one now, so the student sees what it looks like
2052
+ return sub;
2053
+ }
2054
+ async function pushOff(sub){
2055
+ await api("/api/push/unsubscribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({endpoint:sub.endpoint})}).catch(()=>{});
2056
+ await sub.unsubscribe().catch(()=>{});
2057
+ }
2058
  function pushRow(){
2059
  const row=mark("push-row",""), btn=mark("","","button"), note=mark("","","small"); row.append(btn,note);
 
2060
  const set=(text,on,why="")=>{ btn.textContent=text; btn.classList.toggle("on",!!on); note.textContent=why; };
2061
+ if(!PUSH_CAN){
2062
+ if(PUSH_IOS&&!PUSH_STANDALONE){ btn.hidden=true; note.textContent="๐Ÿ“ฑ ื›ื“ื™ ืœืงื‘ืœ ืชื–ื›ื•ืจืช ื›ืœ ื‘ื•ืงืจ: ืฉื™ืชื•ืฃ โ† โ€žื”ื•ืกืคื” ืœืžืกืš ื”ื‘ื™ืชโ€, ื•ืคืชื—ื• ืืช ื”ืืคืœื™ืงืฆื™ื” ืžืฉื."; }
2063
  else row.hidden=true;
2064
  return row;
2065
  }
2066
  let key="", sub=null, hour=8;
2067
  const draw=()=>{
2068
+ row.hidden=!key; if(!key) return;
 
2069
  if(Notification.permission==="denied") return set("๐Ÿ”• ื”ื”ืชืจืื•ืช ื—ืกื•ืžื•ืช",false,"ืืคืฉืจ ืœืืฉืจ ืื•ืชืŸ ื‘ื”ื’ื“ืจื•ืช ื”ืืชืจ ื‘ื“ืคื“ืคืŸ.");
2070
  if(sub) set("๐Ÿ”” ืชื–ื›ื•ืจืช ื›ืœ ื‘ื•ืงืจ ืคืขื™ืœื”",true,`ื‘ืฉืขื” ${hour}:00, ื‘ื™ื•ื ืฉื™ืฉ ืกืจื˜ื•ืŸ ยท ืœื—ืฆื• ืœื‘ื™ื˜ื•ืœ`);
2071
  else set("๐Ÿ”” ื”ื–ื›ื™ืจื• ืœื™ ื›ืœ ื‘ื•ืงืจ",false,"ื”ืชืจืื” ื›ืฉื™ืฉ ืกืจื˜ื•ืŸ ื—ื“ืฉ");
2072
  };
2073
  row.hidden=true;
2074
+ pushState().then(st=>{ ({key,hour,sub}=st); draw(); });
 
2075
  btn.onclick=async()=>{
2076
  btn.disabled=true;
2077
+ try{ if(sub){ await pushOff(sub); sub=null; } else sub=await pushOn(key); draw(); }
2078
+ catch(e){ draw(); note.textContent="ืœื ื”ืฆืœื—ื ื• ืœื”ืคืขื™ืœ ืืช ื”ืชื–ื›ื•ืจืช. ื ืกื• ืฉื•ื‘ ืžืื•ื—ืจ ื™ื•ืชืจ."; }
 
 
 
 
 
 
 
 
 
 
 
 
 
2079
  finally{ btn.disabled=false; }
2080
  };
2081
  return row;
2082
  }
2083
+ /* On entering, once: a prompt to turn the reminder on. "Not now" asks again after PUSH_AGAIN days; it waits for any
2084
+ dialog already open (choosing a class) to close. */
2085
+ const PUSH_AGAIN=7;
2086
+ function askPushSoon(){
2087
+ if(!AUTH_URL) return;
2088
+ setTimeout(function again(){
2089
+ const open=document.querySelector("dialog[open]");
2090
+ if(open) open.addEventListener("close",()=>setTimeout(again,500),{once:true}); else askPush();
2091
+ },900);
2092
+ }
2093
+ async function askPush(){
2094
+ const last=store.get("ihki.pushAsked",""), dlg=$("pushDlg"), yes=$("pushYes"), no=$("pushNo"), text=$("pushText");
2095
+ if(last&&(last==="never"||(Date.now()-new Date(last))/864e5<PUSH_AGAIN)) return;
2096
+ const later=()=>{ store.set("ihki.pushAsked",new Date().toISOString().slice(0,10)); dlg.close(); };
2097
+ no.onclick=later; dlg.oncancel=later;
2098
+ if(!PUSH_CAN){ // an iPhone's browser: only the app on the home screen gets them
2099
+ if(!PUSH_IOS||PUSH_STANDALONE) return;
2100
+ text.innerHTML="ื‘ืื™ื™ืคื•ืŸ ื”ื”ืชืจืื•ืช ืžื’ื™ืขื•ืช ืจืง ืžื”ืืคืœื™ืงืฆื™ื” ืฉืขืœ ืžืกืš ื”ื‘ื™ืช:<br>ืœื—ืฆื• ืขืœ <b>ืฉื™ืชื•ืฃ</b> โฌ†๏ธ โ† <b>โ€žื”ื•ืกืคื” ืœืžืกืš ื”ื‘ื™ืชโ€</b>, ื•ืคืชื—ื• ืืช ื”ืืคืœื™ืงืฆื™ื” ืžืฉื.";
2101
+ yes.hidden=true; no.textContent="ื”ื‘ื ืชื™"; dlg.showModal(); return;
2102
+ }
2103
+ const st=await pushState();
2104
+ if(!st.key||st.sub||Notification.permission!=="default"||document.querySelector("dialog[open]")) return;
2105
+ text.textContent=`ื‘ื›ืœ ื‘ื•ืงืจ ืฉื™ืฉ ืกืจื˜ื•ืŸ ื™ื•ืžื™ ื—ื“ืฉ, ื ืฉืœื— ืœื›ื ื”ืชืจืื” ื‘ืฉืขื” ${st.hour}:00, ื’ื ื›ืฉื”ืืคืœื™ืงืฆื™ื” ืกื’ื•ืจื”.`;
2106
+ yes.hidden=false; no.textContent="ืœื ืขื›ืฉื™ื•";
2107
+ yes.onclick=async()=>{
2108
+ yes.disabled=true;
2109
+ try{
2110
+ const sub=await pushOn(st.key);
2111
+ store.set("ihki.pushAsked",sub?"never":new Date().toISOString().slice(0,10)); dlg.close();
2112
+ }catch(e){ text.textContent="ืœื ื”ืฆืœื—ื ื• ืœื”ืคืขื™ืœ ืืช ื”ืชื–ื›ื•ืจืช. ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘ ืžืžืกืš โ€žืกืจื˜ื•ืŸ ื™ื•ืžื™โ€."; yes.hidden=true; no.textContent="ืกื’ื™ืจื”"; }
2113
+ finally{ yes.disabled=false; }
2114
+ };
2115
+ dlg.showModal(); yes.focus();
2116
+ }
2117
  const VERB_GAME_VISIBLE=false; // "ืคื•ืขืœ ื™ื•ืฆื" is still under development; flip to true to publish it again.
2118
  const EITAN_GAME_VISIBLE=false; // "ื‘ื•ื ื™ื ืคื•ืขืœ ืขื ืื™ืชืŸ" (ืคื•ืขืœ ื™ื•ืฆื's levels 6โ€“8, on their own): teachers see it; true shows it to students.
2119
  function drawNav(){ // the tabs, and the screen of videos when one is on show
 
7548
  if(MY_CLASSES.length>1&&!MY_CLASS) askClass(); // in several classes and none practised in before: which one?
7549
  if(AUTH_URL&&!can("path.use")){ if(can("games.use")||can("tutor.use")) showView("games"); else if(can("library.view")) showView("library"); }
7550
  if(new URLSearchParams(location.search).get("view")==="daily"){ history.replaceState(null,"",location.pathname); showView("daily"); } // from the morning reminder
7551
+ askPushSoon();
7552
  }
7553
  $("startBtn").onclick=$("practiceBtn").onclick=enter;
7554
  $("libraryBtn").onclick=()=>{ enter(); showView("library"); };