Skip to content
quarry Artifact [620758037d]
Login

Artifact [620758037d]

Artifact 620758037d5c5f7b42cfda02dab7734c4111801d77df9cac46d00d92f9029c33:


// 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 · every 2 hours, per repository"},
}

// 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
	// Err is what the last attempt ran into, as the control says it.
	// It is rendered by the SERVER on the redraw, so the words a person
	// reads are one string in one place rather than a sentence written
	// inside a click expression.
	Err string
	// Sent is set after a test push was accepted, so the control can
	// say what happens next rather than only that something happened.
	Sent    bool
	Devices []pushDeviceRow
}

// pushErrWords turns the frag's ?err= into the one line the control
// shows. An unknown code says nothing rather than echoing itself back.
func pushErrWords(code string) string {
	switch code {
	case "unsupported":
		return "This browser cannot do push. On iPhone, add quarry to the Home Screen first."
	case "blocked":
		return "Notifications are blocked for this site in your browser's settings."
	case "nosub":
		return "This browser holds no push subscription."
	case "failed":
		return "Push could not be changed just now. Try again."
	case "testfailed":
		return "The test could not be sent. The notification companion refused it."
	}
	return ""
}

// pushTestWords is what the control says after a test was accepted.
//
// It cannot say the notification arrived — nothing on this side can.
// Everything up to and including the push service answers success, and
// an operating system that is holding notifications back says nothing
// to anybody, which is exactly how a person ends up staring at a page
// that claims push works. So it says what IS known and names the one
// place left to look.
const pushTestWords = "Test sent. If nothing appeared within a few seconds, " +
	"the push left this forge and your device is holding it: check your operating " +
	"system's notification settings for this browser, and Do Not Disturb."

type pushDeviceRow struct {
	Hash, UA, At string
	Pending      bool
}

// pushEnableExpr is RFC 0011 §11's exception, entire: check the browser
// can do this at all, 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.
//
// Every outcome ends at /me/push.frag, so what a person is told comes
// from the SERVER's own reading of the state and not from what the
// expression believes happened. A rejected fetch is a rejected promise
// — `r.ok` is checked, because fetch resolves a 500 like a 200 and a
// silent failure here looks exactly like success.
const pushEnableExpr = `(!('serviceWorker' in navigator) || !('PushManager' in window)) ? ` +
	`@get('/me/push.frag?err=unsupported') : ` +
	`navigator.serviceWorker.register('/sw.js').then(reg => ` +
	`Notification.requestPermission().then(p => p !== 'granted' ? ` +
	`@get('/me/push.frag?err=blocked') : ` +
	`fetch('/me/push/key', {credentials: 'same-origin'}).then(r => ` +
	`r.ok ? r.json() : Promise.reject(r.status)).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(r => ` +
	`r.ok ? r : Promise.reject(r.status))).then(() => ` +
	`@get('/me/push.frag')))).catch(e => @get('/me/push.frag?err=failed'))`

// pushTestExpr asks for one real push. The permission is checked HERE
// rather than on the server because only the browser knows it: a site
// whose permission was revoked after subscribing still has a live
// subscription on notify's side, so the send would succeed and nothing
// would appear — the failure this control exists to explain.
const pushTestExpr = `(!('Notification' in window) || Notification.permission !== 'granted') ? ` +
	`@get('/me/push.frag?err=blocked') : ` +
	`@post('/me/push/test')`

// 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 = `(!('serviceWorker' in navigator)) ? ` +
	`@get('/me/push.frag?err=unsupported') : ` +
	`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(r => ` +
	`r.ok ? r : Promise.reject(r.status))).then(() => ` +
	`@get('/me/push.frag')) : ` +
	`@get('/me/push.frag?err=nosub')).catch(e => @get('/me/push.frag?err=failed'))`

// 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)
			}
		}
	}
	// Nothing is done here about a family notify could not be read:
	// `me.prefs.set` leaves the WHOLE matrix alone when the companion's
	// identities are unresolvable, because a matrix is written as one
	// end state and a per-family exception cannot be expressed in it.
	// The page disables those cells so the form agrees with the op.
	// 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 that did not
// answer 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; the notification companion did not answer — push and email preferences will apply once it does."
	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)
	})
	// One real push, on request. The whole chain answers "success"
	// whether or not anything reaches a screen — the push service
	// accepts, notify records sent, and an operating system holding
	// notifications back tells nobody — so the only honest test is the
	// notification itself.
	mux.HandleFunc("POST /me/push/test", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Error(w, "a test notification needs a signed-in account", http.StatusUnauthorized)
			return
		}
		if _, err := invoke(ops.WithUser(req.Context(), user), r, "me.push.test", url.Values{}); err != nil {
			log.Printf("web: push test for %s: %v", user, err)
			http.Redirect(w, req, "/me/push.frag?err=testfailed", http.StatusSeeOther)
			return
		}
		http.Redirect(w, req, "/me/push.frag?sent=1", 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
		}
		// What the browser ran into on its way here, worded by the
		// server: the control redraws with the reason attached rather
		// than failing silently or leaving a sentence in a signal.
		v.Push.Err = pushErrWords(req.URL.Query().Get("err"))
		v.Push.Sent = req.URL.Query().Get("sent") == "1"
		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. */ -}}
{{- if .Unknown}}<span class="pref-unknown" title="quarry could not read this setting">?</span>
{{- else}}<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>
{{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. They are not shown and not changed — saving this form leaves the
channel matrix exactly as notify holds it.</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}}">
<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}}
{{if .Devices}}<button type="button" class="btn" id="push-test"
  data-on:click="` + pushTestExpr + `">Send a test</button>{{end}}
{{if .Err}}<span class="push-msg" data-push-err="1">{{.Err}}</span>{{end}}
{{if .Sent}}<span class="push-msg" data-push-sent="1">` + pushTestWords + `</span>{{end}}
<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}}`))