1package state
2
3import (
4 "fmt"
5 "log"
6 "net/http"
7 "slices"
8 "strings"
9 "time"
10
11 comatproto "github.com/bluesky-social/indigo/api/atproto"
12 "github.com/bluesky-social/indigo/atproto/identity"
13 "github.com/bluesky-social/indigo/atproto/syntax"
14 lexutil "github.com/bluesky-social/indigo/lex/util"
15 "github.com/go-chi/chi/v5"
16 "tangled.sh/tangled.sh/core/api/tangled"
17 "tangled.sh/tangled.sh/core/appview/db"
18 "tangled.sh/tangled.sh/core/appview/pages"
19)
20
21func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
22 tabVal := r.URL.Query().Get("tab")
23 switch tabVal {
24 case "":
25 s.profilePage(w, r)
26 case "repos":
27 s.reposPage(w, r)
28 }
29}
30
31func (s *State) profilePage(w http.ResponseWriter, r *http.Request) {
32 didOrHandle := chi.URLParam(r, "user")
33 if didOrHandle == "" {
34 http.Error(w, "Bad request", http.StatusBadRequest)
35 return
36 }
37
38 ident, ok := r.Context().Value("resolvedId").(identity.Identity)
39 if !ok {
40 s.pages.Error404(w)
41 return
42 }
43
44 profile, err := db.GetProfile(s.db, ident.DID.String())
45 if err != nil {
46 log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
47 }
48
49 repos, err := db.GetRepos(
50 s.db,
51 0,
52 db.FilterEq("did", ident.DID.String()),
53 )
54 if err != nil {
55 log.Printf("getting repos for %s: %s", ident.DID.String(), err)
56 }
57
58 // filter out ones that are pinned
59 pinnedRepos := []db.Repo{}
60 for i, r := range repos {
61 // if this is a pinned repo, add it
62 if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
63 pinnedRepos = append(pinnedRepos, r)
64 }
65
66 // if there are no saved pins, add the first 4 repos
67 if profile.IsPinnedReposEmpty() && i < 4 {
68 pinnedRepos = append(pinnedRepos, r)
69 }
70 }
71
72 collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
73 if err != nil {
74 log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
75 }
76
77 pinnedCollaboratingRepos := []db.Repo{}
78 for _, r := range collaboratingRepos {
79 // if this is a pinned repo, add it
80 if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
81 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
82 }
83 }
84
85 timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
86 if err != nil {
87 log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
88 }
89
90 var didsToResolve []string
91 for _, r := range collaboratingRepos {
92 didsToResolve = append(didsToResolve, r.Did)
93 }
94 for _, byMonth := range timeline.ByMonth {
95 for _, pe := range byMonth.PullEvents.Items {
96 didsToResolve = append(didsToResolve, pe.Repo.Did)
97 }
98 for _, ie := range byMonth.IssueEvents.Items {
99 didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
100 }
101 for _, re := range byMonth.RepoEvents {
102 didsToResolve = append(didsToResolve, re.Repo.Did)
103 if re.Source != nil {
104 didsToResolve = append(didsToResolve, re.Source.Did)
105 }
106 }
107 }
108
109 resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
110 didHandleMap := make(map[string]string)
111 for _, identity := range resolvedIds {
112 if !identity.Handle.IsInvalidHandle() {
113 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
114 } else {
115 didHandleMap[identity.DID.String()] = identity.DID.String()
116 }
117 }
118
119 followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
120 if err != nil {
121 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
122 }
123
124 loggedInUser := s.oauth.GetUser(r)
125 followStatus := db.IsNotFollowing
126 if loggedInUser != nil {
127 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
128 }
129
130 now := time.Now()
131 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
132 punchcard, err := db.MakePunchcard(
133 s.db,
134 db.FilterEq("did", ident.DID.String()),
135 db.FilterGte("date", startOfYear.Format(time.DateOnly)),
136 db.FilterLte("date", now.Format(time.DateOnly)),
137 )
138 if err != nil {
139 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
140 }
141
142 s.pages.ProfilePage(w, pages.ProfilePageParams{
143 LoggedInUser: loggedInUser,
144 Repos: pinnedRepos,
145 CollaboratingRepos: pinnedCollaboratingRepos,
146 DidHandleMap: didHandleMap,
147 Card: pages.ProfileCard{
148 UserDid: ident.DID.String(),
149 UserHandle: ident.Handle.String(),
150 Profile: profile,
151 FollowStatus: followStatus,
152 Followers: followers,
153 Following: following,
154 },
155 Punchcard: punchcard,
156 ProfileTimeline: timeline,
157 })
158}
159
160func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
161 ident, ok := r.Context().Value("resolvedId").(identity.Identity)
162 if !ok {
163 s.pages.Error404(w)
164 return
165 }
166
167 profile, err := db.GetProfile(s.db, ident.DID.String())
168 if err != nil {
169 log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
170 }
171
172 repos, err := db.GetRepos(
173 s.db,
174 0,
175 db.FilterEq("did", ident.DID.String()),
176 )
177 if err != nil {
178 log.Printf("getting repos for %s: %s", ident.DID.String(), err)
179 }
180
181 loggedInUser := s.oauth.GetUser(r)
182 followStatus := db.IsNotFollowing
183 if loggedInUser != nil {
184 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
185 }
186
187 followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
188 if err != nil {
189 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
190 }
191
192 s.pages.ReposPage(w, pages.ReposPageParams{
193 LoggedInUser: loggedInUser,
194 Repos: repos,
195 DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()},
196 Card: pages.ProfileCard{
197 UserDid: ident.DID.String(),
198 UserHandle: ident.Handle.String(),
199 Profile: profile,
200 FollowStatus: followStatus,
201 Followers: followers,
202 Following: following,
203 },
204 })
205}
206
207func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
208 user := s.oauth.GetUser(r)
209
210 err := r.ParseForm()
211 if err != nil {
212 log.Println("invalid profile update form", err)
213 s.pages.Notice(w, "update-profile", "Invalid form.")
214 return
215 }
216
217 profile, err := db.GetProfile(s.db, user.Did)
218 if err != nil {
219 log.Printf("getting profile data for %s: %s", user.Did, err)
220 }
221
222 profile.Description = r.FormValue("description")
223 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
224 profile.Location = r.FormValue("location")
225
226 var links [5]string
227 for i := range 5 {
228 iLink := r.FormValue(fmt.Sprintf("link%d", i))
229 links[i] = iLink
230 }
231 profile.Links = links
232
233 // Parse stats (exactly 2)
234 stat0 := r.FormValue("stat0")
235 stat1 := r.FormValue("stat1")
236
237 if stat0 != "" {
238 profile.Stats[0].Kind = db.VanityStatKind(stat0)
239 }
240
241 if stat1 != "" {
242 profile.Stats[1].Kind = db.VanityStatKind(stat1)
243 }
244
245 if err := db.ValidateProfile(s.db, profile); err != nil {
246 log.Println("invalid profile", err)
247 s.pages.Notice(w, "update-profile", err.Error())
248 return
249 }
250
251 s.updateProfile(profile, w, r)
252}
253
254func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
255 user := s.oauth.GetUser(r)
256
257 err := r.ParseForm()
258 if err != nil {
259 log.Println("invalid profile update form", err)
260 s.pages.Notice(w, "update-profile", "Invalid form.")
261 return
262 }
263
264 profile, err := db.GetProfile(s.db, user.Did)
265 if err != nil {
266 log.Printf("getting profile data for %s: %s", user.Did, err)
267 }
268
269 i := 0
270 var pinnedRepos [6]syntax.ATURI
271 for key, values := range r.Form {
272 if i >= 6 {
273 log.Println("invalid pin update form", err)
274 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.")
275 return
276 }
277 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 {
278 aturi, err := syntax.ParseATURI(values[0])
279 if err != nil {
280 log.Println("invalid profile update form", err)
281 s.pages.Notice(w, "update-profile", "Invalid form.")
282 return
283 }
284 pinnedRepos[i] = aturi
285 i++
286 }
287 }
288 profile.PinnedRepos = pinnedRepos
289
290 s.updateProfile(profile, w, r)
291}
292
293func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
294 user := s.oauth.GetUser(r)
295 tx, err := s.db.BeginTx(r.Context(), nil)
296 if err != nil {
297 log.Println("failed to start transaction", err)
298 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
299 return
300 }
301
302 client, err := s.oauth.AuthorizedClient(r)
303 if err != nil {
304 log.Println("failed to get authorized client", err)
305 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
306 return
307 }
308
309 // yeah... lexgen dose not support syntax.ATURI in the record for some reason,
310 // nor does it support exact size arrays
311 var pinnedRepoStrings []string
312 for _, r := range profile.PinnedRepos {
313 pinnedRepoStrings = append(pinnedRepoStrings, r.String())
314 }
315
316 var vanityStats []string
317 for _, v := range profile.Stats {
318 vanityStats = append(vanityStats, string(v.Kind))
319 }
320
321 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
322 var cid *string
323 if ex != nil {
324 cid = ex.Cid
325 }
326
327 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
328 Collection: tangled.ActorProfileNSID,
329 Repo: user.Did,
330 Rkey: "self",
331 Record: &lexutil.LexiconTypeDecoder{
332 Val: &tangled.ActorProfile{
333 Bluesky: profile.IncludeBluesky,
334 Description: &profile.Description,
335 Links: profile.Links[:],
336 Location: &profile.Location,
337 PinnedRepositories: pinnedRepoStrings,
338 Stats: vanityStats[:],
339 }},
340 SwapRecord: cid,
341 })
342 if err != nil {
343 log.Println("failed to update profile", err)
344 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.")
345 return
346 }
347
348 err = db.UpsertProfile(tx, profile)
349 if err != nil {
350 log.Println("failed to update profile", err)
351 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
352 return
353 }
354
355 s.notifier.UpdateProfile(r.Context(), profile)
356
357 s.pages.HxRedirect(w, "/"+user.Did)
358}
359
360func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
361 user := s.oauth.GetUser(r)
362
363 profile, err := db.GetProfile(s.db, user.Did)
364 if err != nil {
365 log.Printf("getting profile data for %s: %s", user.Did, err)
366 }
367
368 s.pages.EditBioFragment(w, pages.EditBioParams{
369 LoggedInUser: user,
370 Profile: profile,
371 })
372}
373
374func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) {
375 user := s.oauth.GetUser(r)
376
377 profile, err := db.GetProfile(s.db, user.Did)
378 if err != nil {
379 log.Printf("getting profile data for %s: %s", user.Did, err)
380 }
381
382 repos, err := db.GetAllReposByDid(s.db, user.Did)
383 if err != nil {
384 log.Printf("getting repos for %s: %s", user.Did, err)
385 }
386
387 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did)
388 if err != nil {
389 log.Printf("getting collaborating repos for %s: %s", user.Did, err)
390 }
391
392 allRepos := []pages.PinnedRepo{}
393
394 for _, r := range repos {
395 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
396 allRepos = append(allRepos, pages.PinnedRepo{
397 IsPinned: isPinned,
398 Repo: r,
399 })
400 }
401 for _, r := range collaboratingRepos {
402 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
403 allRepos = append(allRepos, pages.PinnedRepo{
404 IsPinned: isPinned,
405 Repo: r,
406 })
407 }
408
409 var didsToResolve []string
410 for _, r := range allRepos {
411 didsToResolve = append(didsToResolve, r.Did)
412 }
413 resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve)
414 didHandleMap := make(map[string]string)
415 for _, identity := range resolvedIds {
416 if !identity.Handle.IsInvalidHandle() {
417 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
418 } else {
419 didHandleMap[identity.DID.String()] = identity.DID.String()
420 }
421 }
422
423 s.pages.EditPinsFragment(w, pages.EditPinsParams{
424 LoggedInUser: user,
425 Profile: profile,
426 AllRepos: allRepos,
427 DidHandleMap: didHandleMap,
428 })
429}