clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

show wiki proposals with their state and offer the decision to the owner and moderators

Dami committed 4740332
commit
47403321433431a98e66944a5b082aea0026d1f8
parent
426b46e733c27585884179a37a27f98e71ddfb6a
author
Dami <dami.etoile@gmail.com>
5 files changed, +116 -12
Minternal/webui/style.css5+-
Minternal/webui/templates.go1+
Minternal/webui/wiki.go29+++++++++++
Minternal/webui/wiki.html17++++---
Minternal/webui/wiki_test.go76+++++++++++++++++++++++++++++-
M internal/webui/style.css +3 -2
@@ -226,8 +226,9 @@ diff-view td>nostr-event{white-space:normal;max-width:720px;border:0;padding:6px
226226 wiki-article{display:block;max-width:720px;overflow-wrap:anywhere}wiki-article>header{font:12px var(--mono);color:var(--muted);margin-bottom:8px}wiki-article>header nostr-key,wiki-article>header nostr-name{color:var(--ink)}227227 wiki-article h1{font-size:20px;margin:18px 0 8px}wiki-article h2{font-size:17px}wiki-article h3{font-size:15px}wiki-article p{margin:0 0 10px}228228 wiki-article pre{background:var(--rail);border:1px solid var(--line);padding:10px 12px;overflow:auto;font-size:12.5px;line-height:1.5;white-space:pre}wiki-article ul,wiki-article ol{padding-left:20px;margin:0 0 10px}229-merge-request{display:block;border:1px solid var(--line);background:var(--soft);padding:8px 10px;margin:0 0 14px;max-width:720px;font:13px var(--sans)}merge-request nostr-key,merge-request nostr-name{color:var(--ink);font-weight:500}merge-request>a,merge-request button{margin-left:6px;font-size:12px}230-merge-request nostr-react,merge-request nostr-react form{display:inline}merge-request nostr-react output{display:inline-block;margin-left:6px;font:12px var(--mono);color:var(--muted)}merge-request nostr-react output[data-error]{color:var(--alert)}229+merge-request,wiki-proposal{display:block;border:1px solid var(--line);background:var(--soft);padding:8px 10px;margin:0 0 14px;max-width:720px;font:13px var(--sans)}merge-request nostr-key,merge-request nostr-name,wiki-proposal nostr-key,wiki-proposal nostr-name{color:var(--ink);font-weight:500}merge-request>a,merge-request button,wiki-proposal button{margin-left:6px;font-size:12px}230+merge-request nostr-react,merge-request nostr-react form,wiki-proposal nostr-react,wiki-proposal nostr-react form{display:inline}merge-request nostr-react output,wiki-proposal nostr-react output{display:inline-block;margin-left:6px;font:12px var(--mono);color:var(--muted)}merge-request nostr-react output[data-error],wiki-proposal nostr-react output[data-error]{color:var(--alert)}231+wiki-proposal[data-state=pending]{border-color:var(--bark)}wiki-proposal[data-state=rejected]{border-color:var(--alert)}#wiki-list #rows td:first-child small,#panel li[data-state]{font:12px var(--mono)}#wiki-list #rows td:first-child small{margin-left:6px}#wiki-list #rows tr[data-state=pending] small,#panel li[data-state=pending]{color:var(--bark)}#wiki-list #rows tr[data-state=approved] small,#panel li[data-state=approved]{color:var(--accent)}#wiki-list #rows tr[data-state=rejected] small,#panel li[data-state=rejected]{color:var(--alert)}231232 #merge-status{font:12px var(--mono);color:var(--muted)}#wiki-compare h2{margin-top:18px}#wiki-redirects{font:12px var(--mono);color:var(--muted)}232233 wiki-compose{display:block;max-width:720px;border-bottom:1px solid var(--line);padding:10px 0}233234 wiki-compose label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}wiki-compose label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}
M internal/webui/templates.go +1 -0
M internal/webui/wiki.go +29 -0
@@ -26,6 +26,34 @@ type wikiView struct {
2626 OpenCount int2727 RedirectsTo []map[string]any2828 RedirectsFrom []map[string]any29+ // CanApprove is set when the viewer may accept or reject proposals.30+ CanApprove bool31+}32+33+// wikiProposalView is how a version's proposal state reads for one viewer.34+// Shown is set for the people who may see the state: the owner and35+// moderators, and the version's author. Decide is set when the viewer may36+// accept or reject it, which is only while it is pending.37+type wikiProposalView struct {38+ Shown, Decide bool39+ State string40+ At any41+ By string42+}43+44+// wikiProposal reads a version's proposal state for the viewer. canApprove45+// is the browse result's can_approve flag.46+func wikiProposal(version map[string]any, actor string, canApprove any) wikiProposalView {47+ if version == nil || version["proposal"] != true {48+ return wikiProposalView{}49+ }50+ approver, _ := canApprove.(bool)51+ if !approver && (actor == "" || actor != plainString(version["author"])) {52+ return wikiProposalView{}53+ }54+ view := wikiProposalView{Shown: true, State: plainString(version["approval"]), At: version["approval_at"], By: plainString(version["approval_by"])}55+ view.Decide = approver && view.State == "pending"56+ return view2957 }3058 3159 func wikiMaps(value any) []map[string]any {
@@ -49,6 +77,7 @@ func wikiPageView(result any) wikiView {
4977 view.Merges = wikiMaps(page["merges"])5078 view.RedirectsTo = wikiMaps(page["redirects_to"])5179 view.RedirectsFrom = wikiMaps(page["redirects_from"])80+ view.CanApprove, _ = page["can_approve"].(bool)5281 view.Count = len(view.Versions)5382 if current := valueMap(page["version"]); current != nil {5483 view.Version = current
M internal/webui/wiki.html +9 -8
@@ -3,10 +3,10 @@
33 <form method="get" action="/wiki" id="wiki-search"><label>Search <input name="q" value="{{.Query.Get "q"}}" placeholder="title or summary" autocomplete="off"></label> <button>Search</button>{{if .Query.Get "q"}} <a href="/wiki">clear</a>{{end}}</form>44 {{if and .Actor (.Query.Get "new")}}<h2>New page</h2>{{template "wikiEditor" .}}55 {{end}}{{if .Error}}{{template "alert" .Error}}6-{{else if .Feed}}<table id="rows">6+{{else if .Feed}}{{$can := index (asMap .Event) "can_approve"}}<table id="rows">77 <thead><tr><th>page</th><th>author</th><th>versions</th><th>merge requests</th><th>updated</th></tr></thead>88 <tbody>9-{{range .Feed}}{{$p := asMap .}}{{$v := asMap (index $p "version")}}<tr><td><a href="{{wikiURL (str (index $p "d"))}}">{{str (index $p "title")}}</a></td><td>{{template "nostrName" (str (index $v "author"))}}</td><td>{{str (index $p "versions")}}</td><td>{{str (index $p "open_merges")}} open</td><td>{{when (index $v "created_at")}}</td></tr>9+{{range .Feed}}{{$p := asMap .}}{{$v := asMap (index $p "version")}}{{$s := wikiProposal $v $.Actor $can}}<tr{{if $s.Shown}} data-state="{{$s.State}}"{{end}}><td><a href="{{wikiURL (str (index $p "d"))}}">{{str (index $p "title")}}</a>{{if $s.Shown}} <small>proposed | {{$s.State}}</small>{{end}}</td><td>{{template "nostrName" (str (index $v "author"))}}</td><td>{{str (index $p "versions")}}</td><td>{{str (index $p "open_merges")}} open</td><td>{{when (index $v "created_at")}}</td></tr>1010 {{end}}</tbody>1111 </table>1212 {{else}}<p>No pages yet.</p>
@@ -23,10 +23,11 @@
2323 <h1>{{$w.Title}}</h1>2424 {{template "alert" .Notice}}{{if not $w.Version}}<p>This name has no article of its own.</p>2525 {{template "wikiRedirects" $w}}{{if .Actor}}{{template "wikiEditor" .}}{{end}}26-{{else}}{{range $m := $w.OpenMerges}}<merge-request>{{template "nostrName" (str (index $m "author"))}} proposes a new version of this page | <a href="{{wikiURL $w.D "merge" (str (index $m "id"))}}">compare</a>{{if eq $.Actor (str (index $m "destination"))}}<nostr-react event="{{str (index $m "id")}}" pubkey="{{str (index $m "author")}}" kind="818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>{{end}}<a href="/e/{{str (index $m "id")}}">Reply</a></merge-request>26+{{else}}{{$s := wikiProposal $w.Version $.Actor $w.CanApprove}}{{if $s.Shown}}<wiki-proposal data-state="{{$s.State}}">Proposed by {{template "nostrName" $w.Author}} | {{$s.State}}{{if $s.By}} {{when $s.At}} by {{template "nostrName" $s.By}}{{end}}{{if $s.Decide}}<nostr-react event="{{str (index $w.Version "id")}}" pubkey="{{$w.Author}}" kind="30818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>{{end}}</wiki-proposal>27+{{end}}{{range $m := $w.OpenMerges}}<merge-request>{{template "nostrName" (str (index $m "author"))}} proposes a new version of this page | <a href="{{wikiURL $w.D "merge" (str (index $m "id"))}}">compare</a>{{if eq $.Actor (str (index $m "destination"))}}<nostr-react event="{{str (index $m "id")}}" pubkey="{{str (index $m "author")}}" kind="818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>{{end}}<a href="/e/{{str (index $m "id")}}">Reply</a></merge-request>2728 {{end}}{{if .Query.Get "merge"}}{{template "wikiCompare" .}}2829 {{else if .Query.Get "edit"}}{{template "wikiEditor" .}}29-{{else if .Query.Get "history"}}{{template "wikiHistory" $w}}30+{{else if .Query.Get "history"}}{{template "wikiHistory" .}}3031 {{else}}<wiki-article><header>wiki | {{$w.D}} | by {{template "nostrName" $w.Author}} | version {{$w.Number}} of {{$w.Count}} | {{$w.Forks}} fork{{if ne $w.Forks 1}}s{{end}}</header>3132 {{with asMap (index $w.Version "fork")}}<p><small>forked from <a href="{{wikiURL $w.D "version" (str (index . "e"))}}">{{shortID (index . "e")}}</a></small></p>3233 {{end}}{{with asMap (index $w.Version "defer")}}<p><small>the author defers to <a href="{{wikiURL $w.D "version" (str (index . "e"))}}">{{shortID (index . "e")}}</a></small></p>
@@ -62,17 +63,17 @@
6263 {{end}}<p><a href="{{wikiURL $w.D}}">back to the page</a></p></div>{{end}}{{end}}6364 6465 {{/* wikiHistory lists every version of a page, newest first. */}}65-{{define "wikiHistory"}}<table id="rows">66+{{define "wikiHistory"}}{{$w := wikiView .Event}}<table id="rows">6667 <thead><tr><th>author</th><th>version</th><th>notes</th><th>published</th></tr></thead>6768 <tbody>68-{{range .Versions}}<tr><td>{{template "nostrName" (str (index . "author"))}}</td><td><a href="{{wikiURL $.D "version" (str (index . "id"))}}">{{shortID (index . "id")}}</a></td><td>{{with asMap (index . "fork")}}fork of {{shortID (index . "e")}}{{end}}{{with asMap (index . "defer")}} defers to {{shortID (index . "e")}}{{end}}{{with index . "likes"}} | {{str .}} likes{{end}}</td><td>{{when (index . "created_at")}}</td></tr>69+{{range $w.Versions}}{{$s := wikiProposal . $.Actor $w.CanApprove}}<tr{{if $s.Shown}} data-state="{{$s.State}}"{{end}}><td>{{template "nostrName" (str (index . "author"))}}</td><td><a href="{{wikiURL $w.D "version" (str (index . "id"))}}">{{shortID (index . "id")}}</a></td><td>{{with asMap (index . "fork")}}fork of {{shortID (index . "e")}}{{end}}{{with asMap (index . "defer")}} defers to {{shortID (index . "e")}}{{end}}{{with index . "likes"}} | {{str .}} likes{{end}}{{if $s.Shown}} | proposed {{$s.State}}{{end}}</td><td>{{when (index . "created_at")}}</td></tr>6970 {{end}}</tbody>7071 </table>71-<p><a href="{{wikiURL .D}}">back to the page</a></p>{{end}}72+<p><a href="{{wikiURL $w.D}}">back to the page</a></p>{{end}}7273 7374 {{define "panel-wiki"}}{{if eq .Tab "wiki"}}<section><h4>Wiki</h4><table><tr><th>protocol</th><td>NIP-54</td></tr><tr><th>markup</th><td>Djot</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr></table></section>7475 <section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/wiki?new=1">new page</a></li>{{else}}<li><a href="/signin">sign in to write</a></li>{{end}}</ul></section>7576 {{else}}{{$w := wikiView .Event}}<section><h4>Page</h4><table><tr><th>name</th><td><code>{{or $w.D (.Query.Get "d")}}</code></td></tr><tr><th>versions</th><td>{{$w.Count}}</td></tr><tr><th>merge requests</th><td>{{$w.OpenCount}} open</td></tr><tr><th>links</th><td>{{$w.Links}}</td></tr></table></section>76-{{if $w.Others}}<section><h4>Other versions</h4><ul>{{range $w.Others}}<li><a href="{{wikiURL $w.D "version" (str (index . "id"))}}">{{shortID (index . "author")}}</a>{{with asMap (index . "fork")}} | fork{{end}} <small>{{short (index . "created_at")}}</small></li>{{end}}</ul></section>77+{{if $w.Others}}<section><h4>Other versions</h4><ul>{{range $w.Others}}{{$s := wikiProposal . $.Actor $w.CanApprove}}<li{{if $s.Shown}} data-state="{{$s.State}}"{{end}}><a href="{{wikiURL $w.D "version" (str (index . "id"))}}">{{shortID (index . "author")}}</a>{{with asMap (index . "fork")}} | fork{{end}}{{if $s.Shown}} | proposed {{$s.State}}{{end}} <small>{{short (index . "created_at")}}</small></li>{{end}}</ul></section>7778 {{end}}{{if $w.RedirectsTo}}<section><h4>Also known as</h4><ul>{{range $w.RedirectsTo}}<li><code>{{str (index . "d")}}</code></li>{{end}}</ul></section>7879 {{end}}<section><h4>Actions</h4><ul>{{if $w.Version}}{{if eq .Actor $w.Author}}<li><a href="{{wikiURL $w.D "edit" "1"}}">edit</a></li>{{else if .Actor}}<li><a href="{{wikiURL $w.D "edit" "1"}}">fork</a></li>{{end}}<li><a href="{{wikiURL $w.D "history" "1"}}">history</a></li>{{end}}{{if .Actor}}<li><a href="/wiki?new=1">new page</a></li>{{else}}<li><a href="/signin">sign in to write</a></li>{{end}}</ul></section>{{end}}{{end}}
M internal/webui/wiki_test.go +74 -2
@@ -27,6 +27,13 @@ type wikiBackend struct {
2727 calls []string2828 params map[string]map[string]any2929 private bool30+ // proposal makes the second author's version a proposal in this state31+ // (pending, approved or rejected), decided by the owner when it is not32+ // pending. approver marks the caller as allowed to decide, and shown33+ // makes the proposal the page's shown version.34+ proposal string35+ approver bool36+ shown bool3037 }3138 3239 func (b *wikiBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {
@@ -46,14 +53,24 @@ func (b *wikiBackend) Query(_ context.Context, method string, params []json.RawM
4653 "content": "# Highlights\n\nPrivate tenants and _encrypted_ uploads. See [[Files and Private Repositories]].\n\n<script>alert(1)</script>", "links": []any{"files-and-private-repositories"}}4754 other := map[string]any{"id": wikiOtherV, "author": wikiOther, "created_at": float64(1788880000), "d": "release-notes-1-4", "title": "Release notes 1.4", "coordinate": "30818:" + wikiOther + ":release-notes-1-4", "likes": float64(0), "fork": map[string]any{"a": owner["coordinate"], "e": wikiOwnerV}, "content": "# Highlights\n\nA proposed rewrite."}4855 merge := map[string]any{"id": wikiMerge, "author": wikiOther, "created_at": float64(1788881000), "content": "Please take my rewrite", "target": owner["coordinate"], "target_d": "release-notes-1-4", "destination": wikiOwner, "source": wikiOtherV, "status": "open"}56+ shown := owner57+ if b.proposal != "" {58+ other["proposal"], other["approval"] = true, b.proposal59+ if b.proposal != "pending" {60+ other["approval_event"], other["approval_at"], other["approval_by"] = wikiMerge, float64(1788882000), wikiOwner61+ }62+ if b.shown {63+ shown = other64+ }65+ }4966 switch method {5067 case "browsewiki":51- return map[string]any{"items": []any{map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": owner, "versions": float64(2), "open_merges": float64(1)}}, "next_cursor": "release-notes-1-4"}, nil68+ return map[string]any{"items": []any{map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": shown, "versions": float64(2), "open_merges": float64(1)}}, "next_cursor": "release-notes-1-4", "can_approve": b.approver}, nil5269 case "browsewikipage":5370 if object["d"] != "release-notes-1-4" {5471 return nil, errors.New("not found: wiki page")5572 }56- return map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": owner, "preferred_by": "owner", "versions": []any{other, owner}, "merges": []any{merge}, "redirects_to": []any{map[string]any{"d": "changelog-1-4", "target_d": "release-notes-1-4"}}, "redirects_from": []any{}}, nil73+ return map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": shown, "preferred_by": "owner", "versions": []any{other, owner}, "merges": []any{merge}, "redirects_to": []any{map[string]any{"d": "changelog-1-4", "target_d": "release-notes-1-4"}}, "redirects_from": []any{}, "can_approve": b.approver}, nil5774 case "browsewikimerge":5875 return map[string]any{"merge": merge, "proposed": other, "target": owner}, nil5976 }
@@ -196,3 +213,58 @@ func TestWikiGuestAccessFollowsTheReadsPolicy(t *testing.T) {
196213 wantNone(t, path, body, `role="alert"`)197214 }198215 }216+217+const wikiMember = "6666666666666666666666666666666666666666666666666666666666666666"218+219+func TestWikiProposalBarOffersTheDecisionToApprovers(t *testing.T) {220+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "pending", approver: true, shown: true}221+ path := "/wiki/release-notes-1-4"222+ body := wikiGet(t, wikiApp(t, b, wikiOwner), path)223+ wantAll(t, path, body,224+ `<wiki-proposal data-state="pending">Proposed by <nostr-name pubkey="`+wikiOther+`"`, `| pending<nostr-react event="`+wikiOtherV+`" pubkey="`+wikiOther+`" kind="30818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react></wiki-proposal>`,225+ `<wiki-article><header>wiki | release-notes-1-4 | by <nostr-name pubkey="`+wikiOther+`"`, `A proposed rewrite.`)226+ // A decided proposal names the decision, its time and the deciding key,227+ // and offers no buttons.228+ for _, state := range []string{"approved", "rejected"} {229+ b.proposal = state230+ body = wikiGet(t, wikiApp(t, b, wikiOwner), path)231+ wantAll(t, path, body, `<wiki-proposal data-state="`+state+`">Proposed by <nostr-name pubkey="`+wikiOther+`"`, `| `+state+` 2026-09-08 15:40 UTC by <nostr-name pubkey="`+wikiOwner+`"`)232+ wantNone(t, path, body, "<nostr-react", ">Accept<")233+ }234+ // A pending proposal that is not the shown version is marked in the235+ // panel and the history, with no bar above the article.236+ b.proposal, b.shown = "pending", false237+ body = wikiGet(t, wikiApp(t, b, wikiOwner), path)238+ wantAll(t, path, body, `<h4>Other versions</h4><ul><li data-state="pending"><a href="/wiki/release-notes-1-4?version=`+wikiOtherV+`">222222222222</a> | fork | proposed pending <small>`)239+ wantNone(t, path, body, "<wiki-proposal")240+ body = wikiGet(t, wikiApp(t, b, wikiOwner), path+"?history=1")241+ wantAll(t, path, body, `<tr data-state="pending"><td><nostr-name pubkey="`+wikiOther+`"`, `fork of 444444444444 | proposed pending</td>`)242+}243+244+func TestWikiProposalAuthorSeesTheStateWithoutButtons(t *testing.T) {245+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "pending", shown: true}246+ path := "/wiki/release-notes-1-4"247+ body := wikiGet(t, wikiApp(t, b, wikiOther), path)248+ wantAll(t, path, body, `<wiki-proposal data-state="pending">Proposed by <nostr-name pubkey="`+wikiOther+`"`, `| pending</wiki-proposal>`)249+ wantNone(t, path, body, "<nostr-react", ">Accept<", ">Reject<")250+ body = wikiGet(t, wikiApp(t, b, wikiOther), "/wiki")251+ wantAll(t, "/wiki", body, `<tr data-state="pending"><td><a href="/wiki/release-notes-1-4">Release notes 1.4 &lt;b&gt;</a> <small>proposed | pending</small></td>`)252+}253+254+func TestWikiProposalStateIsHiddenFromMembers(t *testing.T) {255+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "approved", shown: true}256+ for _, path := range []string{"/wiki/release-notes-1-4", "/wiki/release-notes-1-4?history=1", "/wiki"} {257+ body := wikiGet(t, wikiApp(t, b, wikiMember), path)258+ wantAll(t, path, body, "Release notes 1.4")259+ wantNone(t, path, body, "<wiki-proposal", "<tr data-state=", "<li data-state=", "proposed | ", "| proposed ", "<nostr-react")260+ }261+}262+263+func TestWikiListMarksProposalRowsForApprovers(t *testing.T) {264+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "rejected", approver: true, shown: true}265+ body := wikiGet(t, wikiApp(t, b, wikiOwner), "/wiki")266+ wantAll(t, "/wiki", body, `<tr data-state="rejected"><td><a href="/wiki/release-notes-1-4">Release notes 1.4 &lt;b&gt;</a> <small>proposed | rejected</small></td><td><nostr-name pubkey="`+wikiOther+`"`)267+ b.proposal = ""268+ body = wikiGet(t, wikiApp(t, b, wikiOwner), "/wiki")269+ wantNone(t, "/wiki", body, "<tr data-state=", "proposed | ")270+}