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