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