// prefs.go is the preferences page of RFC 0011 §6: the family x channel
// matrix notify holds, the forge-local switches quarry owns, the
// repositories somebody muted, and the one control in quarry that needs
// JavaScript.
//
// Principle 3 holds everywhere but that control. The matrix, the
// switches and the mutes are one ordinary form, and every cell posts an
// EXPLICIT state: a checkbox alone posts nothing when it is unticked,
// and `me.prefs.set` reads the whole matrix as the intended end state,
// so an absent field and a deliberate off must not look the same.
//
// The push control is RFC 0011 §11's recorded exception. It is one
// inline expression on one button — no script file, no bundle — and
// without script it says so and nothing else on the page changes.
package web
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"net/url"
"reflect"
"strings"
"craftdesign.group/quarry/internal/ops"
)
// prefFamilyRows are RFC 0011 §6's four families in the order the page
// draws them, worded as somebody recognises them rather than as the
// workflow slugs quarry triggers. The timing column is the workflow's
// own shape (§6's table), stated so the matrix is not read as "email me
// every check-in".
var prefFamilyRows = []struct{ Key, Label, Timing string }{
{"mention", "someone mentions me", "immediate"},
{"reply", "a thread I follow gets a reply", "immediate"},
{"landing", "my landing changes state", "immediate"},
{"activity", "activity in repositories I have access to", "digest · 15 min"},
}
// prefColumns are the three channels, in the order `me.prefs` reports.
var prefColumns = []struct{ Key, Label string }{
{"inapp", "in app"},
{"push", "push"},
{"email", "email"},
}
// prefsOut is `me.prefs`'s answer as the page reads it.
type prefsOut struct {
Families map[string]map[string]bool `json:"families"`
Toasts bool `json:"toasts"`
Desktop bool `json:"notify_desktop"`
Muted []string `json:"muted"`
Unknown []string `json:"unknown"`
Push bool `json:"push"`
Devices []struct {
EndpointHash string `json:"endpoint_hash"`
UA string `json:"ua"`
At string `json:"at"`
Pending bool `json:"pending_remote_delete"`
} `json:"devices"`
Applied struct {
Local bool `json:"local"`
Notify bool `json:"notify"`
} `json:"applied"`
}
// prefsView is the whole page's state.
type prefsView struct {
Rows []prefRow
Columns []prefCol
Toasts prefCell
Desktop prefCell
Muted []string
Mutable []string
Unknown bool
Push pushView
Saved bool
Applied struct{ Local, Notify bool }
Banner string
HasError string
}
type prefCol struct{ Key, Label string }
type prefRow struct {
Family, Label, Timing string
Cells []prefCell
}
// prefCell is one switch. Name is what it posts under — "family.channel"
// for a matrix cell, the bare switch name for the two local ones — and
// both states are always posted, which is what makes an unticked box a
// decision rather than an omission.
type prefCell struct {
Name string
// Label is what a screen reader hears in place of the tick: the
// row and column in words, because "mention.inapp" is the field
// name and not a sentence anybody said.
Label string
On bool
Unknown bool
}
// pushView is the push control's server-known state. "unsupported" is a
// forge with no notification companion, "on" an account with at least
// one device, "off" everything else. Whether THIS browser holds a
// subscription is not something a server can know, which is why the
// control redraws itself from /me/push.frag after subscribing.
type pushView struct {
State string
Words string
Devices []pushDeviceRow
}
type pushDeviceRow struct {
Hash, UA, At string
Pending bool
}
// pushEnableExpr is RFC 0011 §11's exception, entire: register the
// worker, ask for permission from the click that asked for push,
// subscribe with notify's VAPID key, post the subscription verbatim,
// and redraw the control. The base64url key is decoded inline (the
// helper every push guide names urlBase64ToUint8Array) rather than in a
// script file, because a file is what Principle 3 refuses.
const pushEnableExpr = `$pushmsg = '', ` +
`navigator.serviceWorker.register('/sw.js').then(reg => ` +
`Notification.requestPermission().then(p => p !== 'granted' ? ` +
`($pushmsg = 'Your browser blocked notifications for this site.') : ` +
`fetch('/me/push/key', {credentials: 'same-origin'}).then(r => r.json()).then(k => ` +
`reg.pushManager.subscribe({userVisibleOnly: true, applicationServerKey: ` +
`Uint8Array.from(atob(k.publicKey.replace(/-/g, '+').replace(/_/g, '/') + ` +
`'==='.slice((k.publicKey.length + 3) % 4)), c => c.charCodeAt(0))})).then(s => ` +
`fetch('/me/push', {method: 'POST', credentials: 'same-origin', ` +
`headers: {'Content-Type': 'application/json'}, body: JSON.stringify(s)})).then(() => ` +
`@get('/me/push.frag')))).catch(e => ` +
`$pushmsg = 'Push is not supported in this browser.')`
// pushDisableExpr turns this browser's own subscription off. It is the
// only device a page can address: quarry stores an endpoint HASH and
// never the endpoint (RFC 0011 §6), so another browser's subscription
// can only be ended from that browser.
const pushDisableExpr = `navigator.serviceWorker.ready.then(reg => ` +
`reg.pushManager.getSubscription()).then(s => s ? ` +
`s.unsubscribe().then(() => fetch('/me/push/delete', {method: 'POST', ` +
`credentials: 'same-origin', headers: ` +
`{'Content-Type': 'application/x-www-form-urlencoded'}, ` +
`body: 'endpoint=' + encodeURIComponent(s.endpoint)})).then(() => ` +
`@get('/me/push.frag')) : ` +
`($pushmsg = 'This browser holds no push subscription.'))`
// prefsPageOf reads `me.prefs` and words the page.
func prefsPageOf(r *ops.Registry, req *http.Request, user string) (*prefsView, error) {
ctx := ops.WithUser(req.Context(), user)
out, err := invoke(ctx, r, "me.prefs", url.Values{})
if err != nil {
return nil, err
}
var p prefsOut
remarshal(out, &p)
return prefsViewOf(r, req, user, p), nil
}
func prefsViewOf(r *ops.Registry, req *http.Request, user string, p prefsOut) *prefsView {
unknown := map[string]bool{}
for _, f := range p.Unknown {
unknown[f] = true
}
v := &prefsView{Muted: p.Muted, Unknown: len(p.Unknown) > 0}
v.Applied.Local, v.Applied.Notify = p.Applied.Local, p.Applied.Notify
for _, col := range prefColumns {
v.Columns = append(v.Columns, prefCol{Key: col.Key, Label: col.Label})
}
for _, f := range prefFamilyRows {
row := prefRow{Family: f.Key, Label: f.Label, Timing: f.Timing}
for _, col := range prefColumns {
row.Cells = append(row.Cells, prefCell{
Name: f.Key + "." + col.Key,
Label: f.Label + ", " + col.Label,
On: p.Families[f.Key][col.Key],
Unknown: unknown[f.Key],
})
}
v.Rows = append(v.Rows, row)
}
// The two forge-local switches are read from the store the STREAM
// reads, when there is one: /me.sse decides what it emits from that
// same seam, and a page that reported anything else would describe
// a forge the reader is not using.
v.Toasts = prefCell{Name: toastsPref, On: p.Toasts}
v.Desktop = prefCell{Name: notifyDesktopPref, On: p.Desktop}
v.Mutable = mutableRepos(r, req, p.Muted)
v.Push = pushViewOf(p)
return v
}
func pushViewOf(p prefsOut) pushView {
pv := pushView{State: "unsupported", Words: "not available on this forge"}
if p.Push {
pv.State, pv.Words = "off", "not enabled"
}
for _, d := range p.Devices {
pv.Devices = append(pv.Devices, pushDeviceRow{
Hash: d.EndpointHash, UA: d.UA, At: d.At, Pending: d.Pending})
}
if p.Push && len(pv.Devices) > 0 {
pv.State, pv.Words = "on", "enabled"
}
return pv
}
// mutableRepos are the repositories this account can see and has not
// already muted — the options of the "add" control. A failure offers
// none rather than failing the page: the list somebody came to read is
// still true without it.
func mutableRepos(r *ops.Registry, req *http.Request, muted []string) []string {
out, err := invoke(req.Context(), r, "repo.list", nil)
if err != nil {
return nil
}
var rl struct {
Repos []struct {
Name string `json:"name"`
} `json:"repos"`
}
remarshal(out, &rl)
already := map[string]bool{}
for _, m := range muted {
already[m] = true
}
var names []string
for _, repo := range rl.Repos {
if !already[repo.Name] {
names = append(names, repo.Name)
}
}
return names
}
// prefsSetForm turns what the page posted into what `me.prefs.set`
// takes. Each family carries the channels that stay ON; the two
// switches are explicit; mute and unmute are list operations.
func prefsSetForm(req *http.Request) url.Values {
form := url.Values{}
for _, f := range prefFamilyRows {
for _, col := range prefColumns {
if lastValue(req.PostForm[f.Key+"."+col.Key]) == "on" {
form.Add(f.Key, col.Key)
}
}
}
// Absent stays absent: `me.prefs.set` changes a switch nobody named
// not at all, and posting a guessed "off" here is exactly the
// silent turn-off that rule exists to prevent.
for _, name := range []string{toastsPref, notifyDesktopPref} {
if v := lastValue(req.PostForm[name]); v == "on" || v == "off" {
form.Set(name, v)
}
}
for _, key := range []string{"mute", "unmute"} {
for _, repo := range req.PostForm[key] {
if repo != "" {
form.Add(key, repo)
}
}
}
return form
}
// lastValue reads the last of a repeated field. Every switch posts a
// hidden "off" and then, when ticked, an "on"; browsers send them in
// document order, so the last value is the state of the box.
func lastValue(vals []string) string {
if len(vals) == 0 {
return ""
}
return vals[len(vals)-1]
}
// prefsBanner says what a save actually did. A companion whose
// preferences route has not shipped (notify#305) leaves the notify half
// unwritten, and a page that reported "saved" would have told somebody
// a channel is off while it is still on.
func prefsBanner(v *prefsView) string {
switch {
case v.Applied.Notify:
return "Saved."
case v.Applied.Local:
return "Saved here; notify not reachable — push and email preferences will apply once it is."
default:
return "Nothing was saved: this forge keeps no preferences."
}
}
// uaLabel is what a device is called in the list: the browser's own
// user-agent string, bounded. It is a label, not an identity.
func uaLabel(ua string) string {
ua = strings.TrimSpace(ua)
const limit = 200
if len(ua) > limit {
ua = strings.TrimSpace(ua[:limit])
}
return ua
}
// invokeJSON runs an op whose input is a JSON DOCUMENT rather than a
// form — the browser's PushSubscription, which is nested and therefore
// beyond DecodeValues (In/Out structs are flat for forms by design).
// Same decode-validate-invoke path otherwise, so the web route and the
// API route reach identical op semantics.
func invokeJSON(ctx context.Context, r *ops.Registry, name string, body any) (any, error) {
d, ok := r.Lookup(name)
if !ok {
return nil, fmt.Errorf("web: unknown op %q", name)
}
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
inPtr := d.NewIn()
if err := json.Unmarshal(raw, inPtr); err != nil {
return nil, err
}
in := reflect.ValueOf(inPtr).Elem().Interface()
if err := ops.Validate(in); err != nil {
return nil, err
}
return r.Invoke(ctx, name, in)
}
// pushFragment renders the control on its own, for the redraw after a
// browser subscribes.
func pushFragment(v pushView) (string, error) {
var b bytes.Buffer
if err := notifyPrefsPage.ExecuteTemplate(&b, "push-control", v); err != nil {
return "", err
}
return b.String(), nil
}
// registerPrefs wires the preferences page and the push routes. The
// push routes are the WEB plane's, not the API's: a service worker and
// a page carry the cookie session and no HTTP Basic credentials, which
// is why sw.js fetches these and not /api/v1/me/push.
func registerPrefs(mux *http.ServeMux, r *ops.Registry, sessions Sessions) {
page := func(w http.ResponseWriter, req *http.Request, user string, v *prefsView, code int) {
data := pageData{Title: "notifications", NotifyPrefs: v}
renderStatus(r, w, req, notifyPrefsPage, data, code)
}
mux.HandleFunc("GET /me/notifications", func(w http.ResponseWriter, req *http.Request) {
user, ok := sessions.Resolve(cookieToken(req))
if !ok {
http.Redirect(w, req, "/login?next=%2Fme%2Fnotifications", http.StatusSeeOther)
return
}
v, err := prefsPageOf(r, req, user)
if err != nil {
fail(r, w, req, err)
return
}
page(w, req, user, v, http.StatusOK)
})
mux.HandleFunc("POST /me/notifications", func(w http.ResponseWriter, req *http.Request) {
user, ok := sessions.Resolve(cookieToken(req))
if !ok {
http.Redirect(w, req, "/login?next=%2Fme%2Fnotifications", http.StatusSeeOther)
return
}
if err := req.ParseForm(); err != nil {
fail(r, w, req, err)
return
}
ctx := ops.WithUser(req.Context(), user)
out, err := invoke(ctx, r, "me.prefs.set", prefsSetForm(req))
if err != nil {
fail(r, w, req, err)
return
}
var p prefsOut
remarshal(out, &p)
v := prefsViewOf(r, req, user, p)
v.Saved, v.Banner = true, prefsBanner(v)
// Rendered rather than redirected: `applied` is the answer to
// THIS write and is not a thing a URL can carry honestly.
page(w, req, user, v, http.StatusOK)
})
// The VAPID key, for the control and for sw.js's resubscribe. A
// session is required: the worker offers the cookie the browser
// already holds, and an anonymous prober must not be told it worked.
mux.HandleFunc("GET /me/push/key", func(w http.ResponseWriter, req *http.Request) {
user, ok := sessions.Resolve(cookieToken(req))
if !ok {
http.Error(w, "the push key needs a signed-in account", http.StatusUnauthorized)
return
}
out, err := invoke(ops.WithUser(req.Context(), user), r, "me.push.key", url.Values{})
if err != nil {
http.Error(w, "the push key is unavailable", http.StatusInternalServerError)
return
}
var k struct {
PublicKey string `json:"publicKey"`
}
remarshal(out, &k)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(k)
})
// The browser's own PushSubscription, posted verbatim.
mux.HandleFunc("POST /me/push", func(w http.ResponseWriter, req *http.Request) {
user, ok := sessions.Resolve(cookieToken(req))
if !ok {
http.Error(w, "a push subscription needs a signed-in account", http.StatusUnauthorized)
return
}
var sub struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
}
if err := json.NewDecoder(io.LimitReader(req.Body, 16<<10)).Decode(&sub); err != nil {
http.Error(w, "that is not a push subscription", http.StatusBadRequest)
return
}
if _, err := invokeJSON(ops.WithUser(req.Context(), user), r, "me.push.subscribe",
map[string]any{
"endpoint": sub.Endpoint,
"keys": map[string]string{"p256dh": sub.Keys.P256dh, "auth": sub.Keys.Auth},
"ua": uaLabel(req.UserAgent()),
}); err != nil {
log.Printf("web: push subscribe for %s: %v", user, err)
http.Error(w, "the subscription could not be stored", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"ok":true}`)
})
// Turning one browser off again. The endpoint comes from that
// browser, because quarry stores only a hash of it.
mux.HandleFunc("POST /me/push/delete", func(w http.ResponseWriter, req *http.Request) {
user, ok := sessions.Resolve(cookieToken(req))
if !ok {
http.Error(w, "removing a push subscription needs a signed-in account",
http.StatusUnauthorized)
return
}
if _, err := invoke(ops.WithUser(req.Context(), user), r, "me.push.unsubscribe",
url.Values{"endpoint": {req.FormValue("endpoint")}}); err != nil {
log.Printf("web: push unsubscribe for %s: %v", user, err)
fail(r, w, req, err)
return
}
http.Redirect(w, req, "/me/notifications", http.StatusSeeOther)
})
// The control's own redraw, after a browser subscribed or dropped a
// subscription: one patch, no page reload, and the state still comes
// from the server rather than from what the expression believes.
mux.HandleFunc("GET /me/push.frag", func(w http.ResponseWriter, req *http.Request) {
user, ok := sessions.Resolve(cookieToken(req))
if !ok {
http.Error(w, "the push control needs a signed-in account", http.StatusUnauthorized)
return
}
v, err := prefsPageOf(r, req, user)
if err != nil {
http.Error(w, "the push control is unavailable", http.StatusInternalServerError)
return
}
frag, err := pushFragment(v.Push)
if err != nil {
http.Error(w, "the push control failed to render", http.StatusInternalServerError)
return
}
markOneShot(w)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprint(w, "event: datastar-patch-elements\n")
for _, line := range strings.Split(frag, "\n") {
fmt.Fprintf(w, "data: elements %s\n", line)
}
fmt.Fprint(w, "\n")
})
}
var notifyPrefsPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{- /* One form for the whole matrix, the two local switches and the
mutes: ` + "`me.prefs.set`" + ` reads a family's channels as the intended
END STATE, so a control that posted on its own would blank every
cell it did not carry. The push control is outside it, because it
is not a preference quarry stores — it is a browser's own
subscription (RFC 0011 §6). */ -}}
<style>.pref-matrix{border-collapse:collapse;width:100%;font-size:0.9rem}
.pref-matrix th,.pref-matrix td{padding:var(--sp-2);border-bottom:1px solid var(--rule);text-align:left}
.pref-matrix th.c,.pref-matrix td.c{text-align:center}
.pref-matrix td.when{color:var(--fg)}
.pref-when{color:var(--muted);font-size:0.82rem;white-space:nowrap}
.pref-unknown{color:var(--muted);font-weight:600;cursor:help}
.pref-note{color:var(--muted);font-size:0.85rem;margin:var(--sp-2) 0}
.pref-switches{margin:var(--sp-4) 0;display:flex;flex-direction:column;gap:var(--sp-2)}
.pref-banner{border-left:3px solid var(--accent);padding-left:var(--sp-2);margin:var(--sp-3) 0}
.pref-mutes{list-style:none;margin:var(--sp-2) 0;padding:0;display:flex;
flex-wrap:wrap;gap:var(--sp-2)}
.pref-mutes li{display:flex;gap:var(--sp-1);align-items:center;
border:1px solid var(--rule);border-radius:var(--r-1);padding:0 var(--sp-2)}
.pref-unmute{background:none;border:0;padding:0;font:inherit;font-size:0.8rem;
color:var(--muted);text-decoration:underline;cursor:pointer}
.pref-unmute:hover{color:var(--accent)}
.pushrow{margin-top:var(--sp-6);border-top:1px solid var(--rule);padding-top:var(--sp-3)}
.push-state{color:var(--muted);margin-left:var(--sp-2)}
.push-nojs{display:block;color:var(--muted);font-size:0.82rem;margin-top:var(--sp-2)}
.push-msg{color:var(--muted);margin-left:var(--sp-2)}
.push-devices{list-style:none;margin:var(--sp-3) 0 0;padding:0;font-size:0.85rem}
.push-devices li{padding:var(--sp-1) 0;color:var(--muted)}</style>
{{with .NotifyPrefs}}
{{if .Saved}}<p class="pref-banner" data-saved="1"
data-applied-local="{{if .Applied.Local}}1{{else}}0{{end}}"
data-applied-notify="{{if .Applied.Notify}}1{{else}}0{{end}}">{{.Banner}}</p>{{end}}
<form method="post" action="/me/notifications">
<table class="pref-matrix">
<tr><th>when</th>{{range .Columns}}<th class="c">{{.Label}}</th>{{end}}<th class="c">timing</th></tr>
{{range .Rows}}<tr data-family="{{.Family}}"><td class="when">{{.Label}}</td>
{{range .Cells}}<td class="c" data-cell="{{.Name}}"{{if .Unknown}} data-unknown="1"{{end}}>
{{- /* Both states, always: the hidden off is what makes an unticked
box a decision rather than a field the form forgot. */ -}}
<input type="hidden" name="{{.Name}}" value="off"><label><input type="checkbox"
name="{{.Name}}" value="on"{{if .On}} checked{{end}}><span class="sr-only">{{.Label}}</span></label>
{{if .Unknown}}<span class="pref-unknown" title="quarry could not read this setting">?</span>{{end}}</td>
{{end}}<td class="c pref-when">{{.Timing}}</td></tr>
{{end}}</table>
{{if .Unknown}}<p class="pref-note" data-unknown-note="1">The cells marked ? could not be read:
notify is unreachable, so what is shown is quarry's default rather than what is stored.</p>{{end}}
<div class="pref-switches">
<label><input type="hidden" name="{{.Toasts.Name}}" value="off"><input type="checkbox"
id="attn-toasts" name="{{.Toasts.Name}}" value="on"{{if .Toasts.On}} checked{{end}}>
In-page banners</label>
<label><input type="hidden" name="{{.Desktop.Name}}" value="off"><input type="checkbox"
id="notify-desktop" name="{{.Desktop.Name}}" value="on"{{if .Desktop.On}} checked{{end}}>
Desktop notifications</label>
</div>
<h2 class="pref-h">Muted repositories</h2>
<p class="pref-note">A muted repository still reaches this inbox; it just stops being sent.</p>
{{if .Muted}}<ul class="pref-mutes">{{range .Muted}}<li data-muted="{{.}}">{{.}}
<button type="submit" class="pref-unmute" name="unmute" value="{{.}}">unmute</button></li>
{{end}}</ul>{{else}}<p class="pref-note">Nothing is muted.</p>{{end}}
{{if .Mutable}}<label>Mute a repository
<select name="mute"><option value="" selected>choose…</option>
{{range .Mutable}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>{{end}}
<p><button type="submit" class="btn">Save</button></p>
</form>
{{template "push-control" .Push}}
{{end}}
{{end}}
{{define "push-control"}}<section id="push-control" class="pushrow" data-push="{{.State}}"
data-signals="{pushmsg: ''}">
<b>Push on this device</b><span class="push-state">{{.Words}}</span>
{{if eq .State "unsupported"}}
<small class="push-nojs">This forge has no notification companion, so push cannot be turned on here.</small>
{{else}}
{{- /* RFC 0011 §11: the ONE exception to Principle 3. Everything the
control does is in this expression — register the worker, ask
permission from the click that asked for push, subscribe with
notify's key, post the subscription, redraw. There is no script
file and nothing else on the page needs one. */ -}}
<button type="button" class="btn" id="push-enable"
data-on:click="` + pushEnableExpr + `">Enable</button>
{{if .Devices}}<button type="button" class="btn" id="push-disable"
data-on:click="` + pushDisableExpr + `">Turn off on this device</button>{{end}}
<span class="push-msg" data-text="$pushmsg"></span>
<small class="push-nojs">Needs JavaScript and a browser that supports push. On iPhone,
add quarry to the Home Screen first.</small>
{{end}}
{{if .Devices}}<ul class="push-devices">{{range .Devices}}<li data-device="{{.Hash}}">
{{if .UA}}{{.UA}}{{else}}an unnamed browser{{end}}{{if .At}} · {{ago .At}}{{end}}
{{- if .Pending}} · removal pending{{end}}</li>{{end}}</ul>
<small class="push-nojs">quarry keeps only a hash of each subscription (RFC 0011 §6), so a
browser other than this one is turned off from that browser.</small>{{end}}
</section>{{end}}`))