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/oauth"
21 "tangled.sh/tangled.sh/core/appview/pages"
22)
23
24func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
25 tabVal := r.URL.Query().Get("tab")
26 switch tabVal {
27 case "":
28 s.profileHomePage(w, r)
29 case "repos":
30 s.reposPage(w, r)
31 case "followers":
32 s.followersPage(w, r)
33 case "following":
34 s.followingPage(w, r)
35 }
36}
37
38type ProfilePageParams struct {
39 Id identity.Identity
40 LoggedInUser *oauth.User
41 Card pages.ProfileCard
42}
43
44func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams {
45 didOrHandle := chi.URLParam(r, "user")
46 if didOrHandle == "" {
47 http.Error(w, "bad request", http.StatusBadRequest)
48 return nil
49 }
50
51 ident, ok := r.Context().Value("resolvedId").(identity.Identity)
52 if !ok {
53 log.Printf("malformed middleware")
54 w.WriteHeader(http.StatusInternalServerError)
55 return nil
56 }
57 did := ident.DID.String()
58
59 profile, err := db.GetProfile(s.db, did)
60 if err != nil {
61 log.Printf("getting profile data for %s: %s", did, err)
62 s.pages.Error500(w)
63 return nil
64 }
65
66 followStats, err := db.GetFollowerFollowingCount(s.db, did)
67 if err != nil {
68 log.Printf("getting follow stats for %s: %s", did, err)
69 }
70
71 loggedInUser := s.oauth.GetUser(r)
72 followStatus := db.IsNotFollowing
73 if loggedInUser != nil {
74 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
75 }
76
77 return &ProfilePageParams{
78 Id: ident,
79 LoggedInUser: loggedInUser,
80 Card: pages.ProfileCard{
81 UserDid: did,
82 UserHandle: ident.Handle.String(),
83 Profile: profile,
84 FollowStatus: followStatus,
85 FollowersCount: followStats.Followers,
86 FollowingCount: followStats.Following,
87 },
88 }
89}
90
91func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) {
92 pageWithProfile := s.profilePage(w, r)
93 if pageWithProfile == nil {
94 return
95 }
96
97 id := pageWithProfile.Id
98 repos, err := db.GetRepos(
99 s.db,
100 0,
101 db.FilterEq("did", id.DID),
102 )
103 if err != nil {
104 log.Printf("getting repos for %s: %s", id.DID, err)
105 }
106
107 profile := pageWithProfile.Card.Profile
108 // filter out ones that are pinned
109 pinnedRepos := []db.Repo{}
110 for i, r := range repos {
111 // if this is a pinned repo, add it
112 if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
113 pinnedRepos = append(pinnedRepos, r)
114 }
115
116 // if there are no saved pins, add the first 4 repos
117 if profile.IsPinnedReposEmpty() && i < 4 {
118 pinnedRepos = append(pinnedRepos, r)
119 }
120 }
121
122 collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String())
123 if err != nil {
124 log.Printf("getting collaborating repos for %s: %s", id.DID, err)
125 }
126
127 pinnedCollaboratingRepos := []db.Repo{}
128 for _, r := range collaboratingRepos {
129 // if this is a pinned repo, add it
130 if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
131 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
132 }
133 }
134
135 timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
136 if err != nil {
137 log.Printf("failed to create profile timeline for %s: %s", id.DID, err)
138 }
139
140 var didsToResolve []string
141 for _, r := range collaboratingRepos {
142 didsToResolve = append(didsToResolve, r.Did)
143 }
144 for _, byMonth := range timeline.ByMonth {
145 for _, pe := range byMonth.PullEvents.Items {
146 didsToResolve = append(didsToResolve, pe.Repo.Did)
147 }
148 for _, ie := range byMonth.IssueEvents.Items {
149 didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
150 }
151 for _, re := range byMonth.RepoEvents {
152 didsToResolve = append(didsToResolve, re.Repo.Did)
153 if re.Source != nil {
154 didsToResolve = append(didsToResolve, re.Source.Did)
155 }
156 }
157 }
158
159 now := time.Now()
160 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
161 punchcard, err := db.MakePunchcard(
162 s.db,
163 db.FilterEq("did", id.DID),
164 db.FilterGte("date", startOfYear.Format(time.DateOnly)),
165 db.FilterLte("date", now.Format(time.DateOnly)),
166 )
167 if err != nil {
168 log.Println("failed to get punchcard for did", "did", id.DID, "err", err)
169 }
170
171 s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{
172 LoggedInUser: pageWithProfile.LoggedInUser,
173 Repos: pinnedRepos,
174 CollaboratingRepos: pinnedCollaboratingRepos,
175 Card: pageWithProfile.Card,
176 Punchcard: punchcard,
177 ProfileTimeline: timeline,
178 })
179}
180
181func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
182 pageWithProfile := s.profilePage(w, r)
183 if pageWithProfile == nil {
184 return
185 }
186
187 id := pageWithProfile.Id
188 repos, err := db.GetRepos(
189 s.db,
190 0,
191 db.FilterEq("did", id.DID),
192 )
193 if err != nil {
194 log.Printf("getting repos for %s: %s", id.DID, err)
195 }
196
197 s.pages.ReposPage(w, pages.ReposPageParams{
198 LoggedInUser: pageWithProfile.LoggedInUser,
199 Repos: repos,
200 Card: pageWithProfile.Card,
201 })
202}
203
204type FollowsPageParams struct {
205 LoggedInUser *oauth.User
206 Follows []pages.FollowCard
207 Card pages.ProfileCard
208}
209
210func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) {
211 pageWithProfile := s.profilePage(w, r)
212 if pageWithProfile == nil {
213 return FollowsPageParams{}, nil
214 }
215
216 id := pageWithProfile.Id
217 loggedInUser := pageWithProfile.LoggedInUser
218
219 follows, err := fetchFollows(s.db, id.DID.String())
220 if err != nil {
221 log.Printf("getting followers for %s: %s", id.DID, err)
222 return FollowsPageParams{}, err
223 }
224
225 if len(follows) == 0 {
226 return FollowsPageParams{
227 LoggedInUser: loggedInUser,
228 Follows: []pages.FollowCard{},
229 Card: pageWithProfile.Card,
230 }, nil
231 }
232
233 followDids := make([]string, 0, len(follows))
234 for _, follow := range follows {
235 followDids = append(followDids, extractDid(follow))
236 }
237
238 profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
239 if err != nil {
240 log.Printf("getting profile for %s: %s", followDids, err)
241 return FollowsPageParams{}, err
242 }
243
244 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids)
245 if err != nil {
246 log.Printf("getting follow counts for %s: %s", followDids, err)
247 }
248
249 var loggedInUserFollowing map[string]struct{}
250 if loggedInUser != nil {
251 following, err := db.GetFollowing(s.db, loggedInUser.Did)
252 if err != nil {
253 return FollowsPageParams{}, err
254 }
255 if len(following) > 0 {
256 loggedInUserFollowing = make(map[string]struct{}, len(following))
257 for _, follow := range following {
258 loggedInUserFollowing[follow.SubjectDid] = struct{}{}
259 }
260 }
261 }
262
263 followCards := make([]pages.FollowCard, 0, len(follows))
264 for _, did := range followDids {
265 followStats, exists := followStatsMap[did]
266 if !exists {
267 followStats = db.FollowStats{}
268 }
269 followStatus := db.IsNotFollowing
270 if loggedInUserFollowing != nil {
271 if _, exists := loggedInUserFollowing[did]; exists {
272 followStatus = db.IsFollowing
273 } else if loggedInUser.Did == did {
274 followStatus = db.IsSelf
275 }
276 }
277 var profile *db.Profile
278 if p, exists := profiles[did]; exists {
279 profile = p
280 } else {
281 profile = &db.Profile{}
282 profile.Did = did
283 }
284 followCards = append(followCards, pages.FollowCard{
285 UserDid: did,
286 FollowStatus: followStatus,
287 FollowersCount: followStats.Followers,
288 FollowingCount: followStats.Following,
289 Profile: profile,
290 })
291 }
292
293 return FollowsPageParams{
294 LoggedInUser: loggedInUser,
295 Follows: followCards,
296 Card: pageWithProfile.Card,
297 }, nil
298}
299
300func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
301 followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
302 if err != nil {
303 s.pages.Notice(w, "all-followers", "Failed to load followers")
304 return
305 }
306
307 s.pages.FollowersPage(w, pages.FollowersPageParams{
308 LoggedInUser: followPage.LoggedInUser,
309 Followers: followPage.Follows,
310 Card: followPage.Card,
311 })
312}
313
314func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
315 followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
316 if err != nil {
317 s.pages.Notice(w, "all-following", "Failed to load following")
318 return
319 }
320
321 s.pages.FollowingPage(w, pages.FollowingPageParams{
322 LoggedInUser: followPage.LoggedInUser,
323 Following: followPage.Follows,
324 Card: followPage.Card,
325 })
326}
327
328func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
329 ident, ok := r.Context().Value("resolvedId").(identity.Identity)
330 if !ok {
331 s.pages.Error404(w)
332 return
333 }
334
335 feed, err := s.getProfileFeed(r.Context(), &ident)
336 if err != nil {
337 s.pages.Error500(w)
338 return
339 }
340
341 if feed == nil {
342 return
343 }
344
345 atom, err := feed.ToAtom()
346 if err != nil {
347 s.pages.Error500(w)
348 return
349 }
350
351 w.Header().Set("content-type", "application/atom+xml")
352 w.Write([]byte(atom))
353}
354
355func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) {
356 timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
357 if err != nil {
358 return nil, err
359 }
360
361 author := &feeds.Author{
362 Name: fmt.Sprintf("@%s", id.Handle),
363 }
364
365 feed := feeds.Feed{
366 Title: fmt.Sprintf("%s's timeline", author.Name),
367 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"},
368 Items: make([]*feeds.Item, 0),
369 Updated: time.UnixMilli(0),
370 Author: author,
371 }
372
373 for _, byMonth := range timeline.ByMonth {
374 if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil {
375 return nil, err
376 }
377 if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil {
378 return nil, err
379 }
380 if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil {
381 return nil, err
382 }
383 }
384
385 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
386 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
387 })
388
389 if len(feed.Items) > 0 {
390 feed.Updated = feed.Items[0].Created
391 }
392
393 return &feed, nil
394}
395
396func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error {
397 for _, pull := range pulls {
398 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
399 if err != nil {
400 return err
401 }
402
403 // Add pull request creation item
404 feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author))
405 }
406 return nil
407}
408
409func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
410 for _, issue := range issues {
411 owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
412 if err != nil {
413 return err
414 }
415
416 feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author))
417 }
418 return nil
419}
420
421func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error {
422 for _, repo := range repos {
423 item, err := s.createRepoItem(ctx, repo, author)
424 if err != nil {
425 return err
426 }
427 feed.Items = append(feed.Items, item)
428 }
429 return nil
430}
431
432func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
433 return &feeds.Item{
434 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
435 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"},
436 Created: pull.Created,
437 Author: author,
438 }
439}
440
441func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
442 return &feeds.Item{
443 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
444 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"},
445 Created: issue.Created,
446 Author: author,
447 }
448}
449
450func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
451 var title string
452 if repo.Source != nil {
453 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
454 if err != nil {
455 return nil, err
456 }
457 title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name)
458 } else {
459 title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
460 }
461
462 return &feeds.Item{
463 Title: title,
464 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix
465 Created: repo.Repo.Created,
466 Author: author,
467 }, nil
468}
469
470func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
471 user := s.oauth.GetUser(r)
472
473 err := r.ParseForm()
474 if err != nil {
475 log.Println("invalid profile update form", err)
476 s.pages.Notice(w, "update-profile", "Invalid form.")
477 return
478 }
479
480 profile, err := db.GetProfile(s.db, user.Did)
481 if err != nil {
482 log.Printf("getting profile data for %s: %s", user.Did, err)
483 }
484
485 profile.Description = r.FormValue("description")
486 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
487 profile.Location = r.FormValue("location")
488
489 var links [5]string
490 for i := range 5 {
491 iLink := r.FormValue(fmt.Sprintf("link%d", i))
492 links[i] = iLink
493 }
494 profile.Links = links
495
496 // Parse stats (exactly 2)
497 stat0 := r.FormValue("stat0")
498 stat1 := r.FormValue("stat1")
499
500 if stat0 != "" {
501 profile.Stats[0].Kind = db.VanityStatKind(stat0)
502 }
503
504 if stat1 != "" {
505 profile.Stats[1].Kind = db.VanityStatKind(stat1)
506 }
507
508 if err := db.ValidateProfile(s.db, profile); err != nil {
509 log.Println("invalid profile", err)
510 s.pages.Notice(w, "update-profile", err.Error())
511 return
512 }
513
514 s.updateProfile(profile, w, r)
515}
516
517func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
518 user := s.oauth.GetUser(r)
519
520 err := r.ParseForm()
521 if err != nil {
522 log.Println("invalid profile update form", err)
523 s.pages.Notice(w, "update-profile", "Invalid form.")
524 return
525 }
526
527 profile, err := db.GetProfile(s.db, user.Did)
528 if err != nil {
529 log.Printf("getting profile data for %s: %s", user.Did, err)
530 }
531
532 i := 0
533 var pinnedRepos [6]syntax.ATURI
534 for key, values := range r.Form {
535 if i >= 6 {
536 log.Println("invalid pin update form", err)
537 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.")
538 return
539 }
540 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 {
541 aturi, err := syntax.ParseATURI(values[0])
542 if err != nil {
543 log.Println("invalid profile update form", err)
544 s.pages.Notice(w, "update-profile", "Invalid form.")
545 return
546 }
547 pinnedRepos[i] = aturi
548 i++
549 }
550 }
551 profile.PinnedRepos = pinnedRepos
552
553 s.updateProfile(profile, w, r)
554}
555
556func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
557 user := s.oauth.GetUser(r)
558 tx, err := s.db.BeginTx(r.Context(), nil)
559 if err != nil {
560 log.Println("failed to start transaction", err)
561 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
562 return
563 }
564
565 client, err := s.oauth.AuthorizedClient(r)
566 if err != nil {
567 log.Println("failed to get authorized client", err)
568 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
569 return
570 }
571
572 // yeah... lexgen dose not support syntax.ATURI in the record for some reason,
573 // nor does it support exact size arrays
574 var pinnedRepoStrings []string
575 for _, r := range profile.PinnedRepos {
576 pinnedRepoStrings = append(pinnedRepoStrings, r.String())
577 }
578
579 var vanityStats []string
580 for _, v := range profile.Stats {
581 vanityStats = append(vanityStats, string(v.Kind))
582 }
583
584 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
585 var cid *string
586 if ex != nil {
587 cid = ex.Cid
588 }
589
590 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
591 Collection: tangled.ActorProfileNSID,
592 Repo: user.Did,
593 Rkey: "self",
594 Record: &lexutil.LexiconTypeDecoder{
595 Val: &tangled.ActorProfile{
596 Bluesky: profile.IncludeBluesky,
597 Description: &profile.Description,
598 Links: profile.Links[:],
599 Location: &profile.Location,
600 PinnedRepositories: pinnedRepoStrings,
601 Stats: vanityStats[:],
602 }},
603 SwapRecord: cid,
604 })
605 if err != nil {
606 log.Println("failed to update profile", err)
607 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.")
608 return
609 }
610
611 err = db.UpsertProfile(tx, profile)
612 if err != nil {
613 log.Println("failed to update profile", err)
614 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
615 return
616 }
617
618 s.notifier.UpdateProfile(r.Context(), profile)
619
620 s.pages.HxRedirect(w, "/"+user.Did)
621}
622
623func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
624 user := s.oauth.GetUser(r)
625
626 profile, err := db.GetProfile(s.db, user.Did)
627 if err != nil {
628 log.Printf("getting profile data for %s: %s", user.Did, err)
629 }
630
631 s.pages.EditBioFragment(w, pages.EditBioParams{
632 LoggedInUser: user,
633 Profile: profile,
634 })
635}
636
637func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) {
638 user := s.oauth.GetUser(r)
639
640 profile, err := db.GetProfile(s.db, user.Did)
641 if err != nil {
642 log.Printf("getting profile data for %s: %s", user.Did, err)
643 }
644
645 repos, err := db.GetAllReposByDid(s.db, user.Did)
646 if err != nil {
647 log.Printf("getting repos for %s: %s", user.Did, err)
648 }
649
650 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did)
651 if err != nil {
652 log.Printf("getting collaborating repos for %s: %s", user.Did, err)
653 }
654
655 allRepos := []pages.PinnedRepo{}
656
657 for _, r := range repos {
658 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
659 allRepos = append(allRepos, pages.PinnedRepo{
660 IsPinned: isPinned,
661 Repo: r,
662 })
663 }
664 for _, r := range collaboratingRepos {
665 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
666 allRepos = append(allRepos, pages.PinnedRepo{
667 IsPinned: isPinned,
668 Repo: r,
669 })
670 }
671
672 s.pages.EditPinsFragment(w, pages.EditPinsParams{
673 LoggedInUser: user,
674 Profile: profile,
675 AllRepos: allRepos,
676 })
677}