feat(push): implement daily video reminder push notifications and user prompts
Browse files- 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
|
|
|
|
| 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(!
|
| 2029 |
-
if(
|
| 2030 |
else row.hidden=true;
|
| 2031 |
return row;
|
| 2032 |
}
|
| 2033 |
let key="", sub=null, hour=8;
|
| 2034 |
const draw=()=>{
|
| 2035 |
-
if(!key) return
|
| 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 |
-
|
| 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 |
-
|
| 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"); };
|