forked from tangled.org/core
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.sh/tangled.sh/core/api/tangled" 19 "tangled.sh/tangled.sh/core/appview/db" 20 "tangled.sh/tangled.sh/core/appview/pages" 21) 22 23func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 24 tabVal := r.URL.Query().Get("tab") 25 switch tabVal { 26 case "repos": 27 s.reposPage(w, r) 28 case "followers": 29 s.followersPage(w, r) 30 case "following": 31 s.followingPage(w, r) 32 case "starred": 33 s.starredPage(w, r) 34 case "strings": 35 s.stringsPage(w, r) 36 default: 37 s.profileOverview(w, r) 38 } 39} 40 41func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { 42 didOrHandle := chi.URLParam(r, "user") 43 if didOrHandle == "" { 44 return nil, fmt.Errorf("empty DID or handle") 45 } 46 47 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 48 if !ok { 49 return nil, fmt.Errorf("failed to resolve ID") 50 } 51 did := ident.DID.String() 52 53 profile, err := db.GetProfile(s.db, did) 54 if err != nil { 55 return nil, fmt.Errorf("failed to get profile: %w", err) 56 } 57 58 repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did)) 59 if err != nil { 60 return nil, fmt.Errorf("failed to get repo count: %w", err) 61 } 62 63 stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did)) 64 if err != nil { 65 return nil, fmt.Errorf("failed to get string count: %w", err) 66 } 67 68 starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 69 if err != nil { 70 return nil, fmt.Errorf("failed to get starred repo count: %w", err) 71 } 72 73 followStats, err := db.GetFollowerFollowingCount(s.db, did) 74 if err != nil { 75 return nil, fmt.Errorf("failed to get follower stats: %w", err) 76 } 77 78 loggedInUser := s.oauth.GetUser(r) 79 followStatus := db.IsNotFollowing 80 if loggedInUser != nil { 81 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 82 } 83 84 now := time.Now() 85 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 86 punchcard, err := db.MakePunchcard( 87 s.db, 88 db.FilterEq("did", did), 89 db.FilterGte("date", startOfYear.Format(time.DateOnly)), 90 db.FilterLte("date", now.Format(time.DateOnly)), 91 ) 92 if err != nil { 93 return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) 94 } 95 96 return &pages.ProfileCard{ 97 UserDid: did, 98 UserHandle: ident.Handle.String(), 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, "profileHandle", profile.UserHandle) 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 := []db.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 := []db.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, "profileHandle", profile.UserHandle) 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, "profileHandle", profile.UserHandle) 212 213 stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_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 repoAts []string 220 for _, s := range stars { 221 repoAts = append(repoAts, string(s.RepoAt)) 222 } 223 224 repos, err := db.GetRepos( 225 s.db, 226 0, 227 db.FilterIn("at_uri", repoAts), 228 ) 229 if err != nil { 230 l.Error("failed to get repos", "err", err) 231 s.pages.Error500(w) 232 return 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, "profileHandle", profile.UserHandle) 252 253 strings, err := db.GetStrings(s.db, 0, db.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) ([]db.Follow, error), 275 extractDid func(db.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, "profileHandle", profile.UserHandle) 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, db.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 := db.IsNotFollowing 333 if _, exists := loggedInUserFollowing[did]; exists { 334 followStatus = db.IsFollowing 335 } else if loggedInUser != nil && loggedInUser.Did == did { 336 followStatus = db.IsSelf 337 } 338 339 var profile *db.Profile 340 if p, exists := profiles[did]; exists { 341 profile = p 342 } else { 343 profile = &db.Profile{} 344 profile.Did = did 345 } 346 followCards[i] = pages.FollowCard{ 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 db.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 db.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 []*db.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 []*db.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 []db.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 *db.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 *db.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 db.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 549 var links [5]string 550 for i := range 5 { 551 iLink := r.FormValue(fmt.Sprintf("link%d", i)) 552 links[i] = iLink 553 } 554 profile.Links = links 555 556 // Parse stats (exactly 2) 557 stat0 := r.FormValue("stat0") 558 stat1 := r.FormValue("stat1") 559 560 if stat0 != "" { 561 profile.Stats[0].Kind = db.VanityStatKind(stat0) 562 } 563 564 if stat1 != "" { 565 profile.Stats[1].Kind = db.VanityStatKind(stat1) 566 } 567 568 if err := db.ValidateProfile(s.db, profile); err != nil { 569 log.Println("invalid profile", err) 570 s.pages.Notice(w, "update-profile", err.Error()) 571 return 572 } 573 574 s.updateProfile(profile, w, r) 575} 576 577func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 578 user := s.oauth.GetUser(r) 579 580 err := r.ParseForm() 581 if err != nil { 582 log.Println("invalid profile update form", err) 583 s.pages.Notice(w, "update-profile", "Invalid form.") 584 return 585 } 586 587 profile, err := db.GetProfile(s.db, user.Did) 588 if err != nil { 589 log.Printf("getting profile data for %s: %s", user.Did, err) 590 } 591 592 i := 0 593 var pinnedRepos [6]syntax.ATURI 594 for key, values := range r.Form { 595 if i >= 6 { 596 log.Println("invalid pin update form", err) 597 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.") 598 return 599 } 600 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 { 601 aturi, err := syntax.ParseATURI(values[0]) 602 if err != nil { 603 log.Println("invalid profile update form", err) 604 s.pages.Notice(w, "update-profile", "Invalid form.") 605 return 606 } 607 pinnedRepos[i] = aturi 608 i++ 609 } 610 } 611 profile.PinnedRepos = pinnedRepos 612 613 s.updateProfile(profile, w, r) 614} 615 616func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 617 user := s.oauth.GetUser(r) 618 tx, err := s.db.BeginTx(r.Context(), nil) 619 if err != nil { 620 log.Println("failed to start transaction", err) 621 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 622 return 623 } 624 625 client, err := s.oauth.AuthorizedClient(r) 626 if err != nil { 627 log.Println("failed to get authorized client", err) 628 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 629 return 630 } 631 632 // yeah... lexgen dose not support syntax.ATURI in the record for some reason, 633 // nor does it support exact size arrays 634 var pinnedRepoStrings []string 635 for _, r := range profile.PinnedRepos { 636 pinnedRepoStrings = append(pinnedRepoStrings, r.String()) 637 } 638 639 var vanityStats []string 640 for _, v := range profile.Stats { 641 vanityStats = append(vanityStats, string(v.Kind)) 642 } 643 644 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 645 var cid *string 646 if ex != nil { 647 cid = ex.Cid 648 } 649 650 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 651 Collection: tangled.ActorProfileNSID, 652 Repo: user.Did, 653 Rkey: "self", 654 Record: &lexutil.LexiconTypeDecoder{ 655 Val: &tangled.ActorProfile{ 656 Bluesky: profile.IncludeBluesky, 657 Description: &profile.Description, 658 Links: profile.Links[:], 659 Location: &profile.Location, 660 PinnedRepositories: pinnedRepoStrings, 661 Stats: vanityStats[:], 662 }}, 663 SwapRecord: cid, 664 }) 665 if err != nil { 666 log.Println("failed to update profile", err) 667 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 668 return 669 } 670 671 err = db.UpsertProfile(tx, profile) 672 if err != nil { 673 log.Println("failed to update profile", err) 674 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 675 return 676 } 677 678 s.notifier.UpdateProfile(r.Context(), profile) 679 680 s.pages.HxRedirect(w, "/"+user.Did) 681} 682 683func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 684 user := s.oauth.GetUser(r) 685 686 profile, err := db.GetProfile(s.db, user.Did) 687 if err != nil { 688 log.Printf("getting profile data for %s: %s", user.Did, err) 689 } 690 691 s.pages.EditBioFragment(w, pages.EditBioParams{ 692 LoggedInUser: user, 693 Profile: profile, 694 }) 695} 696 697func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 698 user := s.oauth.GetUser(r) 699 700 profile, err := db.GetProfile(s.db, user.Did) 701 if err != nil { 702 log.Printf("getting profile data for %s: %s", user.Did, err) 703 } 704 705 repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 706 if err != nil { 707 log.Printf("getting repos for %s: %s", user.Did, err) 708 } 709 710 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 711 if err != nil { 712 log.Printf("getting collaborating repos for %s: %s", user.Did, err) 713 } 714 715 allRepos := []pages.PinnedRepo{} 716 717 for _, r := range repos { 718 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 719 allRepos = append(allRepos, pages.PinnedRepo{ 720 IsPinned: isPinned, 721 Repo: r, 722 }) 723 } 724 for _, r := range collaboratingRepos { 725 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 726 allRepos = append(allRepos, pages.PinnedRepo{ 727 IsPinned: isPinned, 728 Repo: r, 729 }) 730 } 731 732 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 733 LoggedInUser: user, 734 Profile: profile, 735 AllRepos: allRepos, 736 }) 737}