// attention.go is the HTML translator for RFC 0011 §5's attention
// stream: the same `me.attention` op the API serves, rendered as
// Datastar patches instead of JSON.
//
// Principle 3 throughout. Everything here is an enhancement of a page
// that is already correct without it: the inbox dot has a one-shot
// fetch, the inbox page renders its own state and its controls are
// forms, and a browser with no script simply never opens the stream.
package web
import (
"context"
"encoding/json"
"fmt"
"html"
"html/template"
"net/http"
"net/url"
"reflect"
"strconv"
"strings"
"craftdesign.group/quarry/internal/ops"
)
// Prefs is the per-account preference store (RFC 0011 §5). A whole
// preferences page is Phase 3's; this is the one switch Phase 2 needs,
// stated as its own seam so the page that grows around it later does
// not have to move it. nil means preferences cannot be set, which is a
// forge with no OLTP — and then the desktop-notification control is not
// drawn rather than drawn and inert.
type Prefs interface {
Pref(account, key string) string
SetPref(account, key, value string) error
}
// WithPrefs attaches the per-account preference store.
func WithPrefs(p Prefs) Option { return func(o *handlerOpts) { o.prefs = p } }
// WithAttentionEverywhere opens the attention stream on every page
// rather than repository pages alone. It is a DEPLOYMENT statement, not
// a taste: on plain HTTP/1.1 a browser allows six connections per
// origin and a repository page already spends one or two, so a stream
// on every page is a stream that starves the page it sits on. With h2
// in front (TLS, or h2c at the proxy) the limit is gone and this is
// simply correct. docs/running.md says so.
func WithAttentionEverywhere() Option {
return func(o *handlerOpts) { o.attnEverywhere = true }
}
// notifyDesktopPref is the key the desktop-notification switch stores
// itself under; "on" is the only value that means on.
const notifyDesktopPref = "notify_desktop"
// toastsPref is the in-page banner switch. Off by default and gated on
// the SERVER: the stream's own job is the badge and the title count,
// and "render it and hide it" would still spend the bandwidth and
// still put the words in the DOM of a page that asked for neither.
const toastsPref = "toasts"
// attentionAttr opens the stream. The signal is declared here, on the
// wrapper, so the title's expression below has something to read before
// the first patch arrives.
const attentionAttr template.HTMLAttr = `data-signals="{attn: 0}" data-init="@get('/me.sse')"`
// attentionTitleAttr reflects the unread count as a "(3) " prefix on
// the document title — the one piece of page furniture visible from
// another tab, which is the whole point of counting at all.
//
// Built in Go rather than written in the template because the title is
// arbitrary text (a ticket's, a wiki page's) and it has to reach the
// expression as a JavaScript STRING LITERAL. html/template escapes a
// data-* attribute as HTML, not as JS, so a title with an apostrophe in
// it would end the literal and break every expression on the page.
func attentionTitleAttr(title string) template.HTMLAttr {
full := title
if title != "quarry" {
full = title + " — quarry"
}
return template.HTMLAttr(`data-text="($attn > 0 ? '(' + $attn + ') ' : '') + ` +
html.EscapeString(jsString(full)) + `"`)
}
// jsString renders s as a single-quoted JavaScript string literal.
// json.Marshal does the escaping (it is the same grammar for the
// characters that matter) and the quotes are then swapped, because the
// literal lives inside a double-quoted HTML attribute.
func jsString(s string) string {
b, err := json.Marshal(s)
if err != nil {
return "''"
}
lit := string(b)
lit = strings.ReplaceAll(lit[1:len(lit)-1], `\"`, `"`)
lit = strings.ReplaceAll(lit, `'`, `\'`)
return "'" + lit + "'"
}
// attentionEvent is one frame of the me.attention stream as the HTML
// translator needs it — the op's Out shape, read back through the
// registry's own JSON.
type attentionEvent struct {
Seq uint64 `json:"seq"`
Kind string `json:"kind"`
Repo string `json:"repo"`
From string `json:"from"`
Summary string `json:"summary"`
Link string `json:"link"`
At string `json:"at"`
}
// attentionReason says WHY this event reached you, in one line — the
// headline of a toast and the label of an inbox row, spelled once so
// the two cannot disagree about what happened.
//
// The message's own words are never the headline: "looking now" tells
// a reader nothing about why their screen just moved, and the summary
// is right underneath it either way.
func attentionReason(e attentionEvent) string {
who := e.From
if who == "" {
who = "somebody"
}
where := ""
if e.Repo != "" {
where = " in " + e.Repo
}
noun, action, _ := strings.Cut(e.Kind, ".")
switch noun {
case "mention":
return who + " mentioned you" + where
case "reply":
return who + " replied to a thread you follow"
case "chat":
return who + " mentioned you" + where + " chat"
case "forum":
return who + " replied to a thread you follow" + where
case "landing":
if action != "" {
return "your landing was " + action + where
}
return who + " needs you on a landing" + where
case "ticket":
if action != "" {
return "a ticket you follow was " + action + where
}
case "":
return "something was addressed to you" + where
}
if action != "" {
return noun + " " + action + where
}
return noun + where
}
// attentionEventOf reads one recent-attention row into the shape the
// toast renders from, so the inbox and the stream describe an event
// with the same code.
type attnRow struct {
Seq uint64
Reason string
Summary string
Link string
At string
Unread bool
}
// attentionSummaryLine is the muted second line: one line of somebody's
// prose, bounded. The stream already trims at 160 for the wire; a row
// has less room than that.
func attentionSummaryLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
s = strings.TrimSpace(s[:i])
}
const limit = 120
if len(s) <= limit {
return s
}
cut := limit
for cut > 0 && s[cut]&0xC0 == 0x80 {
cut--
}
return strings.TrimSpace(s[:cut]) + "…"
}
// attentionSSE is the ops.Streamer that renders me.attention as
// Datastar patches: the badge, a toast, the title signal, and — only
// when the account asked for it — a desktop notification.
type attentionSSE struct {
w http.ResponseWriter
fl http.Flusher
r *ops.Registry
ctx context.Context
// desktop is the account's notify_desktop preference, resolved once
// at connect. The Notification expression is emitted only when it
// is on: RFC 0011 §5 is explicit that the permission prompt belongs
// to a control the person clicked, never to a page load, and an
// expression that calls requestPermission is that prompt.
desktop bool
// toasts is the in-page banner preference, resolved the same way.
// Off by default, so an ordinary account's stream moves the badge
// and the title and touches nothing the reader is looking at.
toasts bool
}
func (s *attentionSSE) Emit(id uint64, data any) error {
var e attentionEvent
remarshal(data, &e)
if id != 0 {
fmt.Fprintf(s.w, "id: %d\n", id)
}
// Nothing is drawn over the page unless the account asked for it;
// a desktop notification still needs an element to ride on, so the
// patch is written for either preference and carries only what
// that preference bought.
if s.toasts || s.desktop {
s.toast(e)
}
s.badge()
s.fl.Flush()
return nil
}
// Event handles the stream's non-item frames. A gap says the window no
// longer reaches the resume point: there is nothing to re-render here
// (a toast is a moment, not a list), so the badge is refreshed and the
// missed events stay in the inbox where they belong.
func (s *attentionSSE) Event(name string, _ any) error {
if name == "gap" {
s.badge()
s.fl.Flush()
}
return nil
}
func (s *attentionSSE) Ping() {
fmt.Fprint(s.w, ": ping\n\n")
s.fl.Flush()
}
// toast appends one self-dismissing notice. role="status" with
// aria-live="polite" is the pair that makes a screen reader announce it
// without stealing focus from whatever the reader was doing.
func (s *attentionSSE) toast(e attentionEvent) {
var b strings.Builder
if s.toasts {
b.WriteString(`<aside class="toast" role="status" aria-live="polite"`)
} else {
// Banners off, desktop notifications on: the expression rides
// on an element that draws nothing and says nothing, so the
// page the reader is looking at is not touched.
b.WriteString(`<span class="attn-notify" hidden`)
}
if s.desktop {
// Only when the tab is not the one being looked at: a toast is
// already on screen there, and a second copy in the corner of
// the operating system is noise. Permission must already have
// been granted — this expression never asks for it.
b.WriteString(` data-init="` + html.EscapeString(
"document.visibilityState !== 'visible' && "+
"typeof Notification !== 'undefined' && Notification.permission === 'granted' && "+
"new Notification("+jsString(notificationTitle(e))+
", {body: "+jsString(e.Summary)+", tag: "+jsString(notificationTag(e))+"})") + `"`)
}
b.WriteString(">")
if !s.toasts {
b.WriteString(`</span>`)
} else {
// The headline is the REASON, never the message: a person
// glancing at the corner of a page needs to know why it moved
// before they need to know what was said. The words themselves
// are the muted line under it.
reason := html.EscapeString(attentionReason(e))
if e.Link != "" {
fmt.Fprintf(&b, `<a class="attn-reason" href="%s">%s</a>`,
html.EscapeString(e.Link), reason)
} else {
b.WriteString(`<span class="attn-reason">` + reason + `</span>`)
}
if line := attentionSummaryLine(e.Summary); line != "" {
b.WriteString(`<small class="attn-summary">` + html.EscapeString(line) + `</small>`)
}
b.WriteString(`<small>` + html.EscapeString(attentionMeta(e)) + `</small></aside>`)
}
fmt.Fprint(s.w, "event: datastar-patch-elements\n")
fmt.Fprint(s.w, "data: mode append\n")
fmt.Fprint(s.w, "data: selector #toasts\n")
fmt.Fprintf(s.w, "data: elements %s\n\n", b.String())
}
// notificationTitle is what the operating system shows: who, and about
// what, in one short line. The body carries the summary.
func notificationTitle(e attentionEvent) string {
switch {
case e.From != "" && e.Repo != "":
return e.From + " in " + e.Repo
case e.From != "":
return e.From
case e.Repo != "":
return e.Repo
}
return "quarry"
}
// notificationTag collapses repeats: the same event redelivered after a
// reconnect replaces its own notification instead of stacking a second
// copy of it.
func notificationTag(e attentionEvent) string {
return "quarry-attn-" + strconv.FormatUint(e.Seq, 10)
}
func attentionMeta(e attentionEvent) string {
parts := make([]string, 0, 2)
if e.From != "" {
parts = append(parts, e.From)
}
if e.Repo != "" {
parts = append(parts, e.Repo)
}
return strings.Join(parts, " · ")
}
// badge redraws the inbox dot and the title signal from inbox.count —
// the same op the one-shot fetch and the API both read, so the number
// on the page has one definition wherever it is asked for.
func (s *attentionSSE) badge() {
n, err := attentionUnread(s.ctx, s.r)
if err != nil {
return // an outage leaves the badge as it was; it does not lie
}
fmt.Fprint(s.w, "event: datastar-patch-elements\n")
fmt.Fprintf(s.w, "data: elements %s\n\n", inboxDotHTML(n))
fmt.Fprint(s.w, "event: datastar-patch-signals\n")
fmt.Fprintf(s.w, "data: signals {attn: %d}\n\n", n)
}
// inboxDotHTML is the badge at one count. Zero renders the span with no
// `on` class, which is how the dot goes away again after a read —
// omitting the patch entirely would leave a stale number on screen.
func inboxDotHTML(n int) string {
if n <= 0 {
return `<span id="inbox-dot"></span>`
}
label := strconv.Itoa(n)
if n > 9 {
label = "9+"
}
return fmt.Sprintf(`<span id="inbox-dot" class="on" title="%d unread">%s</span>`, n, label)
}
// attentionUnread reads the count through the op, with the caller's
// identity already on the context.
func attentionUnread(ctx context.Context, r *ops.Registry) (int, error) {
out, err := invoke(ctx, r, "inbox.count", url.Values{})
if err != nil {
return 0, err
}
var c struct {
Unread int `json:"unread"`
}
remarshal(out, &c)
return c.Unread, nil
}
// serveAttention is GET /me.sse: me.attention, rendered as patches.
func serveAttention(r *ops.Registry, prefs Prefs, user string, w http.ResponseWriter, req *http.Request) {
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, ": live\n\n")
fl.Flush()
ctx := ops.WithUser(req.Context(), user)
st := &attentionSSE{w: w, fl: fl, r: r, ctx: ctx,
desktop: prefs != nil && prefs.Pref(user, notifyDesktopPref) == "on",
toasts: prefs != nil && prefs.Pref(user, toastsPref) == "on"}
// The badge on connect: a page that has been open across a restart
// must not wait for the next event to learn what is waiting.
st.badge()
fl.Flush()
form := url.Values{}
if leid := req.Header.Get("Last-Event-ID"); leid != "" {
form.Set("from", leid)
}
if err := invokeStream(ctx, r, "me.attention", form, st); err != nil {
return
}
}
// invokeStream is invoke for a Stream op: the same decode-and-validate
// path, ending in InvokeStream rather than Invoke.
func invokeStream(ctx context.Context, r *ops.Registry, name string,
form url.Values, s ops.Streamer) error {
d, ok := r.Lookup(name)
if !ok {
return fmt.Errorf("web: unknown op %q", name)
}
inPtr := d.NewIn()
if err := ops.DecodeValues(form, inPtr); err != nil {
return err
}
in := reflect.ValueOf(inPtr).Elem().Interface()
if err := ops.Validate(in); err != nil {
return err
}
return r.InvokeStream(ctx, name, in, s)
}
// robotRegistered reports whether this forge has a chat plane at all —
// the robot's switch is chat configuration, and a settings page with no
// chat draws no panel for it.
func robotRegistered(r *ops.Registry) bool {
_, ok := r.Lookup("chat.robot")
return ok
}
// robotPatternsOf reads one repository's robot patterns for the
// settings page. A failure renders an empty field rather than a broken
// page: the rest of the panel is still true.
func robotPatternsOf(r *ops.Registry, req *http.Request, repo string) string {
out, err := invoke(req.Context(), r, "chat.robot.get", url.Values{"repo": {repo}})
if err != nil {
return ""
}
var rb struct {
Events string `json:"events"`
}
remarshal(out, &rb)
return rb.Events
}
// recentAttention reads the inbox page's own list through the op, with
// the caller's identity already on the context. A failure renders no
// list rather than no page: the count and the controls above it are
// still true.
func recentAttention(ctx context.Context, r *ops.Registry) []attnRow {
out, err := invoke(ctx, r, "inbox.recent", url.Values{})
if err != nil {
return nil
}
var res struct {
Events []attentionEvent `json:"events"`
Read uint64 `json:"read"`
}
remarshal(out, &res)
rows := make([]attnRow, 0, len(res.Events))
for _, e := range res.Events {
rows = append(rows, attnRow{
Seq: e.Seq, Reason: attentionReason(e),
Summary: attentionSummaryLine(e.Summary),
Link: e.Link, At: e.At, Unread: e.Seq > res.Read,
})
}
return rows
}