tenseleyflow/shithub / 3012166

Browse files

Append Explore feed pages in place

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
30121660b7828553f1cd0af67808ddfe4309d601
Parents
c43bec0
Tree
96a5710

8 changed files

StatusFile+-
M docs/internal/social.md 5 1
M internal/web/embed_test.go 28 0
M internal/web/handlers/explore.go 27 0
A internal/web/handlers/explore_test.go 71 0
M internal/web/static/css/shithub.css 15 0
A internal/web/templates/_explore_feed_pagination.html 21 0
A internal/web/templates/explore/feed_page.html 6 0
M internal/web/templates/explore/index.html 2 6
docs/internal/social.mdmodified
@@ -46,7 +46,11 @@ The signed-in `/explore` feed includes:
4646
 - public org-scoped activity for followed orgs.
4747
 
4848
 Anonymous Explore uses the global public feed. Both feeds page with a
49
-keyset cursor over `(created_at, id)`.
49
+keyset cursor over `(created_at, id)`. Full-page requests with a
50
+`before` cursor still render that cursor window for no-JavaScript
51
+fallbacks; HTMX requests return only the next feed rows plus a replacement
52
+`More` control, so the browser appends activity in place like GitHub's
53
+dashboard feed.
5054
 
5155
 ## Event Kinds
5256
 
internal/web/embed_test.gomodified
@@ -120,6 +120,34 @@ func TestOrgPagesRenderSingleSharedOrgNav(t *testing.T) {
120120
 	}
121121
 }
122122
 
123
+func TestExploreFeedFragmentAppendsRowsAndReplacesPagination(t *testing.T) {
124
+	t.Parallel()
125
+	r, err := render.New(TemplatesFS(), render.Options{})
126
+	if err != nil {
127
+		t.Fatalf("render.New: %v", err)
128
+	}
129
+	rw := httptest.NewRecorder()
130
+	if err := r.RenderFragment(rw, "explore/feed_page", map[string]any{
131
+		"FeedHasNext": true,
132
+		"FeedNextURL": "/explore?before=2026-05-12T03%3A00%3A00Z~42",
133
+	}); err != nil {
134
+		t.Fatalf("RenderFragment: %v", err)
135
+	}
136
+	body := rw.Body.String()
137
+	for _, want := range []string{
138
+		`id="shithub-feed-fragment-rows"`,
139
+		`hx-target="#shithub-feed-list"`,
140
+		`hx-swap="beforeend"`,
141
+		`hx-select="#shithub-feed-fragment-rows > *"`,
142
+		`hx-select-oob="#shithub-feed-pagination:outerHTML"`,
143
+		`Loading...`,
144
+	} {
145
+		if !strings.Contains(body, want) {
146
+			t.Fatalf("fragment missing %q in:\n%s", want, body)
147
+		}
148
+	}
149
+}
150
+
123151
 // errorOriginatesInPartial returns true when an html/template execute
124152
 // error blames a file whose basename starts with `_`. Errors from such
125153
 // files are bugs in the partial because we render with an empty map
internal/web/handlers/explore.gomodified
@@ -52,6 +52,10 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat
5252
 			}
5353
 			return social.PublicFeed(r.Context(), deps, cursor, limit)
5454
 		})
55
+		if activeTab == "activity" && isExploreFeedFragmentRequest(r) {
56
+			h.renderFeedFragment(w, r, feed, hasNext, nextURL)
57
+			return
58
+		}
5559
 		if viewer.ID != 0 {
5660
 			var err error
5761
 			topRepos, err = social.DashboardRepos(r.Context(), deps, viewer.ID, 30)
@@ -73,6 +77,10 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat
7377
 			h.logger.WarnContext(r.Context(), "explore trending users", "error", err)
7478
 		}
7579
 	}
80
+	if activeTab == "activity" && isExploreFeedFragmentRequest(r) {
81
+		h.renderFeedFragment(w, r, feed, hasNext, nextURL)
82
+		return
83
+	}
7684
 
7785
 	pageHeading := title
7886
 	feedHeading := "Public activity"
@@ -102,6 +110,7 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat
102110
 		"TrendingRepos":  trendingRepos,
103111
 		"TrendingUsers":  trendingUsers,
104112
 		"Path":           path,
113
+		"UseHTMX":        true,
105114
 	}
106115
 	if err := h.render.RenderPage(w, r, "explore/index", data); err != nil {
107116
 		if h.logger != nil {
@@ -111,6 +120,24 @@ func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, pat
111120
 	}
112121
 }
113122
 
123
+func (h exploreHandler) renderFeedFragment(w http.ResponseWriter, r *http.Request, feed []social.FeedItem, hasNext bool, nextURL string) {
124
+	data := map[string]any{
125
+		"Feed":        feed,
126
+		"FeedHasNext": hasNext,
127
+		"FeedNextURL": nextURL,
128
+	}
129
+	if err := h.render.RenderFragment(w, "explore/feed_page", data); err != nil {
130
+		if h.logger != nil {
131
+			h.logger.ErrorContext(r.Context(), "render explore feed fragment", "error", err)
132
+		}
133
+		http.Error(w, "internal server error", http.StatusInternalServerError)
134
+	}
135
+}
136
+
137
+func isExploreFeedFragmentRequest(r *http.Request) bool {
138
+	return r.Header.Get("HX-Request") == "true" && r.URL.Query().Get("before") != ""
139
+}
140
+
114141
 func feedPageFor(r *http.Request, load func(social.FeedCursor, int32) ([]social.FeedItem, error)) ([]social.FeedItem, bool, string) {
115142
 	items, err := load(parseFeedCursor(r), feedDisplayLimit+1)
116143
 	if err != nil {
internal/web/handlers/explore_test.goadded
@@ -0,0 +1,71 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"net/http/httptest"
7
+	"net/url"
8
+	"testing"
9
+	"time"
10
+
11
+	"github.com/tenseleyFlow/shithub/internal/social"
12
+)
13
+
14
+func TestExploreFeedFragmentRequestRequiresHTMXAndCursor(t *testing.T) {
15
+	t.Parallel()
16
+
17
+	req := httptest.NewRequest("GET", "/explore?before=2026-05-12T00:00:00Z~42", nil)
18
+	if isExploreFeedFragmentRequest(req) {
19
+		t.Fatal("plain cursor request should stay a full-page no-JS fallback")
20
+	}
21
+
22
+	req.Header.Set("HX-Request", "true")
23
+	if !isExploreFeedFragmentRequest(req) {
24
+		t.Fatal("HTMX cursor request should render feed fragment")
25
+	}
26
+
27
+	req = httptest.NewRequest("GET", "/explore", nil)
28
+	req.Header.Set("HX-Request", "true")
29
+	if isExploreFeedFragmentRequest(req) {
30
+		t.Fatal("HTMX first-page request should not render feed fragment")
31
+	}
32
+}
33
+
34
+func TestFeedPageForBuildsNextCursorFromLastDisplayedItem(t *testing.T) {
35
+	t.Parallel()
36
+
37
+	req := httptest.NewRequest("GET", "/explore?tab=activity", nil)
38
+	base := time.Date(2026, 5, 12, 14, 0, 0, 0, time.UTC)
39
+	items := make([]social.FeedItem, 0, feedDisplayLimit+1)
40
+	for i := int32(0); i < feedDisplayLimit+1; i++ {
41
+		items = append(items, social.FeedItem{
42
+			ID:        int64(1000 + i),
43
+			CreatedAt: base.Add(-time.Duration(i) * time.Minute),
44
+		})
45
+	}
46
+
47
+	got, hasNext, nextURL := feedPageFor(req, func(cursor social.FeedCursor, limit int32) ([]social.FeedItem, error) {
48
+		if !cursor.BeforeCreatedAt.IsZero() || cursor.BeforeID != 0 {
49
+			t.Fatalf("first page cursor = %+v, want zero", cursor)
50
+		}
51
+		if limit != feedDisplayLimit+1 {
52
+			t.Fatalf("limit = %d, want %d", limit, feedDisplayLimit+1)
53
+		}
54
+		return items, nil
55
+	})
56
+
57
+	if len(got) != int(feedDisplayLimit) {
58
+		t.Fatalf("display item count = %d, want %d", len(got), feedDisplayLimit)
59
+	}
60
+	if !hasNext {
61
+		t.Fatal("hasNext = false, want true")
62
+	}
63
+	wantCursor := got[len(got)-1].CreatedAt.UTC().Format(time.RFC3339Nano) + "~" + "1019"
64
+	parsed, err := url.Parse(nextURL)
65
+	if err != nil {
66
+		t.Fatalf("parse nextURL: %v", err)
67
+	}
68
+	if parsed.Query().Get("tab") != "activity" || parsed.Query().Get("before") != wantCursor {
69
+		t.Fatalf("nextURL = %q, want preserved query and cursor %q", nextURL, wantCursor)
70
+	}
71
+}
internal/web/static/css/shithub.cssmodified
@@ -10483,6 +10483,21 @@ button.shithub-repo-action {
1048310483
   justify-content: center;
1048410484
   padding-top: 1rem;
1048510485
 }
10486
+.shithub-feed-pagination:empty {
10487
+  display: none;
10488
+}
10489
+.shithub-feed-more-button {
10490
+  min-width: 4.5rem;
10491
+}
10492
+.shithub-feed-more-loading {
10493
+  display: none;
10494
+}
10495
+.shithub-feed-more-button.htmx-request .shithub-feed-more-label {
10496
+  display: none;
10497
+}
10498
+.shithub-feed-more-button.htmx-request .shithub-feed-more-loading {
10499
+  display: inline;
10500
+}
1048610501
 .shithub-side-panel {
1048710502
   padding: 0 0 1.25rem;
1048810503
   margin-bottom: 1.25rem;
internal/web/templates/_explore_feed_pagination.htmladded
@@ -0,0 +1,21 @@
1
+{{ define "explore-feed-pagination" -}}
2
+{{ if .FeedHasNext }}
3
+<nav id="shithub-feed-pagination" class="shithub-feed-pagination" aria-label="Activity pagination">
4
+  <a
5
+    class="shithub-button shithub-feed-more-button"
6
+    href="{{ .FeedNextURL }}"
7
+    hx-get="{{ .FeedNextURL }}"
8
+    hx-target="#shithub-feed-list"
9
+    hx-swap="beforeend"
10
+    hx-select="#shithub-feed-fragment-rows > *"
11
+    hx-select-oob="#shithub-feed-pagination:outerHTML"
12
+    hx-indicator="this"
13
+  >
14
+    <span class="shithub-feed-more-label">More</span>
15
+    <span class="shithub-feed-more-loading">Loading...</span>
16
+  </a>
17
+</nav>
18
+{{ else }}
19
+<nav id="shithub-feed-pagination" class="shithub-feed-pagination" aria-label="Activity pagination"></nav>
20
+{{ end }}
21
+{{- end }}
internal/web/templates/explore/feed_page.htmladded
@@ -0,0 +1,6 @@
1
+{{ define "page" -}}
2
+<div id="shithub-feed-fragment-rows">
3
+  {{ range .Feed }}{{ template "feed-row" . }}{{ end }}
4
+</div>
5
+{{ template "explore-feed-pagination" . }}
6
+{{- end }}
internal/web/templates/explore/index.htmlmodified
@@ -97,7 +97,7 @@
9797
         <button type="button" class="shithub-button shithub-button-small" disabled>{{ octicon "list-unordered" }} Filter</button>
9898
       </div>
9999
       {{ if .Feed }}
100
-      <ol class="shithub-feed-list">
100
+      <ol id="shithub-feed-list" class="shithub-feed-list" aria-live="polite">
101101
         {{ range .Feed }}{{ template "feed-row" . }}{{ end }}
102102
       </ol>
103103
       {{ else }}
@@ -107,11 +107,7 @@
107107
         {{ if .Viewer.ID }}<a href="/trending" class="shithub-button">Explore trending repositories</a>{{ end }}
108108
       </div>
109109
       {{ end }}
110
-      {{ if .FeedHasNext }}
111
-      <nav class="shithub-feed-pagination" aria-label="Public activity pagination">
112
-        <a class="shithub-button" href="{{ .FeedNextURL }}">More</a>
113
-      </nav>
114
-      {{ end }}
110
+      {{ template "explore-feed-pagination" . }}
115111
       {{ end }}
116112
     </main>
117113