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