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