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