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