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 ¶ms, err
283 }
284
285 if len(follows) == 0 {
286 return ¶ms, 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 ¶ms, 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 ¶ms, 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 ¶ms, 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}