- commit
- 47403321433431a98e66944a5b082aea0026d1f8
- parent
- 426b46e733c27585884179a37a27f98e71ddfb6a
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 5 | +- |
| M | internal/ | 1 | + |
| M | internal/ | 29 | +++++++++++ |
| M | internal/ | 17 | ++++--- |
| M | internal/ | 76 | +++++++++++++++++++++++++++++- |
@@ -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}@@ -279,6 +279,7 @@ func parseTemplates() (*template.Template, error) {
279279 "approvalDevices": approvalDevices,280280 "wikiHTML": wikiHTML,281281 "wikiView": wikiPageView,282+ "wikiProposal": wikiProposal,282283 "wikiURL": wikiURL,283284 "roomItems": roomItems,284285 "roomRoot": roomRoot,@@ -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@@ -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}}@@ -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 <b></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 <b></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+}