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