Go · 5731 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package handlers
4
5 import (
6 "log/slog"
7 "net/http"
8 "strconv"
9 "strings"
10 "time"
11
12 "github.com/jackc/pgx/v5/pgxpool"
13
14 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
15 "github.com/tenseleyFlow/shithub/internal/social"
16 "github.com/tenseleyFlow/shithub/internal/web/middleware"
17 "github.com/tenseleyFlow/shithub/internal/web/render"
18 )
19
20 const feedDisplayLimit int32 = 20
21
22 type exploreHandler struct {
23 render *render.Renderer
24 logger *slog.Logger
25 pool *pgxpool.Pool
26 }
27
28 func (h exploreHandler) ServeExplore(w http.ResponseWriter, r *http.Request) {
29 h.serve(w, r, "Explore", "/explore", "activity")
30 }
31
32 func (h exploreHandler) ServeTrending(w http.ResponseWriter, r *http.Request) {
33 h.serve(w, r, "Trending", "/trending", "trending")
34 }
35
36 func (h exploreHandler) serve(w http.ResponseWriter, r *http.Request, title, path, activeTab string) {
37 viewer := middleware.CurrentUserFromContext(r.Context())
38 var (
39 feed []social.FeedItem
40 hasNext bool
41 nextURL string
42 topRepos []social.DashboardRepo
43 viewerOrgs []orgsdb.ListOrgsForUserRow
44 trendingRepos []social.TrendingRepo
45 trendingUsers []social.TrendingUser
46 )
47 if h.pool != nil {
48 deps := social.Deps{Pool: h.pool, Logger: h.logger}
49 feed, hasNext, nextURL = feedPageFor(r, func(cursor social.FeedCursor, limit int32) ([]social.FeedItem, error) {
50 if viewer.ID != 0 {
51 return social.DashboardFeed(r.Context(), deps, viewer.ID, cursor, limit)
52 }
53 return social.PublicFeed(r.Context(), deps, cursor, limit)
54 })
55 if activeTab == "activity" && isExploreFeedFragmentRequest(r) {
56 h.renderFeedFragment(w, r, feed, hasNext, nextURL)
57 return
58 }
59 if viewer.ID != 0 {
60 var err error
61 topRepos, err = social.DashboardRepos(r.Context(), deps, viewer.ID, 30)
62 if err != nil && h.logger != nil {
63 h.logger.WarnContext(r.Context(), "explore dashboard repos", "error", err)
64 }
65 viewerOrgs, err = orgsdb.New().ListOrgsForUser(r.Context(), h.pool, viewer.ID)
66 if err != nil && h.logger != nil {
67 h.logger.WarnContext(r.Context(), "explore org switcher", "error", err)
68 }
69 }
70 var err error
71 trendingRepos, err = social.CachedTrendingRepos(r.Context(), deps, social.TrendingScopeWeek, 7, 10)
72 if err != nil && h.logger != nil {
73 h.logger.WarnContext(r.Context(), "explore trending repos", "error", err)
74 }
75 trendingUsers, err = social.CachedTrendingUsers(r.Context(), deps, social.TrendingScopeWeek, 7, 8)
76 if err != nil && h.logger != nil {
77 h.logger.WarnContext(r.Context(), "explore trending users", "error", err)
78 }
79 }
80 if activeTab == "activity" && isExploreFeedFragmentRequest(r) {
81 h.renderFeedFragment(w, r, feed, hasNext, nextURL)
82 return
83 }
84
85 pageHeading := title
86 feedHeading := "Public activity"
87 emptyTitle := "No public activity yet"
88 emptyBody := "Public stars, forks, pushes, issues, pull requests, and follows will appear here."
89 if viewer.ID != 0 {
90 if activeTab == "activity" {
91 pageHeading = "Home"
92 }
93 feedHeading = "Feed"
94 emptyTitle = "Follow people and organizations to build your feed"
95 emptyBody = "Stars, forks, pushes, issues, pull requests, and follows from your network will appear here."
96 }
97
98 data := map[string]any{
99 "Title": title,
100 "ActiveTab": activeTab,
101 "PageHeading": pageHeading,
102 "FeedHeading": feedHeading,
103 "FeedEmptyTitle": emptyTitle,
104 "FeedEmptyBody": emptyBody,
105 "Feed": feed,
106 "FeedHasNext": hasNext,
107 "FeedNextURL": nextURL,
108 "TopRepos": topRepos,
109 "ViewerOrgs": viewerOrgs,
110 "TrendingRepos": trendingRepos,
111 "TrendingUsers": trendingUsers,
112 "Path": path,
113 "UseHTMX": true,
114 }
115 if err := h.render.RenderPage(w, r, "explore/index", data); err != nil {
116 if h.logger != nil {
117 h.logger.Error("render explore", "error", err)
118 }
119 http.Error(w, "internal server error", http.StatusInternalServerError)
120 }
121 }
122
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
141 func feedPageFor(r *http.Request, load func(social.FeedCursor, int32) ([]social.FeedItem, error)) ([]social.FeedItem, bool, string) {
142 items, err := load(parseFeedCursor(r), feedDisplayLimit+1)
143 if err != nil {
144 return nil, false, ""
145 }
146 if int32(len(items)) <= feedDisplayLimit {
147 return items, false, ""
148 }
149 display := items[:feedDisplayLimit]
150 return display, true, feedNextURL(r, display[len(display)-1])
151 }
152
153 func parseFeedCursor(r *http.Request) social.FeedCursor {
154 raw := r.URL.Query().Get("before")
155 if raw == "" {
156 return social.FeedCursor{}
157 }
158 parts := strings.SplitN(raw, "~", 2)
159 if len(parts) != 2 {
160 return social.FeedCursor{}
161 }
162 createdAt, err := time.Parse(time.RFC3339Nano, parts[0])
163 if err != nil {
164 return social.FeedCursor{}
165 }
166 id, err := strconv.ParseInt(parts[1], 10, 64)
167 if err != nil || id <= 0 {
168 return social.FeedCursor{}
169 }
170 return social.FeedCursor{BeforeCreatedAt: createdAt, BeforeID: id}
171 }
172
173 func feedNextURL(r *http.Request, item social.FeedItem) string {
174 q := r.URL.Query()
175 q.Set("before", item.CreatedAt.UTC().Format(time.RFC3339Nano)+"~"+strconv.FormatInt(item.ID, 10))
176 return r.URL.Path + "?" + q.Encode()
177 }
178