v0.31.0 d5e0505

inbox.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"net/http"
8	"net/url"
9	"strconv"
10	"strings"
11	"time"
12
13	"github.com/a-h/templ"
14
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports"
17)
18
19// The owner's inbox (ADR 0031): GET /-/inbox lists the open merge requests and
20// issues of every repository, newest activity first, with a filter by
21// repository (?repo=<name>, a plain link, so it works without scripts). Only a
22// signed-in owner sees it: for anyone else the path is as missing as any other.
23// It follows the stream "owner" with view=inbox: each event of a merge request
24// or an issue sends the counters, the two lists and the filter again.
25
26// inboxPath is the page of the inbox.
27const inboxPath = "/-/inbox"
28
29// inboxEvent is the name of the live events of the inbox.
30const inboxEvent = "inbox"
31
32// InboxRow is one open item of the inbox.
33type InboxRow struct {
34	Repo     string
35	RepoHref string
36	Number   string
37	Href     string
38	Title    string
39	When     string
40	WhenAt   string
41	Cursor   bool
42}
43
44// PendingRow is one issue awaiting moderation. Everything in it is plain text:
45// it comes from an anonymous visitor and is never rendered as Markdown.
46type PendingRow struct {
47	Repo     string
48	RepoHref string
49	Author   string
50	Title    string
51	// Preview is the start of the body, at most pendingPreviewRunes characters.
52	Preview string
53	When    string
54	WhenAt  string
55	// Approve, Reject and Ban are the URLs the buttons post to; empty without
56	// the moderation use case.
57	Approve, Reject, Ban string
58}
59
60// pendingPreviewRunes bounds the preview of a pending body.
61const pendingPreviewRunes = 300
62
63// InboxRepoLink is an entry of the repository filter.
64type InboxRepoLink struct {
65	Label   string
66	Href    string
67	Current bool
68}
69
70// InboxPage is the inbox: the counters, the filter and the two lists.
71type InboxPage struct {
72	// MergeRequests and Issues count every open item, whatever the filter.
73	MergeRequests, Issues int
74	// PendingCount counts every issue awaiting moderation.
75	PendingCount int
76	Pending      []PendingRow
77	Filter       string // the repository filtered on, "" for all
78	Repos        []InboxRepoLink
79	MRs          []InboxRow
80	IssueRows    []InboxRow
81}
82
83// inboxHref is the inbox page, filtered on repo when it is not "".
84func inboxHref(repo string) string {
85	if repo == "" {
86		return inboxPath
87	}
88	return inboxPath + "?repo=" + url.QueryEscape(repo)
89}
90
91// inboxFilter reads ?repo=. A name that is not valid is a page that is not
92// there.
93func inboxFilter(r *http.Request) (domain.RepoName, error) {
94	raw := r.URL.Query().Get("repo")
95	if raw == "" {
96		return domain.RepoName{}, nil
97	}
98	name, err := domain.ParseRepoName(raw)
99	if err != nil {
100		return domain.RepoName{}, errNotFound
101	}
102	return name, nil
103}
104
105// inboxPage builds the page from the overview.
106func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
107	now := s.cfg.Now()
108	p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
109	for _, pi := range ov.Pending {
110		p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
111	}
112	row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow {
113		ts := updated
114		if ts.IsZero() {
115			ts = created
116		}
117		r := InboxRow{
118			Repo: repo.String(), RepoHref: repoHref(repo.String()),
119			Number: "#" + strconv.FormatInt(n, 10), Href: href, Title: visibleText(title),
120		}
121		r.When, r.WhenAt = whenOf(now, ts)
122		return r
123	}
124	for _, m := range ov.MergeRequests {
125		p.MRs = append(p.MRs, row(m.Repo, m.Number, mrHref(m.Repo.String(), m.Number), m.Title.String(), m.UpdatedAt, m.CreatedAt))
126	}
127	for _, i := range ov.Issues {
128		p.IssueRows = append(p.IssueRows, row(i.Repo, i.Number, issueHref(i.Repo.String(), i.Number), i.Title.String(), i.UpdatedAt, i.CreatedAt))
129	}
130	switch {
131	case len(p.MRs) > 0:
132		p.MRs[0].Cursor = true
133	case len(p.IssueRows) > 0:
134		p.IssueRows[0].Cursor = true
135	}
136	p.Repos = append(p.Repos, InboxRepoLink{Label: "all", Href: inboxHref(""), Current: filter.IsZero()})
137	for _, r := range ov.Repos {
138		p.Repos = append(p.Repos, InboxRepoLink{Label: r.String(), Href: inboxHref(r.String()), Current: r == filter})
139	}
140	return p
141}
142
143// pendingRow is the row of a pending issue: the texts as plain strings, the
144// invisible characters marked, the body cut.
145func (s *Server) pendingRow(now time.Time, pi domain.PendingIssue, filter domain.RepoName) PendingRow {
146	repo := pi.Repo.String()
147	row := PendingRow{
148		Repo: repo, RepoHref: repoHref(repo), Author: "anonymous",
149		Title: visibleText(pi.Title.String()),
150	}
151	if a := strings.TrimSpace(visibleText(pi.Author)); a != "" {
152		row.Author = a
153	}
154	body := strings.TrimSpace(visibleText(pi.Body.String()))
155	row.Preview = cutRunes(body, pendingPreviewRunes)
156	if len(row.Preview) < len(body) {
157		row.Preview += "…"
158	}
159	row.When, row.WhenAt = whenOf(now, pi.CreatedAt)
160	if s.cfg.Moderation != nil {
161		base := inboxPath + "/pending/" + url.PathEscape(repo) + "/" + url.PathEscape(pi.ID.String()) + "/"
162		var q string
163		if !filter.IsZero() {
164			q = "?repo=" + url.QueryEscape(filter.String())
165		}
166		row.Approve, row.Reject, row.Ban = base+"approve"+q, base+"reject"+q, base+"ban"+q
167	}
168	return row
169}
170
171// pendingBack is where a moderation action returns: the inbox, on the same
172// repository filter.
173func pendingBack(r *http.Request) string {
174	if f, err := inboxFilter(r); err == nil {
175		return inboxHref(f.String())
176	}
177	return inboxPath
178}
179
180// pendingID reads the repository and the id of the path.
181func pendingID(r *http.Request) (domain.RepoName, domain.PendingIssueID, error) {
182	name, err := domain.ParseRepoName(r.PathValue("repo"))
183	if err != nil {
184		return domain.RepoName{}, "", err
185	}
186	id, err := domain.ParsePendingIssueID(r.PathValue("id"))
187	if err != nil {
188		return domain.RepoName{}, "", domain.ErrNotFound
189	}
190	return name, id, nil
191}
192
193// pendingApprove is POST /-/inbox/pending/<repo>/<id>/approve.
194func (s *Server) pendingApprove(ctx context.Context, r *http.Request) (string, string, error) {
195	back := pendingBack(r)
196	name, id, err := pendingID(r)
197	if err != nil {
198		return "", back, err
199	}
200	if _, err := s.cfg.Moderation.Approve(ctx, principal(ctx), name, id); err != nil {
201		return "", back, err
202	}
203	return back, back, nil
204}
205
206// pendingReject is POST /-/inbox/pending/<repo>/<id>/reject, or .../ban, which
207// also bans the submitter.
208func (s *Server) pendingReject(ban bool) func(ctx context.Context, r *http.Request) (string, string, error) {
209	return func(ctx context.Context, r *http.Request) (string, string, error) {
210		back := pendingBack(r)
211		name, id, err := pendingID(r)
212		if err != nil {
213			return "", back, err
214		}
215		if err := s.cfg.Moderation.Reject(ctx, principal(ctx), name, id, ban); err != nil {
216			return "", back, err
217		}
218		return back, back, nil
219	}
220}
221
222// inbox is GET /-/inbox.
223func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) {
224	if !SignedIn(ctx) {
225		return nil, 0, errNotFound
226	}
227	filter, err := inboxFilter(r)
228	if err != nil {
229		return nil, 0, err
230	}
231	ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
232	if err != nil {
233		return nil, 0, err
234	}
235	l := s.base()
236	l.Title = "inbox · " + s.instance
237	l.Crumbs = []Crumb{{Label: "inbox", Current: true}}
238	l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
239	if s.cfg.Events != nil {
240		q := "view=inbox"
241		if !filter.IsZero() {
242			q += "&repo=" + url.QueryEscape(filter.String())
243		}
244		l.Live, l.LiveURL = true, liveURL("owner", q)
245	}
246	return Inbox(l, s.inboxPage(ov, filter)), http.StatusOK, nil
247}
248
249// inboxCount is the number of open items the home button shows, and whether
250// there is a button: only for the signed-in owner, and only when the inbox is
251// there. A failed read leaves the button without a number.
252func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
253	if s.cfg.Inbox == nil || !SignedIn(ctx) {
254		return 0, false
255	}
256	ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), domain.RepoName{})
257	if err != nil {
258		return -1, true
259	}
260	return ov.Counts.MergeRequests + ov.Counts.Issues, true
261}
262
263// inbox renders the fragments an event of a merge request, an issue or a repository changes
264// on the inbox page: the counters, the filter and the two lists.
265func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
266	switch we.Event.(type) {
267	case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
268		domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
269		domain.RepoUpdated, domain.RepoDeleted:
270	default:
271		return nil, nil
272	}
273	if l.s.cfg.Inbox == nil {
274		return nil, nil
275	}
276	var filter domain.RepoName
277	if l.filter != "" {
278		filter, _ = domain.ParseRepoName(l.filter)
279	}
280	ov, err := l.s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
281	if err != nil {
282		return nil, err
283	}
284	p := l.s.inboxPage(ov, filter)
285	var out []fragment
286	for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
287		f, err := renderFragment(ctx, inboxEvent, c)
288		if err != nil {
289			return nil, err
290		}
291		out = append(out, f)
292	}
293	return out, nil
294}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 44

↑↓ moveenter openesc close