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