From 323a7f26db0ead7f11490282ee6b5ec02d4b5db1 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 27 Jun 2020 20:21:41 +0000 Subject: [PATCH 01/15] Local time clock --- static/remote.js | 6 ++++++ static/remote.ts | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/static/remote.js b/static/remote.js index b631402..d881411 100644 --- a/static/remote.js +++ b/static/remote.js @@ -100,6 +100,12 @@ function renderControls(roomId, clientId, adminSecret, prnt, es) { controls.classList.remove("enable"); } }); + const clockDiv = create(prnt, "div", "\u00a0Time: "); + const clock = create(clockDiv, "span"); + setInterval(() => { + const now = new Date(); + clock.innerText = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`; + }, 250); } function renderAdmin(roomId, adminSecret, prnt, es) { const table = create(prnt, "table", undefined, ["users"]); diff --git a/static/remote.ts b/static/remote.ts index 01c2b7a..905d142 100644 --- a/static/remote.ts +++ b/static/remote.ts @@ -180,6 +180,13 @@ function renderControls(roomId: string, clientId: string, adminSecret: string | controls.classList.remove("enable"); } }); + + const clockDiv = create(prnt, "div", "\u00a0Time: "); + const clock = create(clockDiv, "span"); + setInterval(() => { + const now = new Date(); + clock.innerText = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`; + }, 250); } function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: EventSource) { From ade233fe47165ec90d67e50724af2094015cdffd Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 27 Jun 2020 21:18:19 +0000 Subject: [PATCH 02/15] Overall timer --- main.go | 40 +++++++++++++++++++++++++++++++++ static/remote.css | 5 +++++ static/remote.js | 41 ++++++++++++++++++++++++++++++++-- static/remote.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 139 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 6d27c99..6a8fcdd 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,11 @@ type adminRequest struct { PublicClientId string `json:"public_client_id"` } +type resetRequest struct { + RoomId string `json:"room_id"` + AdminSecret string `json:"admin_secret"` +} + type announceRequest struct { RoomId string `json:"room_id"` ClientId string `json:"client_id"` @@ -79,6 +84,7 @@ type adminEvent struct { type standardEvent struct { Active bool `json:"active"` + TimerStart int64 `json:"timer_start"` AdminSecret string `json:"admin_secret"` } @@ -88,6 +94,7 @@ type controlEvent struct { type room struct { roomId string + timerStart time.Time clientById map[string]*client clientByPublicId map[string]*client present map[*presentState]bool @@ -140,6 +147,7 @@ func main() { http.HandleFunc("/api/create", create) http.HandleFunc("/api/present", present) http.HandleFunc("/api/remove", remove) + http.HandleFunc("/api/reset", reset) http.HandleFunc("/api/watch", watch) server := http.Server{ @@ -386,6 +394,29 @@ func remove(w http.ResponseWriter, r *http.Request) { c.remove() } +func reset(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + req := &resetRequest{} + + err := json.NewDecoder(r.Body).Decode(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + rm := getRoom(req.RoomId) + + if req.AdminSecret != rm.adminSecret() { + http.Error(w, "invalid admin_secret", http.StatusBadRequest) + return + } + + rm.timerStart = time.Now() + rm.updateAllClients() +} + func watch(w http.ResponseWriter, r *http.Request) { ws := newWatchState(w, r) if ws == nil { @@ -436,6 +467,7 @@ func (c *client) update() { e := &event{ StandardEvent: &standardEvent{ Active: c.Active, + TimerStart: c.room.timerStart.Unix(), }, } if c.Admin { @@ -447,6 +479,7 @@ func (c *client) update() { func newRoom(roomId string) *room { return &room{ roomId: roomId, + timerStart: time.Now(), clientById: map[string]*client{}, clientByPublicId: map[string]*client{}, present: map[*presentState]bool{}, @@ -505,6 +538,12 @@ func (rm *room) sendControlEvent(ce *controlEvent) { } } +func (rm *room) updateAllClients() { + for _, client := range rm.clientById { + client.update() + } +} + func newWatchState(w http.ResponseWriter, r *http.Request) *watchState { mu.Lock() defer mu.Unlock() @@ -539,6 +578,7 @@ func newWatchState(w http.ResponseWriter, r *http.Request) *watchState { } ws.client.eventChan = ws.eventChan + ws.client.update() w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") diff --git a/static/remote.css b/static/remote.css index c58b1d9..376b04b 100644 --- a/static/remote.css +++ b/static/remote.css @@ -78,6 +78,11 @@ tfoot tr { cursor: pointer; } +.action { + cursor: pointer; + margin-left: 10px; +} + .github { bottom: 0; color: var(--subtle-color); diff --git a/static/remote.js b/static/remote.js index d881411..f530537 100644 --- a/static/remote.js +++ b/static/remote.js @@ -63,6 +63,7 @@ function watch(roomId, clientId, adminSecret, prnt) { } const es = new EventSource(url.toString()); renderControls(roomId, clientId, adminSecret, prnt, es); + renderTimers(roomId, adminSecret, prnt, es); if (adminSecret) { renderAdmin(roomId, adminSecret, prnt, es); } @@ -100,11 +101,47 @@ function renderControls(roomId, clientId, adminSecret, prnt, es) { controls.classList.remove("enable"); } }); - const clockDiv = create(prnt, "div", "\u00a0Time: "); +} +function renderTimers(roomId, adminSecret, prnt, es) { + let overallStart = null; + es.addEventListener("message", (e) => { + const event = JSON.parse(e.data); + if (!event.standard_event) { + return; + } + overallStart = parseInt(event.standard_event.timer_start || "0", 10) || null; + }); + const width = 10; + const clockDiv = create(prnt, "div", "Clock: ".padStart(width, "\u00a0")); const clock = create(clockDiv, "span"); + const overallDiv = create(prnt, "div", "Overall: ".padStart(width, "\u00a0")); + const overall = create(overallDiv, "span"); + if (adminSecret) { + const reset = create(overallDiv, "span", "↺", ["action"]); + reset.addEventListener("click", () => { + const req = { + room_id: roomId, + admin_secret: adminSecret, + }; + fetch("/api/reset", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(req), + }); + }); + } setInterval(() => { const now = new Date(); - clock.innerText = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`; + clock.innerText = `${now.getHours().toString().padStart(2, "0")}h${now.getMinutes().toString().padStart(2, "0")}m${now.getSeconds().toString().padStart(2, "0")}s`; + if (overallStart) { + const o = Math.trunc(now.getTime() / 1000 - overallStart); + overall.innerText = `${Math.trunc(o / 3600).toString().padStart(2, "0")}h${Math.trunc(o % 3600 / 60).toString().padStart(2, "0")}m${Math.trunc(o % 60).toString().padStart(2, "0")}s`; + } + else { + overall.innerText = ""; + } }, 250); } function renderAdmin(roomId, adminSecret, prnt, es) { diff --git a/static/remote.ts b/static/remote.ts index 905d142..ce85e86 100644 --- a/static/remote.ts +++ b/static/remote.ts @@ -34,12 +34,18 @@ interface RemoveRequest { client_id: string; } +interface ResetRequest { + room_id: string; + admin_secret: string; +} + interface Event { standard_event?: StandardEvent; admin_event?: AdminEvent; } interface StandardEvent { + timer_start?: string; active?: boolean; admin_secret?: string; } @@ -135,6 +141,8 @@ function watch(roomId: string, clientId: string, adminSecret: string | null, prn renderControls(roomId, clientId, adminSecret, prnt, es); + renderTimers(roomId, adminSecret, prnt, es); + if (adminSecret) { renderAdmin(roomId, adminSecret, prnt, es); } @@ -180,12 +188,57 @@ function renderControls(roomId: string, clientId: string, adminSecret: string | controls.classList.remove("enable"); } }); +} - const clockDiv = create(prnt, "div", "\u00a0Time: "); +function renderTimers(roomId: string, adminSecret: string | null, prnt: HTMLElement, es: EventSource) { + let overallStart: number | null = null; + + es.addEventListener("message", (e) => { + const event = JSON.parse(e.data) as Event; + + if (!event.standard_event) { + return; + } + + overallStart = parseInt(event.standard_event.timer_start || "0", 10) || null; + }); + + const width = 10; + + const clockDiv = create(prnt, "div", "Clock: ".padStart(width, "\u00a0")); const clock = create(clockDiv, "span"); + + const overallDiv = create(prnt, "div", "Overall: ".padStart(width, "\u00a0")); + const overall = create(overallDiv, "span"); + + if (adminSecret) { + const reset = create(overallDiv, "span", "↺", ["action"]); + reset.addEventListener("click", () => { + const req: ResetRequest = { + room_id: roomId, + admin_secret: adminSecret, + }; + + fetch("/api/reset", { + method: "POST", + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(req), + }); + }); + } + setInterval(() => { const now = new Date(); - clock.innerText = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}:${now.getSeconds().toString().padStart(2, "0")}`; + clock.innerText = `${now.getHours().toString().padStart(2, "0")}h${now.getMinutes().toString().padStart(2, "0")}m${now.getSeconds().toString().padStart(2, "0")}s`; + + if (overallStart) { + const o = Math.trunc(now.getTime() / 1000 - overallStart); + overall.innerText = `${Math.trunc(o / 3600).toString().padStart(2, "0")}h${Math.trunc(o % 3600 / 60).toString().padStart(2, "0")}m${Math.trunc(o % 60).toString().padStart(2, "0")}s`; + } else { + overall.innerText = ""; + } }, 250); } From 3a015ecfd8364a0b4ce2539ad0939a008e7bdfb4 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 27 Jun 2020 21:19:15 +0000 Subject: [PATCH 03/15] Make reset button highlight color --- static/remote.css | 1 + 1 file changed, 1 insertion(+) diff --git a/static/remote.css b/static/remote.css index 376b04b..4f2cb66 100644 --- a/static/remote.css +++ b/static/remote.css @@ -81,6 +81,7 @@ tfoot tr { .action { cursor: pointer; margin-left: 10px; + color: var(--highlight-color); } .github { From 31509efd16f3df5d4de16d7751b2dc0314d893ff Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 27 Jun 2020 21:48:27 +0000 Subject: [PATCH 04/15] Personal presentation timer --- main.go | 8 ++++++++ static/remote.css | 6 +++++- static/remote.js | 29 +++++++++++++++++++++++++++++ static/remote.ts | 37 ++++++++++++++++++++++++++++++++++++- 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 6a8fcdd..04a5f37 100644 --- a/main.go +++ b/main.go @@ -65,6 +65,7 @@ type client struct { Name string `json:"name"` Admin bool `json:"admin"` Active bool `json:"active"` + ActiveStart int64 `json:"active_start"` clientId string room *room @@ -84,6 +85,7 @@ type adminEvent struct { type standardEvent struct { Active bool `json:"active"` + ActiveStart int64 `json:"active_start"` TimerStart int64 `json:"timer_start"` AdminSecret string `json:"admin_secret"` } @@ -223,6 +225,11 @@ func active(w http.ResponseWriter, r *http.Request) { } c.Active = req.Active + if c.Active { + c.ActiveStart = time.Now().Unix() + } else { + c.ActiveStart = 0 + } c.update() rm.sendAdminEvent(&adminEvent{ Client: c, @@ -467,6 +474,7 @@ func (c *client) update() { e := &event{ StandardEvent: &standardEvent{ Active: c.Active, + ActiveStart: c.ActiveStart, TimerStart: c.room.timerStart.Unix(), }, } diff --git a/static/remote.css b/static/remote.css index 4f2cb66..80d6018 100644 --- a/static/remote.css +++ b/static/remote.css @@ -79,9 +79,13 @@ tfoot tr { } .action { + color: var(--highlight-color); cursor: pointer; margin-left: 10px; - color: var(--highlight-color); +} + +.users tbody tr td:nth-child(2) { + text-align: right; } .github { diff --git a/static/remote.js b/static/remote.js index f530537..0fef224 100644 --- a/static/remote.js +++ b/static/remote.js @@ -104,18 +104,22 @@ function renderControls(roomId, clientId, adminSecret, prnt, es) { } function renderTimers(roomId, adminSecret, prnt, es) { let overallStart = null; + let meStart = null; es.addEventListener("message", (e) => { const event = JSON.parse(e.data); if (!event.standard_event) { return; } overallStart = parseInt(event.standard_event.timer_start || "0", 10) || null; + meStart = parseInt(event.standard_event.active_start || "0", 10) || null; }); const width = 10; const clockDiv = create(prnt, "div", "Clock: ".padStart(width, "\u00a0")); const clock = create(clockDiv, "span"); const overallDiv = create(prnt, "div", "Overall: ".padStart(width, "\u00a0")); const overall = create(overallDiv, "span"); + const meDiv = create(prnt, "div", "Me: ".padStart(width, "\u00a0")); + const me = create(meDiv, "span"); if (adminSecret) { const reset = create(overallDiv, "span", "↺", ["action"]); reset.addEventListener("click", () => { @@ -142,6 +146,13 @@ function renderTimers(roomId, adminSecret, prnt, es) { else { overall.innerText = ""; } + if (meStart) { + const d = Math.trunc(now.getTime() / 1000 - meStart); + me.innerText = `${Math.trunc(d / 3600).toString().padStart(2, "0")}h${Math.trunc(d % 3600 / 60).toString().padStart(2, "0")}m${Math.trunc(d % 60).toString().padStart(2, "0")}s`; + } + else { + me.innerText = ""; + } }, 250); } function renderAdmin(roomId, adminSecret, prnt, es) { @@ -149,10 +160,15 @@ function renderAdmin(roomId, adminSecret, prnt, es) { const head = create(table, "thead"); const head1 = create(head, "tr"); create(head1, "th", "Name"); + create(head1, "th", "Active Time"); create(head1, "th", "👑"); create(head1, "th", "👆"); const body = create(table, "tbody"); const rows = new Map(); + es.addEventListener("open", () => { + rows.clear(); + body.innerHTML = ""; + }); es.addEventListener("message", (e) => { const event = JSON.parse(e.data); if (!event.admin_event) { @@ -169,6 +185,7 @@ function renderAdmin(roomId, adminSecret, prnt, es) { } row = document.createElement("tr"); row.dataset.name = client.name; + row.dataset.activeStart = client.active_start; let before = null; for (const iter of body.children) { const iterRow = iter; @@ -179,6 +196,7 @@ function renderAdmin(roomId, adminSecret, prnt, es) { } body.insertBefore(row, before); create(row, "td", client.name); + create(row, "td"); const adminCell = create(row, "td", "👑", client.admin ? ["admin", "enable"] : ["admin"]); adminCell.addEventListener("click", () => { if (!client.admin) { @@ -194,6 +212,17 @@ function renderAdmin(roomId, adminSecret, prnt, es) { }); rows.set(client.public_client_id, row); }); + setInterval(() => { + const now = new Date(); + for (const row of rows.values()) { + const cell = row.children[1]; + const as = parseInt(row.dataset.activeStart || "0", 10) || null; + if (as) { + const d = Math.trunc(now.getTime() / 1000 - as); + cell.innerText = `${Math.trunc(d / 3600).toString().padStart(2, "0")}h${Math.trunc(d % 3600 / 60).toString().padStart(2, "0")}m${Math.trunc(d % 60).toString().padStart(2, "0")}s`; + } + } + }, 250); } function active(roomId, adminSecret, publicClientId, val) { const req = { diff --git a/static/remote.ts b/static/remote.ts index ce85e86..fb5296f 100644 --- a/static/remote.ts +++ b/static/remote.ts @@ -47,6 +47,7 @@ interface Event { interface StandardEvent { timer_start?: string; active?: boolean; + active_start?: string; admin_secret?: string; } @@ -60,6 +61,7 @@ interface Client { name: string; admin: boolean; active: boolean; + active_start: string; } function main() { @@ -191,7 +193,8 @@ function renderControls(roomId: string, clientId: string, adminSecret: string | } function renderTimers(roomId: string, adminSecret: string | null, prnt: HTMLElement, es: EventSource) { - let overallStart: number | null = null; + let overallStart: number | null = null; + let meStart: number | null = null; es.addEventListener("message", (e) => { const event = JSON.parse(e.data) as Event; @@ -201,6 +204,7 @@ function renderTimers(roomId: string, adminSecret: string | null, prnt: HTMLElem } overallStart = parseInt(event.standard_event.timer_start || "0", 10) || null; + meStart = parseInt(event.standard_event.active_start || "0", 10) || null; }); const width = 10; @@ -211,6 +215,9 @@ function renderTimers(roomId: string, adminSecret: string | null, prnt: HTMLElem const overallDiv = create(prnt, "div", "Overall: ".padStart(width, "\u00a0")); const overall = create(overallDiv, "span"); + const meDiv = create(prnt, "div", "Me: ".padStart(width, "\u00a0")); + const me = create(meDiv, "span"); + if (adminSecret) { const reset = create(overallDiv, "span", "↺", ["action"]); reset.addEventListener("click", () => { @@ -239,6 +246,13 @@ function renderTimers(roomId: string, adminSecret: string | null, prnt: HTMLElem } else { overall.innerText = ""; } + + if (meStart) { + const d = Math.trunc(now.getTime() / 1000 - meStart); + me.innerText = `${Math.trunc(d / 3600).toString().padStart(2, "0")}h${Math.trunc(d % 3600 / 60).toString().padStart(2, "0")}m${Math.trunc(d % 60).toString().padStart(2, "0")}s`; + } else { + me.innerText = ""; + } }, 250); } @@ -247,6 +261,7 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: const head = create(table, "thead"); const head1 = create(head, "tr"); create(head1, "th", "Name"); + create(head1, "th", "Active Time"); create(head1, "th", "👑"); create(head1, "th", "👆"); @@ -254,6 +269,11 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: const rows: Map = new Map(); + es.addEventListener("open", () => { + rows.clear(); + body.innerHTML = ""; + }); + es.addEventListener("message", (e) => { const event = JSON.parse(e.data) as Event; @@ -275,6 +295,7 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: row = document.createElement("tr") as HTMLTableRowElement; row.dataset.name = client.name; + row.dataset.activeStart = client.active_start; let before = null; for (const iter of body.children) { @@ -287,6 +308,7 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: body.insertBefore(row, before); create(row, "td", client.name); + create(row, "td"); const adminCell = create(row, "td", "👑", client.admin ? ["admin", "enable"] : ["admin"]) as HTMLTableCellElement; adminCell.addEventListener("click", () => { @@ -305,6 +327,19 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: rows.set(client.public_client_id, row); }); + + setInterval(() => { + const now = new Date(); + + for (const row of rows.values()) { + const cell = row.children[1] as HTMLTableCellElement; + const as = parseInt(row.dataset.activeStart || "0", 10) || null; + if (as) { + const d = Math.trunc(now.getTime() / 1000 - as); + cell.innerText = `${Math.trunc(d / 3600).toString().padStart(2, "0")}h${Math.trunc(d % 3600 / 60).toString().padStart(2, "0")}m${Math.trunc(d % 60).toString().padStart(2, "0")}s`; + } + } + }, 250); } function active(roomId: string, adminSecret: string, publicClientId: string, val: boolean) { From 650c835234b4bdd487aaa27b239ebdbe3b1febc1 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Tue, 24 Nov 2020 23:04:34 +0000 Subject: [PATCH 05/15] Configurable bind address, support for paths --- main.go | 3 ++- static/remote.js | 16 ++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/main.go b/main.go index 04a5f37..593cb21 100644 --- a/main.go +++ b/main.go @@ -128,6 +128,7 @@ func main() { rand.Seed(time.Now().UnixNano()) keyFlag := flag.String("key", "", "secret key") + bindFlag := flag.String("bind", ":2000", "host:port to listen on") flag.Parse() @@ -153,7 +154,7 @@ func main() { http.HandleFunc("/api/watch", watch) server := http.Server{ - Addr: ":2000", + Addr: *bindFlag, } err := server.ListenAndServe() if err != nil { diff --git a/static/remote.js b/static/remote.js index 0fef224..fb2e06a 100644 --- a/static/remote.js +++ b/static/remote.js @@ -26,7 +26,7 @@ function renderRoom(roomId) { watch(roomId, clientId, adminSecret, prnt); } function newRoom() { - fetch("/api/create", { method: "POST" }) + fetch("api/create", { method: "POST" }) .then(resp => resp.json()) .then(data => { const resp = data; @@ -43,7 +43,7 @@ function announce(roomId, clientId, adminSecret, name) { admin_secret: adminSecret, name: name.value, }; - fetch("/api/announce", { + fetch("api/announce", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -55,7 +55,7 @@ function announce(roomId, clientId, adminSecret, name) { }); } function watch(roomId, clientId, adminSecret, prnt) { - const url = new URL("/api/watch", location.href); + const url = new URL("api/watch", location.href); url.searchParams.set("room_id", roomId); url.searchParams.set("client_id", clientId); if (adminSecret) { @@ -127,7 +127,7 @@ function renderTimers(roomId, adminSecret, prnt, es) { room_id: roomId, admin_secret: adminSecret, }; - fetch("/api/reset", { + fetch("api/reset", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -231,7 +231,7 @@ function active(roomId, adminSecret, publicClientId, val) { public_client_id: publicClientId, active: val, }; - fetch("/api/active", { + fetch("api/active", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -245,7 +245,7 @@ function admin(roomId, adminSecret, publicClientId) { admin_secret: adminSecret, public_client_id: publicClientId, }; - fetch("/api/admin", { + fetch("api/admin", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -262,7 +262,7 @@ function control(roomId, clientId, controls, ctrl) { client_id: clientId, control: ctrl, }; - fetch("/api/control", { + fetch("api/control", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -286,7 +286,7 @@ function remove(roomId, clientId) { room_id: roomId, client_id: clientId, }; - navigator.sendBeacon("/api/remove", JSON.stringify(req)); + navigator.sendBeacon("api/remove", JSON.stringify(req)); } function uuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { From 6c4ec676fef866015364622be1cbf3ae64dfd6fd Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 25 Nov 2020 20:13:39 +0000 Subject: [PATCH 06/15] Monotonic time for stale removal --- main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 593cb21..2cbcdf1 100644 --- a/main.go +++ b/main.go @@ -189,11 +189,11 @@ func scan() { mu.Lock() defer mu.Unlock() - cutoff := time.Now().UTC().Add(-30 * time.Second) + grace := 30 * time.Second for _, rm := range roomById { for _, c := range rm.clientById { - if c.lastSeen.Before(cutoff) { + if time.Now().Sub(c.lastSeen) > grace { c.remove() } } From f142f4689b9407b5a46de1f78be7f440360f6e54 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 25 Nov 2020 20:52:04 +0000 Subject: [PATCH 07/15] Fix announce after network blip --- static/remote.js | 3 +++ static/remote.ts | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/static/remote.js b/static/remote.js index fb2e06a..9b4ae87 100644 --- a/static/remote.js +++ b/static/remote.js @@ -52,6 +52,9 @@ function announce(roomId, clientId, adminSecret, name) { }) .then(() => { setTimeout(() => announce(roomId, clientId, adminSecret, name), 5000); + }) + .catch(() => { + setTimeout(() => announce(roomId, clientId, adminSecret, name), 5000); }); } function watch(roomId, clientId, adminSecret, prnt) { diff --git a/static/remote.ts b/static/remote.ts index fb5296f..3c296ad 100644 --- a/static/remote.ts +++ b/static/remote.ts @@ -99,7 +99,7 @@ function renderRoom(roomId: string) { } function newRoom() { - fetch("/api/create", {method: "POST"}) + fetch("api/create", {method: "POST"}) .then(resp => resp.json()) .then(data => { const resp = data as CreateResponse; @@ -120,7 +120,7 @@ function announce(roomId: string, clientId: string, adminSecret: string | null, name: name.value, }; - fetch("/api/announce", { + fetch("api/announce", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -129,11 +129,14 @@ function announce(roomId: string, clientId: string, adminSecret: string | null, }) .then(() => { setTimeout(() => announce(roomId, clientId, adminSecret, name), 5000); + }) + .catch(() => { + setTimeout(() => announce(roomId, clientId, adminSecret, name), 5000); }); } function watch(roomId: string, clientId: string, adminSecret: string | null, prnt: HTMLElement) { - const url = new URL("/api/watch", location.href); + const url = new URL("api/watch", location.href); url.searchParams.set("room_id", roomId); url.searchParams.set("client_id", clientId); if (adminSecret) { @@ -226,7 +229,7 @@ function renderTimers(roomId: string, adminSecret: string | null, prnt: HTMLElem admin_secret: adminSecret, }; - fetch("/api/reset", { + fetch("api/reset", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -350,7 +353,7 @@ function active(roomId: string, adminSecret: string, publicClientId: string, val active: val, }; - fetch("/api/active", { + fetch("api/active", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -366,7 +369,7 @@ function admin(roomId: string, adminSecret: string, publicClientId: string) { public_client_id: publicClientId, }; - fetch("/api/admin", { + fetch("api/admin", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -386,7 +389,7 @@ function control(roomId: string, clientId: string, controls: HTMLElement, ctrl: control: ctrl, }; - fetch("/api/control", { + fetch("api/control", { method: "POST", headers: { 'Content-Type': 'application/json' @@ -415,7 +418,7 @@ function remove(roomId: string, clientId: string) { room_id: roomId, client_id: clientId, } - navigator.sendBeacon("/api/remove", JSON.stringify(req)); + navigator.sendBeacon("api/remove", JSON.stringify(req)); } function uuid() { From a1a74b5f8fe2655070c099eda96c90f617e3d045 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 25 Nov 2020 21:15:34 +0000 Subject: [PATCH 08/15] Drop public client IDs for simplicity --- main.go | 24 +++++++++--------------- static/remote.js | 18 +++++++++--------- static/remote.ts | 24 ++++++++++++------------ 3 files changed, 30 insertions(+), 36 deletions(-) diff --git a/main.go b/main.go index 2cbcdf1..ef94aa2 100644 --- a/main.go +++ b/main.go @@ -22,14 +22,14 @@ import ( type activeRequest struct { RoomId string `json:"room_id"` AdminSecret string `json:"admin_secret"` - PublicClientId string `json:"public_client_id"` + ClientId string `json:"client_id"` Active bool `json:"active"` } type adminRequest struct { RoomId string `json:"room_id"` AdminSecret string `json:"admin_secret"` - PublicClientId string `json:"public_client_id"` + ClientId string `json:"client_id"` } type resetRequest struct { @@ -61,13 +61,12 @@ type removeRequest struct { } type client struct { - PublicClientId string `json:"public_client_id"` + ClientId string `json:"client_id"` Name string `json:"name"` Admin bool `json:"admin"` Active bool `json:"active"` ActiveStart int64 `json:"active_start"` - clientId string room *room lastSeen time.Time eventChan chan *event @@ -98,7 +97,6 @@ type room struct { roomId string timerStart time.Time clientById map[string]*client - clientByPublicId map[string]*client present map[*presentState]bool } @@ -219,9 +217,9 @@ func active(w http.ResponseWriter, r *http.Request) { return } - c := rm.clientByPublicId[req.PublicClientId] + c := rm.clientById[req.ClientId] if c == nil { - http.Error(w, "invalid public_client_id", http.StatusBadRequest) + http.Error(w, "invalid client_id", http.StatusBadRequest) return } @@ -256,9 +254,9 @@ func admin(w http.ResponseWriter, r *http.Request) { return } - c := rm.clientByPublicId[req.PublicClientId] + c := rm.clientById[req.ClientId] if c == nil { - http.Error(w, "invalid public_client_id", http.StatusBadRequest) + http.Error(w, "invalid client_id", http.StatusBadRequest) return } @@ -462,8 +460,7 @@ func (c *client) sendEvent(e *event) { } func (c *client) remove() { - delete(c.room.clientById, c.clientId) - delete(c.room.clientByPublicId, c.PublicClientId) + delete(c.room.clientById, c.ClientId) c.room.sendAdminEvent(&adminEvent{ Client: c, @@ -490,7 +487,6 @@ func newRoom(roomId string) *room { roomId: roomId, timerStart: time.Now(), clientById: map[string]*client{}, - clientByPublicId: map[string]*client{}, present: map[*presentState]bool{}, } } @@ -513,12 +509,10 @@ func (rm *room) getClient(clientId string) *client { c := rm.clientById[clientId] if c == nil { c = &client{ - clientId: clientId, - PublicClientId: uuid.New().String(), + ClientId: clientId, room: rm, } rm.clientById[clientId] = c - rm.clientByPublicId[c.PublicClientId] = c rm.sendAdminEvent(&adminEvent{ Client: c, diff --git a/static/remote.js b/static/remote.js index 9b4ae87..cf7a742 100644 --- a/static/remote.js +++ b/static/remote.js @@ -178,10 +178,10 @@ function renderAdmin(roomId, adminSecret, prnt, es) { return; } const client = event.admin_event.client; - let row = rows.get(client.public_client_id); + let row = rows.get(client.client_id); if (row) { row.remove(); - rows.delete(client.public_client_id); + rows.delete(client.client_id); } if (event.admin_event.remove) { return; @@ -206,14 +206,14 @@ function renderAdmin(roomId, adminSecret, prnt, es) { if (!confirm(`Grant admin access to ${client.name}?`)) { return; } - admin(roomId, adminSecret, client.public_client_id); + admin(roomId, adminSecret, client.client_id); } }); const activeCell = create(row, "td", "👆", client.active ? ["active", "enable"] : ["active"]); activeCell.addEventListener("click", () => { - active(roomId, adminSecret, client.public_client_id, !activeCell.classList.contains("enable")); + active(roomId, adminSecret, client.client_id, !activeCell.classList.contains("enable")); }); - rows.set(client.public_client_id, row); + rows.set(client.client_id, row); }); setInterval(() => { const now = new Date(); @@ -227,11 +227,11 @@ function renderAdmin(roomId, adminSecret, prnt, es) { } }, 250); } -function active(roomId, adminSecret, publicClientId, val) { +function active(roomId, adminSecret, clientId, val) { const req = { room_id: roomId, admin_secret: adminSecret, - public_client_id: publicClientId, + client_id: clientId, active: val, }; fetch("api/active", { @@ -242,11 +242,11 @@ function active(roomId, adminSecret, publicClientId, val) { body: JSON.stringify(req), }); } -function admin(roomId, adminSecret, publicClientId) { +function admin(roomId, adminSecret, clientId) { const req = { room_id: roomId, admin_secret: adminSecret, - public_client_id: publicClientId, + client_id: clientId, }; fetch("api/admin", { method: "POST", diff --git a/static/remote.ts b/static/remote.ts index 3c296ad..a4b2afd 100644 --- a/static/remote.ts +++ b/static/remote.ts @@ -1,14 +1,14 @@ interface ActiveRequest { room_id: string; admin_secret: string; - public_client_id: string; + client_id: string; active: boolean; } interface AdminRequest { room_id: string; admin_secret: string; - public_client_id: string; + client_id: string; } interface AnnounceRequest { @@ -57,7 +57,7 @@ interface AdminEvent { } interface Client { - public_client_id: string; + client_id: string; name: string; admin: boolean; active: boolean; @@ -285,11 +285,11 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: } const client = event.admin_event.client; - let row = rows.get(client.public_client_id); + let row = rows.get(client.client_id); if (row) { row.remove(); - rows.delete(client.public_client_id); + rows.delete(client.client_id); } if (event.admin_event.remove) { @@ -319,16 +319,16 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: if (!confirm(`Grant admin access to ${client.name}?`)) { return; } - admin(roomId, adminSecret, client.public_client_id); + admin(roomId, adminSecret, client.client_id); } }); const activeCell = create(row, "td", "👆", client.active ? ["active", "enable"] : ["active"]) as HTMLTableCellElement; activeCell.addEventListener("click", () => { - active(roomId, adminSecret, client.public_client_id, !activeCell.classList.contains("enable")); + active(roomId, adminSecret, client.client_id, !activeCell.classList.contains("enable")); }); - rows.set(client.public_client_id, row); + rows.set(client.client_id, row); }); setInterval(() => { @@ -345,11 +345,11 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: }, 250); } -function active(roomId: string, adminSecret: string, publicClientId: string, val: boolean) { +function active(roomId: string, adminSecret: string, clientId: string, val: boolean) { const req: ActiveRequest = { room_id: roomId, admin_secret: adminSecret, - public_client_id: publicClientId, + client_id: clientId, active: val, }; @@ -362,11 +362,11 @@ function active(roomId: string, adminSecret: string, publicClientId: string, val }) } -function admin(roomId: string, adminSecret: string, publicClientId: string) { +function admin(roomId: string, adminSecret: string, clientId: string) { const req: AdminRequest = { room_id: roomId, admin_secret: adminSecret, - public_client_id: publicClientId, + client_id: clientId, }; fetch("api/admin", { From 2619794b7862ef1dab3e66a5163a557fc390189f Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 25 Nov 2020 21:19:31 +0000 Subject: [PATCH 09/15] Remove unused request fields --- main.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/main.go b/main.go index ef94aa2..e4c50d0 100644 --- a/main.go +++ b/main.go @@ -102,7 +102,6 @@ type room struct { type watchState struct { responseWriter http.ResponseWriter - request *http.Request flusher http.Flusher room *room client *client @@ -112,7 +111,6 @@ type watchState struct { type presentState struct { responseWriter http.ResponseWriter - request *http.Request flusher http.Flusher room *room controlChan chan *controlEvent @@ -553,7 +551,6 @@ func newWatchState(w http.ResponseWriter, r *http.Request) *watchState { ws := &watchState{ responseWriter: w, - request: r, eventChan: make(chan *event, 100), } @@ -639,7 +636,6 @@ func newPresentState(w http.ResponseWriter, r *http.Request) *presentState { ps := &presentState{ responseWriter: w, - request: r, controlChan: make(chan *controlEvent, 100), } From 462159c606758e03ea42eddd8362f022d18abffd Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 25 Nov 2020 21:52:30 +0000 Subject: [PATCH 10/15] Remove watchState --- main.go | 119 +++++++++++++++++++++++++------------------------------- 1 file changed, 53 insertions(+), 66 deletions(-) diff --git a/main.go b/main.go index e4c50d0..0e0c1ec 100644 --- a/main.go +++ b/main.go @@ -100,15 +100,6 @@ type room struct { present map[*presentState]bool } -type watchState struct { - responseWriter http.ResponseWriter - flusher http.Flusher - room *room - client *client - admin bool - eventChan chan *event -} - type presentState struct { responseWriter http.ResponseWriter flusher http.Flusher @@ -422,31 +413,45 @@ func reset(w http.ResponseWriter, r *http.Request) { } func watch(w http.ResponseWriter, r *http.Request) { - ws := newWatchState(w, r) - if ws == nil { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusBadRequest) return } + client, eventChan := registerWatch(w, r) + if client == nil { + return + } + + // TODO: refcount client so it stays alive from just a watch + // add in registerwatch, sub in defer here + closeChan := w.(http.CloseNotifier).CloseNotify() ticker := time.NewTicker(15 * time.Second) - ws.sendInitial() + writeInitial(client, w, flusher) for { select { case <-closeChan: - ws.close() - return + close(eventChan) + + mu.Lock() + if client.eventChan == eventChan { + client.eventChan = nil + } + mu.Unlock() case <-ticker.C: - mu.Lock() - ws.sendHeartbeat() - mu.Unlock() + writeHeartbeat(w, flusher) - case event := <-ws.eventChan: - mu.Lock() - ws.sendEvent(event) - mu.Unlock() + case event, ok := <-eventChan: + if ok { + writeEvent(event, w, flusher) + } else { + return + } } } } @@ -545,89 +550,71 @@ func (rm *room) updateAllClients() { } } -func newWatchState(w http.ResponseWriter, r *http.Request) *watchState { +func registerWatch(w http.ResponseWriter, r *http.Request) (*client, chan *event) { mu.Lock() defer mu.Unlock() - ws := &watchState{ - responseWriter: w, - eventChan: make(chan *event, 100), - } - - var ok bool - ws.flusher, ok = w.(http.Flusher) - if !ok { - http.Error(ws.responseWriter, "streaming unsupported", http.StatusBadRequest) - return nil - } - roomId := r.URL.Query().Get("room_id") - ws.room = getRoom(roomId) + room := getRoom(roomId) clientId := r.URL.Query().Get("client_id") - ws.client = ws.room.getClient(clientId) + client := room.getClient(clientId) adminSecret := r.URL.Query().Get("admin_secret") if adminSecret != "" { - if adminSecret == ws.room.adminSecret() { - ws.admin = true + if adminSecret == room.adminSecret() { + client.Admin = true } else { http.Error(w, "invalid admin_secret", http.StatusBadRequest) - return nil + return nil, nil } } - ws.client.eventChan = ws.eventChan - ws.client.update() + if client.eventChan != nil { + close(client.eventChan) + } + + client.eventChan = make(chan *event, 100) + + client.update() w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - return ws + // Return eventChan because we're reading it with the lock held + return client, client.eventChan } -func (ws *watchState) sendInitial() { +func writeInitial(client *client, w http.ResponseWriter, flusher http.Flusher) { mu.Lock() defer mu.Unlock() - if !ws.admin { + if !client.Admin { return } - for _, client := range ws.room.clientById { - ws.sendEvent(&event{ + for _, iter := range client.room.clientById { + writeEvent(&event{ AdminEvent: &adminEvent{ - Client: client, + Client: iter, }, - }) + }, w, flusher) } - - ws.flusher.Flush() } -func (ws *watchState) sendHeartbeat() { - fmt.Fprintf(ws.responseWriter, ":\n\n") - ws.flusher.Flush() +func writeHeartbeat(w http.ResponseWriter, flusher http.Flusher) { + fmt.Fprintf(w, ":\n\n") + flusher.Flush() } -func (ws *watchState) sendEvent(e *event) { +func writeEvent(e *event, w http.ResponseWriter, flusher http.Flusher) { j, err := json.Marshal(e) if err != nil { log.Fatal(err) } - fmt.Fprintf(ws.responseWriter, "data: %s\n\n", j) - ws.flusher.Flush() -} - -func (ws *watchState) close() { - mu.Lock() - defer mu.Unlock() - - if ws.client.eventChan == ws.eventChan { - ws.client.eventChan = nil - close(ws.eventChan) - } + fmt.Fprintf(w, "data: %s\n\n", j) + flusher.Flush() } func newPresentState(w http.ResponseWriter, r *http.Request) *presentState { From d5bfc59ab8193bad5747b8e8ea4d53a00b28a16b Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 25 Nov 2020 22:03:43 +0000 Subject: [PATCH 11/15] Instead of refcount, close watch channel when we delete client --- main.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/main.go b/main.go index 0e0c1ec..778d8d6 100644 --- a/main.go +++ b/main.go @@ -176,7 +176,7 @@ func scan() { mu.Lock() defer mu.Unlock() - grace := 30 * time.Second + grace := 10 * time.Second for _, rm := range roomById { for _, c := range rm.clientById { @@ -351,7 +351,7 @@ func present(w http.ResponseWriter, r *http.Request) { } closeChan := w.(http.CloseNotifier).CloseNotify() - ticker := time.NewTicker(15 * time.Second) + ticker := time.NewTicker(5 * time.Second) for { select { @@ -424,11 +424,8 @@ func watch(w http.ResponseWriter, r *http.Request) { return } - // TODO: refcount client so it stays alive from just a watch - // add in registerwatch, sub in defer here - closeChan := w.(http.CloseNotifier).CloseNotify() - ticker := time.NewTicker(15 * time.Second) + ticker := time.NewTicker(5 * time.Second) writeInitial(client, w, flusher) @@ -463,6 +460,10 @@ func (c *client) sendEvent(e *event) { } func (c *client) remove() { + if c.eventChan != nil { + close(c.eventChan) + } + delete(c.room.clientById, c.ClientId) c.room.sendAdminEvent(&adminEvent{ From d7495aa83bc46d604fb4fd659131cb6b446e6e02 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 25 Nov 2020 22:17:29 +0000 Subject: [PATCH 12/15] Reject requests from inactive clients --- main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/main.go b/main.go index 778d8d6..0fa92f7 100644 --- a/main.go +++ b/main.go @@ -317,6 +317,7 @@ func control(w http.ResponseWriter, r *http.Request) { if !c.Active { http.Error(w, "client is not active", http.StatusBadRequest) + return } rm.sendControlEvent(&controlEvent{ From 82da61bd8649283837382e8b8b675135cccf36cc Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 25 Nov 2020 22:41:05 +0000 Subject: [PATCH 13/15] Add solo button --- main.go | 71 +++++++++++++++++++++++++++++------------------ static/remote.css | 3 +- static/remote.js | 33 ++++++++++++++++++++-- static/remote.ts | 37 ++++++++++++++++++++++-- 4 files changed, 112 insertions(+), 32 deletions(-) diff --git a/main.go b/main.go index 0fa92f7..a3cb1eb 100644 --- a/main.go +++ b/main.go @@ -20,21 +20,22 @@ import ( ) type activeRequest struct { - RoomId string `json:"room_id"` - AdminSecret string `json:"admin_secret"` - ClientId string `json:"client_id"` - Active bool `json:"active"` + RoomId string `json:"room_id"` + AdminSecret string `json:"admin_secret"` + ClientId string `json:"client_id"` + Active bool `json:"active"` + Solo bool `json:"solo"` } type adminRequest struct { - RoomId string `json:"room_id"` - AdminSecret string `json:"admin_secret"` - ClientId string `json:"client_id"` + RoomId string `json:"room_id"` + AdminSecret string `json:"admin_secret"` + ClientId string `json:"client_id"` } type resetRequest struct { - RoomId string `json:"room_id"` - AdminSecret string `json:"admin_secret"` + RoomId string `json:"room_id"` + AdminSecret string `json:"admin_secret"` } type announceRequest struct { @@ -61,11 +62,11 @@ type removeRequest struct { } type client struct { - ClientId string `json:"client_id"` - Name string `json:"name"` - Admin bool `json:"admin"` - Active bool `json:"active"` - ActiveStart int64 `json:"active_start"` + ClientId string `json:"client_id"` + Name string `json:"name"` + Admin bool `json:"admin"` + Active bool `json:"active"` + ActiveStart int64 `json:"active_start"` room *room lastSeen time.Time @@ -94,10 +95,10 @@ type controlEvent struct { } type room struct { - roomId string - timerStart time.Time - clientById map[string]*client - present map[*presentState]bool + roomId string + timerStart time.Time + clientById map[string]*client + present map[*presentState]bool } type presentState struct { @@ -212,7 +213,7 @@ func active(w http.ResponseWriter, r *http.Request) { return } - c.Active = req.Active + c.Active = req.Active || req.Solo if c.Active { c.ActiveStart = time.Now().Unix() } else { @@ -222,6 +223,22 @@ func active(w http.ResponseWriter, r *http.Request) { rm.sendAdminEvent(&adminEvent{ Client: c, }) + + if req.Solo { + for _, iter := range rm.clientById { + if iter == c { + continue + } + if iter.Active { + iter.Active = false + iter.ActiveStart = 0 + iter.update() + rm.sendAdminEvent(&adminEvent{ + Client: iter, + }) + } + } + } } func admin(w http.ResponseWriter, r *http.Request) { @@ -476,9 +493,9 @@ func (c *client) remove() { func (c *client) update() { e := &event{ StandardEvent: &standardEvent{ - Active: c.Active, + Active: c.Active, ActiveStart: c.ActiveStart, - TimerStart: c.room.timerStart.Unix(), + TimerStart: c.room.timerStart.Unix(), }, } if c.Admin { @@ -489,10 +506,10 @@ func (c *client) update() { func newRoom(roomId string) *room { return &room{ - roomId: roomId, - timerStart: time.Now(), - clientById: map[string]*client{}, - present: map[*presentState]bool{}, + roomId: roomId, + timerStart: time.Now(), + clientById: map[string]*client{}, + present: map[*presentState]bool{}, } } @@ -514,8 +531,8 @@ func (rm *room) getClient(clientId string) *client { c := rm.clientById[clientId] if c == nil { c = &client{ - ClientId: clientId, - room: rm, + ClientId: clientId, + room: rm, } rm.clientById[clientId] = c diff --git a/static/remote.css b/static/remote.css index 80d6018..9e3a474 100644 --- a/static/remote.css +++ b/static/remote.css @@ -42,7 +42,8 @@ tfoot tr { } .admin, -.active { +.active, +.solo { cursor: pointer; opacity: 0.3; user-select: none; diff --git a/static/remote.js b/static/remote.js index cf7a742..2dc8b62 100644 --- a/static/remote.js +++ b/static/remote.js @@ -166,6 +166,7 @@ function renderAdmin(roomId, adminSecret, prnt, es) { create(head1, "th", "Active Time"); create(head1, "th", "👑"); create(head1, "th", "👆"); + create(head1, "th", "🌟"); const body = create(table, "tbody"); const rows = new Map(); es.addEventListener("open", () => { @@ -188,6 +189,7 @@ function renderAdmin(roomId, adminSecret, prnt, es) { } row = document.createElement("tr"); row.dataset.name = client.name; + row.dataset.active = client.active ? "active" : ""; row.dataset.activeStart = client.active_start; let before = null; for (const iter of body.children) { @@ -211,9 +213,19 @@ function renderAdmin(roomId, adminSecret, prnt, es) { }); const activeCell = create(row, "td", "👆", client.active ? ["active", "enable"] : ["active"]); activeCell.addEventListener("click", () => { - active(roomId, adminSecret, client.client_id, !activeCell.classList.contains("enable")); + active(roomId, adminSecret, client.client_id, !activeCell.classList.contains("enable"), false); + }); + const soloCell = create(row, "td", "🌟", ["solo"]); + soloCell.addEventListener("click", () => { + if (soloCell.classList.contains("enable")) { + active(roomId, adminSecret, client.client_id, false, false); + } + else { + active(roomId, adminSecret, client.client_id, true, true); + } }); rows.set(client.client_id, row); + setSolo(rows); }); setInterval(() => { const now = new Date(); @@ -227,12 +239,29 @@ function renderAdmin(roomId, adminSecret, prnt, es) { } }, 250); } -function active(roomId, adminSecret, clientId, val) { +function setSolo(rows) { + let activeCount = 0; + for (const row of rows.values()) { + if (row.dataset.active === "active") { + activeCount++; + } + } + for (const row of rows.values()) { + if (activeCount === 1 && row.dataset.active === "active") { + row.children[4].classList.add("enable"); + } + else { + row.children[4].classList.remove("enable"); + } + } +} +function active(roomId, adminSecret, clientId, val, solo) { const req = { room_id: roomId, admin_secret: adminSecret, client_id: clientId, active: val, + solo, }; fetch("api/active", { method: "POST", diff --git a/static/remote.ts b/static/remote.ts index a4b2afd..c282102 100644 --- a/static/remote.ts +++ b/static/remote.ts @@ -3,6 +3,7 @@ interface ActiveRequest { admin_secret: string; client_id: string; active: boolean; + solo: boolean; } interface AdminRequest { @@ -267,6 +268,7 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: create(head1, "th", "Active Time"); create(head1, "th", "👑"); create(head1, "th", "👆"); + create(head1, "th", "🌟"); const body = create(table, "tbody"); @@ -298,6 +300,7 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: row = document.createElement("tr") as HTMLTableRowElement; row.dataset.name = client.name; + row.dataset.active = client.active ? "active" : ""; row.dataset.activeStart = client.active_start; let before = null; @@ -325,10 +328,21 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: const activeCell = create(row, "td", "👆", client.active ? ["active", "enable"] : ["active"]) as HTMLTableCellElement; activeCell.addEventListener("click", () => { - active(roomId, adminSecret, client.client_id, !activeCell.classList.contains("enable")); + active(roomId, adminSecret, client.client_id, !activeCell.classList.contains("enable"), false); + }); + + const soloCell = create(row, "td", "🌟", ["solo"]) as HTMLTableCellElement; + soloCell.addEventListener("click", () => { + if (soloCell.classList.contains("enable")) { + active(roomId, adminSecret, client.client_id, false, false); + } else { + active(roomId, adminSecret, client.client_id, true, true); + } }); rows.set(client.client_id, row); + + setSolo(rows); }); setInterval(() => { @@ -345,12 +359,31 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: }, 250); } -function active(roomId: string, adminSecret: string, clientId: string, val: boolean) { +function setSolo(rows: Map) { + let activeCount = 0; + + for (const row of rows.values()) { + if (row.dataset.active === "active") { + activeCount++; + } + } + + for (const row of rows.values()) { + if (activeCount === 1 && row.dataset.active === "active") { + row.children[4].classList.add("enable"); + } else { + row.children[4].classList.remove("enable"); + } + } +} + +function active(roomId: string, adminSecret: string, clientId: string, val: boolean, solo: boolean) { const req: ActiveRequest = { room_id: roomId, admin_secret: adminSecret, client_id: clientId, active: val, + solo, }; fetch("api/active", { From 7d1035f751eceb5c9495ab041594efd879863a3c Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 25 Nov 2020 23:21:22 +0000 Subject: [PATCH 14/15] Do our own EventSource reconnection, show icon to user --- static/remote.js | 52 ++++++++++++++++++++++++++++++++--------- static/remote.ts | 61 +++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 91 insertions(+), 22 deletions(-) diff --git a/static/remote.js b/static/remote.js index 2dc8b62..64a47c5 100644 --- a/static/remote.js +++ b/static/remote.js @@ -1,4 +1,5 @@ "use strict"; +const messageBus = new EventTarget(); function main() { const url = new URL(location.href); if (url.searchParams.has("room")) { @@ -64,14 +65,32 @@ function watch(roomId, clientId, adminSecret, prnt) { if (adminSecret) { url.searchParams.set("admin_secret", adminSecret); } - const es = new EventSource(url.toString()); - renderControls(roomId, clientId, adminSecret, prnt, es); - renderTimers(roomId, adminSecret, prnt, es); + createEventSource(url); + renderControls(roomId, clientId, adminSecret, prnt); + renderTimers(roomId, adminSecret, prnt); if (adminSecret) { - renderAdmin(roomId, adminSecret, prnt, es); + renderAdmin(roomId, adminSecret, prnt); } } -function renderControls(roomId, clientId, adminSecret, prnt, es) { +function createEventSource(url) { + const es = new EventSource(url.toString()); + es.addEventListener("open", () => { + messageBus.dispatchEvent(new Event("open")); + }); + es.addEventListener("message", (e) => { + messageBus.dispatchEvent(new MessageEvent("message", { + data: e.data, + lastEventId: e.lastEventId, + })); + }); + es.addEventListener("error", () => { + console.warn("disconnected"); + es.close(); + setTimeout(() => createEventSource(url), 3000); + messageBus.dispatchEvent(new Event("error")); + }); +} +function renderControls(roomId, clientId, adminSecret, prnt) { const controls = create(prnt, "div", undefined, ["controls"]); const left = create(controls, "span", "<<<", ["control-button"]); left.addEventListener("click", () => control(roomId, clientId, controls, "left")); @@ -88,7 +107,8 @@ function renderControls(roomId, clientId, adminSecret, prnt, es) { break; } }); - es.addEventListener("message", (e) => { + messageBus.addEventListener("message", (ev) => { + const e = ev; const event = JSON.parse(e.data); if (!event.standard_event) { return; @@ -105,10 +125,11 @@ function renderControls(roomId, clientId, adminSecret, prnt, es) { } }); } -function renderTimers(roomId, adminSecret, prnt, es) { +function renderTimers(roomId, adminSecret, prnt) { let overallStart = null; let meStart = null; - es.addEventListener("message", (e) => { + messageBus.addEventListener("message", (ev) => { + const e = ev; const event = JSON.parse(e.data); if (!event.standard_event) { return; @@ -117,6 +138,14 @@ function renderTimers(roomId, adminSecret, prnt, es) { meStart = parseInt(event.standard_event.active_start || "0", 10) || null; }); const width = 10; + const statusDiv = create(prnt, "div", "Status: ".padStart(width, "\u00a0")); + const status = create(statusDiv, "span"); + messageBus.addEventListener("open", () => { + status.innerText = "\u{1f7e2}"; + }); + messageBus.addEventListener("error", () => { + status.innerText = "\u{1f534}"; + }); const clockDiv = create(prnt, "div", "Clock: ".padStart(width, "\u00a0")); const clock = create(clockDiv, "span"); const overallDiv = create(prnt, "div", "Overall: ".padStart(width, "\u00a0")); @@ -158,7 +187,7 @@ function renderTimers(roomId, adminSecret, prnt, es) { } }, 250); } -function renderAdmin(roomId, adminSecret, prnt, es) { +function renderAdmin(roomId, adminSecret, prnt) { const table = create(prnt, "table", undefined, ["users"]); const head = create(table, "thead"); const head1 = create(head, "tr"); @@ -169,11 +198,12 @@ function renderAdmin(roomId, adminSecret, prnt, es) { create(head1, "th", "🌟"); const body = create(table, "tbody"); const rows = new Map(); - es.addEventListener("open", () => { + messageBus.addEventListener("open", () => { rows.clear(); body.innerHTML = ""; }); - es.addEventListener("message", (e) => { + messageBus.addEventListener("message", (ev) => { + const e = ev; const event = JSON.parse(e.data); if (!event.admin_event) { return; diff --git a/static/remote.ts b/static/remote.ts index c282102..9a70763 100644 --- a/static/remote.ts +++ b/static/remote.ts @@ -65,6 +65,8 @@ interface Client { active_start: string; } +const messageBus = new EventTarget(); + function main() { const url = new URL(location.href); @@ -143,18 +145,41 @@ function watch(roomId: string, clientId: string, adminSecret: string | null, prn if (adminSecret) { url.searchParams.set("admin_secret", adminSecret); } - const es = new EventSource(url.toString()); + createEventSource(url); - renderControls(roomId, clientId, adminSecret, prnt, es); + renderControls(roomId, clientId, adminSecret, prnt); - renderTimers(roomId, adminSecret, prnt, es); + renderTimers(roomId, adminSecret, prnt); if (adminSecret) { - renderAdmin(roomId, adminSecret, prnt, es); + renderAdmin(roomId, adminSecret, prnt); } } -function renderControls(roomId: string, clientId: string, adminSecret: string | null, prnt: HTMLElement, es: EventSource) { +function createEventSource(url: URL) { + const es = new EventSource(url.toString()); + + es.addEventListener("open", () => { + messageBus.dispatchEvent(new Event("open")); + }); + + es.addEventListener("message", (e) => { + messageBus.dispatchEvent(new MessageEvent("message", { + data: e.data, + lastEventId: e.lastEventId, + })); + }); + + es.addEventListener("error", () => { + console.warn("disconnected"); + es.close(); + setTimeout(() => createEventSource(url), 3000); + + messageBus.dispatchEvent(new Event("error")); + }); +} + +function renderControls(roomId: string, clientId: string, adminSecret: string | null, prnt: HTMLElement) { const controls = create(prnt, "div", undefined, ["controls"]) as HTMLDivElement; const left = create(controls, "span", "<<<", ["control-button"]) as HTMLDivElement; @@ -176,7 +201,8 @@ function renderControls(roomId: string, clientId: string, adminSecret: string | } }); - es.addEventListener("message", (e) => { + messageBus.addEventListener("message", (ev) => { + const e = ev as MessageEvent; const event = JSON.parse(e.data) as Event; if (!event.standard_event) { @@ -196,11 +222,12 @@ function renderControls(roomId: string, clientId: string, adminSecret: string | }); } -function renderTimers(roomId: string, adminSecret: string | null, prnt: HTMLElement, es: EventSource) { +function renderTimers(roomId: string, adminSecret: string | null, prnt: HTMLElement) { let overallStart: number | null = null; let meStart: number | null = null; - es.addEventListener("message", (e) => { + messageBus.addEventListener("message", (ev) => { + const e = ev as MessageEvent; const event = JSON.parse(e.data) as Event; if (!event.standard_event) { @@ -213,6 +240,17 @@ function renderTimers(roomId: string, adminSecret: string | null, prnt: HTMLElem const width = 10; + const statusDiv = create(prnt, "div", "Status: ".padStart(width, "\u00a0")); + const status = create(statusDiv, "span"); + + messageBus.addEventListener("open", () => { + status.innerText = "\u{1f7e2}"; + }); + + messageBus.addEventListener("error", () => { + status.innerText = "\u{1f534}"; + }); + const clockDiv = create(prnt, "div", "Clock: ".padStart(width, "\u00a0")); const clock = create(clockDiv, "span"); @@ -260,7 +298,7 @@ function renderTimers(roomId: string, adminSecret: string | null, prnt: HTMLElem }, 250); } -function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: EventSource) { +function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement) { const table = create(prnt, "table", undefined, ["users"]) as HTMLTableElement; const head = create(table, "thead"); const head1 = create(head, "tr"); @@ -274,12 +312,13 @@ function renderAdmin(roomId: string, adminSecret: string, prnt: HTMLElement, es: const rows: Map = new Map(); - es.addEventListener("open", () => { + messageBus.addEventListener("open", () => { rows.clear(); body.innerHTML = ""; }); - es.addEventListener("message", (e) => { + messageBus.addEventListener("message", (ev) => { + const e = ev as MessageEvent; const event = JSON.parse(e.data) as Event; if (!event.admin_event) { From a95fdbbf102863d9f31d101ed6f904cafee53179 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 25 Nov 2020 23:34:09 +0000 Subject: [PATCH 15/15] Real heartbeat timeouts server -> client --- main.go | 3 +-- static/remote.js | 10 ++++++++++ static/remote.ts | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index a3cb1eb..bfd3e0e 100644 --- a/main.go +++ b/main.go @@ -623,8 +623,7 @@ func writeInitial(client *client, w http.ResponseWriter, flusher http.Flusher) { } func writeHeartbeat(w http.ResponseWriter, flusher http.Flusher) { - fmt.Fprintf(w, ":\n\n") - flusher.Flush() + writeEvent(&event{}, w, flusher) } func writeEvent(e *event, w http.ResponseWriter, flusher http.Flusher) { diff --git a/static/remote.js b/static/remote.js index 64a47c5..7ef87da 100644 --- a/static/remote.js +++ b/static/remote.js @@ -74,7 +74,15 @@ function watch(roomId, clientId, adminSecret, prnt) { } function createEventSource(url) { const es = new EventSource(url.toString()); + let lastMessage = performance.now(); + const intId = setInterval(() => { + if (performance.now() - lastMessage > 10000) { + console.warn("timeout"); + es.dispatchEvent(new Event("error")); + } + }, 1000); es.addEventListener("open", () => { + console.info("connected"); messageBus.dispatchEvent(new Event("open")); }); es.addEventListener("message", (e) => { @@ -82,10 +90,12 @@ function createEventSource(url) { data: e.data, lastEventId: e.lastEventId, })); + lastMessage = performance.now(); }); es.addEventListener("error", () => { console.warn("disconnected"); es.close(); + clearInterval(intId); setTimeout(() => createEventSource(url), 3000); messageBus.dispatchEvent(new Event("error")); }); diff --git a/static/remote.ts b/static/remote.ts index 9a70763..2800ec8 100644 --- a/static/remote.ts +++ b/static/remote.ts @@ -159,7 +159,17 @@ function watch(roomId: string, clientId: string, adminSecret: string | null, prn function createEventSource(url: URL) { const es = new EventSource(url.toString()); + let lastMessage = performance.now(); + + const intId = setInterval(() => { + if (performance.now() - lastMessage > 10000) { + console.warn("timeout"); + es.dispatchEvent(new Event("error")); + } + }, 1000); + es.addEventListener("open", () => { + console.info("connected"); messageBus.dispatchEvent(new Event("open")); }); @@ -168,11 +178,16 @@ function createEventSource(url: URL) { data: e.data, lastEventId: e.lastEventId, })); + + lastMessage = performance.now(); }); es.addEventListener("error", () => { console.warn("disconnected"); + es.close(); + clearInterval(intId); + setTimeout(() => createEventSource(url), 3000); messageBus.dispatchEvent(new Event("error"));