Skip to content
quarry Artifact [f12403fac2]
Login

Artifact [f12403fac2]

Artifact f12403fac2c907594fb0ba196250e8624b99209f0098e7908edf17c719cbc17e:


// 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"
	"slices"
	"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 {
	// ID is what `inbox.mark` takes back: a notify message id, or
	// "local:<seq>" for one of quarry's own attention events. It is the
	// row's identity everywhere the page acts on it, because a sequence
	// alone cannot address the notify half.
	ID      string
	Seq     uint64
	Reason  string
	Summary string
	Link    string
	At      string
	Unread  bool
	// Kind is the event family — the noun half of the op's kind
	// ("mention", "forum", "landing", …). It is the filter's key and
	// the row's own hook; the reason line is prose and must never be
	// the thing a filter matches on.
	Kind string
	Repo string
	// Root is the forum thread a reply belongs to, when the link says
	// so: what "unfollow" needs, and the only reason it is read out.
	Root string
	// Notify marks a row that came from the notify companion rather
	// than from quarry's own attention window. It has no sequence, so
	// it can be listed and read but not marked done here.
	Notify bool
}

// attnKinds are the row families the inbox filters by, in tab order
// after "unread" and "all". Spelled once: the tabs, the counts and the
// filtering all read this table, so a family cannot appear in the bar
// and match nothing.
var attnKinds = map[string][]string{
	"mentions": {"mention", "chat"},
	"replies":  {"reply", "forum"},
	"landings": {"landing"},
}

// attnMatches reports whether a row belongs in one filter's listing.
// An unknown filter lists everything, which is what a hand-typed query
// string should get: the page, not an error.
func attnMatches(f string, row attnRow) bool {
	switch f {
	case "", "all":
		return true
	case "unread":
		return row.Unread
	}
	return slices.Contains(attnKinds[f], row.Kind)
}

// attnFilter is one tab in the inbox bar.
type attnFilter struct {
	Key   string
	Label string
	Count int
	On    bool
}

// attnFilters builds the bar from the rows themselves, so every count
// is the number of rows the tab would actually show.
//
// "Show" means ON THIS PAGE: the rows are one page of `inbox.list`, so
// a count is what the tab holds here and not the account's whole
// history. The badge is the account-wide number and comes from
// `inbox.count`; these are a reading aid for the list in front of
// somebody, and counting the whole inbox per tab would be four more
// round trips for a number nobody acts on.
func attnFilters(rows []attnRow, on string) []attnFilter {
	tabs := []attnFilter{
		{Key: "unread", Label: "Unread"},
		{Key: "all", Label: "All"},
		{Key: "mentions", Label: "Mentions"},
		{Key: "replies", Label: "Replies"},
		{Key: "landings", Label: "Landings"},
	}
	for i := range tabs {
		for _, row := range rows {
			if attnMatches(tabs[i].Key, row) {
				tabs[i].Count++
			}
		}
		tabs[i].On = tabs[i].Key == on
	}
	return tabs
}

// attnForumRoot reads the thread a forum link points at, empty when the
// link is not a forum thread's. "/r/<repo>/forum/<root>" is the shape
// the forum page itself is served at, fragment and query aside.
func attnForumRoot(link string) string {
	if i := strings.IndexAny(link, "?#"); i >= 0 {
		link = link[:i]
	}
	parts := strings.Split(strings.Trim(link, "/"), "/")
	if len(parts) < 4 || parts[0] != "r" || parts[2] != "forum" {
		return ""
	}
	return parts[3]
}

// 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
	// inbox renders the inbox page's live region for THIS reader, set
	// only when the stream was opened from /inbox. Every event that
	// moves the badge re-renders the list the reader is looking at,
	// and the page patches it — the badge and the list are one
	// stream, so they cannot disagree. lastInbox keeps a quiet stream
	// quiet: an event that changes nothing on the page sends nothing.
	inbox     func() string
	lastInbox string
	// 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.inboxPatch()
	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.inboxPatch()
		s.fl.Flush()
	}
	return nil
}

// inboxPatch morphs #inbox-live to the list as it now stands.
func (s *attentionSSE) inboxPatch() {
	if s.inbox == nil {
		return
	}
	frag := s.inbox()
	if frag == "" || frag == s.lastInbox {
		return
	}
	s.lastInbox = frag
	fmt.Fprint(s.w, "event: datastar-patch-elements\n")
	for _, line := range strings.Split(frag, "\n") {
		fmt.Fprintf(s.w, "data: elements %s\n", line)
	}
	fmt.Fprint(s.w, "\n")
}

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 {
		// The animation is the timer AND the dismissal: when it ends
		// the notice takes itself out of the DOM, so a tab left open
		// for a day does not accumulate a day of faded asides.
		b.WriteString(`<aside class="toast" role="status" aria-live="polite"` +
			` data-on:animationend="el.remove()"`)
	} 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.
		// The carrier has done its whole job the moment the expression
		// runs, so it removes itself in the same expression: a comma,
		// not a second attribute, because a hidden span that stayed
		// would be one more node per event forever.
		notify := "document.visibilityState !== 'visible' && " +
			"typeof Notification !== 'undefined' && Notification.permission === 'granted' && " +
			"new Notification(" + jsString(notificationTitle(e)) +
			", {body: " + jsString(e.Summary) + ", tag: " + jsString(notificationTag(e)) + "})"
		if !s.toasts {
			notify = "(" + notify + ", el.remove())"
		}
		b.WriteString(` data-init="` + html.EscapeString(notify) + `"`)
	}
	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.
// inboxAttentionAttr opens the same stream from the inbox page, asking
// it to carry the list as well as the badge. The filter rides along so
// the patched list is the one the reader chose.
func inboxAttentionAttr(filter string) template.HTMLAttr {
	return template.HTMLAttr(`data-signals="{attn: 0}" data-init="@get('/me.sse?inbox=1&f=` + template.JSEscapeString(url.QueryEscape(filter)) + `')"`)
}

func serveAttention(r *ops.Registry, prefs Prefs, user string, inbox func() 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, inbox: inbox,
		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
}

// inboxPageRows is one page of the inbox. Bounded so a page that has
// been ignored for a month still renders, with the rest an "older"
// link away.
const inboxPageRows = 50

// inboxOpRow is one `inbox.list` row as the page reads it.
type inboxOpRow struct {
	ID      string `json:"id"`
	Source  string `json:"source"`
	Reason  string `json:"reason"`
	Repo    string `json:"repo"`
	From    string `json:"from"`
	Summary string `json:"summary"`
	Link    string `json:"link"`
	At      string `json:"at"`
	Read    bool   `json:"read"`
	Seq     uint64 `json:"seq"`
}

// inboxRowsOf reads one page of the inbox through `inbox.list` — the
// same op the API serves, so the page and an API client see one merged
// list of notify's messages and quarry's own attention events, in one
// order, with one set of ids.
//
// The cursor comes back opaque and goes into the "older" link as it
// arrived: it addresses BOTH sources at once, and a page that took it
// apart would be the one deciding which half a row belongs to.
func inboxRowsOf(ctx context.Context, r *ops.Registry, before string, limit int) ([]attnRow, string, error) {
	form := url.Values{"limit": {strconv.Itoa(limit)}}
	if before != "" {
		form.Set("before", before)
	}
	out, err := invoke(ctx, r, "inbox.list", form)
	if err != nil {
		return nil, "", err
	}
	var res struct {
		Rows []inboxOpRow `json:"rows"`
		Next string       `json:"next"`
	}
	remarshal(out, &res)
	rows := make([]attnRow, 0, len(res.Rows))
	for _, x := range res.Rows {
		rows = append(rows, inboxRowOf(x))
	}
	return rows, res.Next, nil
}

// inboxRowOf words one row. The KIND is the op's reason family, which
// is what the filter tabs match on; the line a reader sees is prose
// built the same way the toast builds it, so the two cannot describe an
// event differently.
//
// A notify message is the exception: it arrives already worded by the
// workflow that sent it, and its subject is a better line than anything
// derivable from a family name.
func inboxRowOf(x inboxOpRow) attnRow {
	kind, _, _ := strings.Cut(x.Reason, ".")
	row := attnRow{
		ID: x.ID, Seq: x.Seq, Kind: kind, Repo: x.Repo, At: x.At,
		Link: x.Link, Unread: !x.Read, Notify: x.Source == "notify",
	}
	if row.Notify {
		row.Reason = x.Summary
	} else {
		row.Reason = attentionReason(attentionEvent{
			Kind: x.Reason, Repo: x.Repo, From: x.From})
		row.Summary = attentionSummaryLine(x.Summary)
	}
	if kind == "forum" || kind == "reply" {
		row.Root = attnForumRoot(x.Link)
	}
	return row
}

// ElemID is the row's element id, which the Done control patches out
// by. The op's ids carry a colon ("local:12"); a CSS selector does not,
// so it is spelled with a dash here and nowhere else.
func (row attnRow) ElemID() string {
	return "attn-" + strings.ReplaceAll(row.ID, ":", "-")
}

// inboxMarkError is what a failed mark says, on the page and in the
// redirect that carries it back. One string, because the live patch and
// the reloaded page must not word the same failure differently.
const inboxMarkError = "That could not be marked; nothing was changed."

// inboxErrorHTML is the error line as a patch payload.
func inboxErrorHTML(msg string) string {
	if msg == "" {
		return `<p id="inbox-error" class="attn-none"></p>`
	}
	return `<p id="inbox-error" class="attn-none" data-error="1"><strong>` +
		html.EscapeString(msg) + `</strong></p>`
}

// inboxMarkableID reports whether an id is one this page could have
// drawn: a notify message id or "local:<seq>". It is checked because
// the id is echoed into a CSS selector in the live answer, and an id
// carrying a quote or a space would be a selector somebody else wrote.
func inboxMarkableID(id string) bool {
	if id == "" || len(id) > 128 {
		return false
	}
	for _, c := range id {
		switch {
		case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
		case c == ':' || c == '-' || c == '_' || c == '.':
		default:
			return false
		}
	}
	return true
}

// inboxFilter reads the ?f= tab, defaulting to the whole list. An inbox
// that shows nothing until a tab is picked reads as broken, so "all" is
// the landing view and "unread" is one click away with its count on it.
func inboxFilter(f string) string {
	switch f {
	case "unread", "mentions", "replies", "landings":
		return f
	default:
		return "all"
	}
}

// inboxEmptyLine is what a filter with nothing in it says — one muted
// sentence about THAT filter, because "nothing here" under a tab the
// reader just clicked is an answer to a different question.
func inboxEmptyLine(f string) string {
	switch f {
	case "unread":
		return "Nothing unread."
	case "mentions":
		return "Nobody has mentioned you."
	case "replies":
		return "No replies on threads you follow."
	case "landings":
		return "No landings of yours have moved."
	default:
		return "Nothing is waiting for you."
	}
}