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 "":
27 s.profilePage(w, r)
28 case "repos":
29 s.reposPage(w, r)
30 }
31}
32
33func (s *State) profilePage(w http.ResponseWriter, r *http.Request) {
34 didOrHandle := chi.URLParam(r, "user")
35 if didOrHandle == "" {
36 http.Error(w, "Bad request", http.StatusBadRequest)
37 return
38 }
39
40 ident, ok := r.Context().Value("resolvedId").(identity.Identity)
41 if !ok {
42 s.pages.Error404(w)
43 return
44 }
45
46 profile, err := db.GetProfile(s.db, ident.DID.String())
47 if err != nil {
48 log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
49 }
50
51 repos, err := db.GetRepos(
52 s.db,
53 0,
54 db.FilterEq("did", ident.DID.String()),
55 )
56 if err != nil {
57 log.Printf("getting repos for %s: %s", ident.DID.String(), err)
58 }
59
60 // filter out ones that are pinned
61 pinnedRepos := []db.Repo{}
62 for i, r := range repos {
63 // if this is a pinned repo, add it
64 if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
65 pinnedRepos = append(pinnedRepos, r)
66 }
67
68 // if there are no saved pins, add the first 4 repos
69 if profile.IsPinnedReposEmpty() && i < 4 {
70 pinnedRepos = append(pinnedRepos, r)
71 }
72 }
73
74 collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
75 if err != nil {
76 log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
77 }
78
79 pinnedCollaboratingRepos := []db.Repo{}
80 for _, r := range collaboratingRepos {
81 // if this is a pinned repo, add it
82 if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) {
83 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r)
84 }
85 }
86
87 timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
88 if err != nil {
89 log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
90 }
91
92 followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
93 if err != nil {
94 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
95 }
96
97 loggedInUser := s.oauth.GetUser(r)
98 followStatus := db.IsNotFollowing
99 if loggedInUser != nil {
100 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
101 }
102
103 now := time.Now()
104 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
105 punchcard, err := db.MakePunchcard(
106 s.db,
107 db.FilterEq("did", ident.DID.String()),
108 db.FilterGte("date", startOfYear.Format(time.DateOnly)),
109 db.FilterLte("date", now.Format(time.DateOnly)),
110 )
111 if err != nil {
112 log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
113 }
114
115 s.pages.ProfilePage(w, pages.ProfilePageParams{
116 LoggedInUser: loggedInUser,
117 Repos: pinnedRepos,
118 CollaboratingRepos: pinnedCollaboratingRepos,
119 Card: pages.ProfileCard{
120 UserDid: ident.DID.String(),
121 UserHandle: ident.Handle.String(),
122 Profile: profile,
123 FollowStatus: followStatus,
124 Followers: followers,
125 Following: following,
126 },
127 Punchcard: punchcard,
128 ProfileTimeline: timeline,
129 })
130}
131
132func (s *State) reposPage(w http.ResponseWriter, r *http.Request) {
133 ident, ok := r.Context().Value("resolvedId").(identity.Identity)
134 if !ok {
135 s.pages.Error404(w)
136 return
137 }
138
139 profile, err := db.GetProfile(s.db, ident.DID.String())
140 if err != nil {
141 log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
142 }
143
144 repos, err := db.GetRepos(
145 s.db,
146 0,
147 db.FilterEq("did", ident.DID.String()),
148 )
149 if err != nil {
150 log.Printf("getting repos for %s: %s", ident.DID.String(), err)
151 }
152
153 loggedInUser := s.oauth.GetUser(r)
154 followStatus := db.IsNotFollowing
155 if loggedInUser != nil {
156 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
157 }
158
159 followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
160 if err != nil {
161 log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
162 }
163
164 s.pages.ReposPage(w, pages.ReposPageParams{
165 LoggedInUser: loggedInUser,
166 Repos: repos,
167 Card: pages.ProfileCard{
168 UserDid: ident.DID.String(),
169 UserHandle: ident.Handle.String(),
170 Profile: profile,
171 FollowStatus: followStatus,
172 Followers: followers,
173 Following: following,
174 },
175 })
176}
177
178func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
179 ident, ok := r.Context().Value("resolvedId").(identity.Identity)
180 if !ok {
181 s.pages.Error404(w)
182 return
183 }
184
185 feed, err := s.getProfileFeed(r.Context(), &ident)
186 if err != nil {
187 s.pages.Error500(w)
188 return
189 }
190
191 if feed == nil {
192 return
193 }
194
195 atom, err := feed.ToAtom()
196 if err != nil {
197 s.pages.Error500(w)
198 return
199 }
200
201 w.Header().Set("content-type", "application/atom+xml")
202 w.Write([]byte(atom))
203}
204
205func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) {
206 timeline, err := db.MakeProfileTimeline(s.db, id.DID.String())
207 if err != nil {
208 return nil, err
209 }
210
211 author := &feeds.Author{
212 Name: fmt.Sprintf("@%s", id.Handle),
213 }
214
215 feed := feeds.Feed{
216 Title: fmt.Sprintf("%s's timeline", author.Name),
217 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"},
218 Items: make([]*feeds.Item, 0),
219 Updated: time.UnixMilli(0),
220 Author: author,
221 }
222
223 for _, byMonth := range timeline.ByMonth {
224 if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil {
225 return nil, err
226 }
227 if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil {
228 return nil, err
229 }
230 if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil {
231 return nil, err
232 }
233 }
234
235 slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
236 return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
237 })
238
239 if len(feed.Items) > 0 {
240 feed.Updated = feed.Items[0].Created
241 }
242
243 return &feed, nil
244}
245
246func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error {
247 for _, pull := range pulls {
248 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
249 if err != nil {
250 return err
251 }
252
253 // Add pull request creation item
254 feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author))
255 }
256 return nil
257}
258
259func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
260 for _, issue := range issues {
261 owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
262 if err != nil {
263 return err
264 }
265
266 feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author))
267 }
268 return nil
269}
270
271func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error {
272 for _, repo := range repos {
273 item, err := s.createRepoItem(ctx, repo, author)
274 if err != nil {
275 return err
276 }
277 feed.Items = append(feed.Items, item)
278 }
279 return nil
280}
281
282func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
283 return &feeds.Item{
284 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
285 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"},
286 Created: pull.Created,
287 Author: author,
288 }
289}
290
291func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
292 return &feeds.Item{
293 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
294 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
295 Created: issue.Created,
296 Author: author,
297 }
298}
299
300func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
301 var title string
302 if repo.Source != nil {
303 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
304 if err != nil {
305 return nil, err
306 }
307 title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name)
308 } else {
309 title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
310 }
311
312 return &feeds.Item{
313 Title: title,
314 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
315 Created: repo.Repo.Created,
316 Author: author,
317 }, nil
318}
319
320func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
321 user := s.oauth.GetUser(r)
322
323 err := r.ParseForm()
324 if err != nil {
325 log.Println("invalid profile update form", err)
326 s.pages.Notice(w, "update-profile", "Invalid form.")
327 return
328 }
329
330 profile, err := db.GetProfile(s.db, user.Did)
331 if err != nil {
332 log.Printf("getting profile data for %s: %s", user.Did, err)
333 }
334
335 profile.Description = r.FormValue("description")
336 profile.IncludeBluesky = r.FormValue("includeBluesky") == "on"
337 profile.Location = r.FormValue("location")
338
339 var links [5]string
340 for i := range 5 {
341 iLink := r.FormValue(fmt.Sprintf("link%d", i))
342 links[i] = iLink
343 }
344 profile.Links = links
345
346 // Parse stats (exactly 2)
347 stat0 := r.FormValue("stat0")
348 stat1 := r.FormValue("stat1")
349
350 if stat0 != "" {
351 profile.Stats[0].Kind = db.VanityStatKind(stat0)
352 }
353
354 if stat1 != "" {
355 profile.Stats[1].Kind = db.VanityStatKind(stat1)
356 }
357
358 if err := db.ValidateProfile(s.db, profile); err != nil {
359 log.Println("invalid profile", err)
360 s.pages.Notice(w, "update-profile", err.Error())
361 return
362 }
363
364 s.updateProfile(profile, w, r)
365}
366
367func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) {
368 user := s.oauth.GetUser(r)
369
370 err := r.ParseForm()
371 if err != nil {
372 log.Println("invalid profile update form", err)
373 s.pages.Notice(w, "update-profile", "Invalid form.")
374 return
375 }
376
377 profile, err := db.GetProfile(s.db, user.Did)
378 if err != nil {
379 log.Printf("getting profile data for %s: %s", user.Did, err)
380 }
381
382 i := 0
383 var pinnedRepos [6]syntax.ATURI
384 for key, values := range r.Form {
385 if i >= 6 {
386 log.Println("invalid pin update form", err)
387 s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.")
388 return
389 }
390 if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 {
391 aturi, err := syntax.ParseATURI(values[0])
392 if err != nil {
393 log.Println("invalid profile update form", err)
394 s.pages.Notice(w, "update-profile", "Invalid form.")
395 return
396 }
397 pinnedRepos[i] = aturi
398 i++
399 }
400 }
401 profile.PinnedRepos = pinnedRepos
402
403 s.updateProfile(profile, w, r)
404}
405
406func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
407 user := s.oauth.GetUser(r)
408 tx, err := s.db.BeginTx(r.Context(), nil)
409 if err != nil {
410 log.Println("failed to start transaction", err)
411 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
412 return
413 }
414
415 client, err := s.oauth.AuthorizedClient(r)
416 if err != nil {
417 log.Println("failed to get authorized client", err)
418 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
419 return
420 }
421
422 // yeah... lexgen dose not support syntax.ATURI in the record for some reason,
423 // nor does it support exact size arrays
424 var pinnedRepoStrings []string
425 for _, r := range profile.PinnedRepos {
426 pinnedRepoStrings = append(pinnedRepoStrings, r.String())
427 }
428
429 var vanityStats []string
430 for _, v := range profile.Stats {
431 vanityStats = append(vanityStats, string(v.Kind))
432 }
433
434 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
435 var cid *string
436 if ex != nil {
437 cid = ex.Cid
438 }
439
440 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
441 Collection: tangled.ActorProfileNSID,
442 Repo: user.Did,
443 Rkey: "self",
444 Record: &lexutil.LexiconTypeDecoder{
445 Val: &tangled.ActorProfile{
446 Bluesky: profile.IncludeBluesky,
447 Description: &profile.Description,
448 Links: profile.Links[:],
449 Location: &profile.Location,
450 PinnedRepositories: pinnedRepoStrings,
451 Stats: vanityStats[:],
452 }},
453 SwapRecord: cid,
454 })
455 if err != nil {
456 log.Println("failed to update profile", err)
457 s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.")
458 return
459 }
460
461 err = db.UpsertProfile(tx, profile)
462 if err != nil {
463 log.Println("failed to update profile", err)
464 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.")
465 return
466 }
467
468 s.notifier.UpdateProfile(r.Context(), profile)
469
470 s.pages.HxRedirect(w, "/"+user.Did)
471}
472
473func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
474 user := s.oauth.GetUser(r)
475
476 profile, err := db.GetProfile(s.db, user.Did)
477 if err != nil {
478 log.Printf("getting profile data for %s: %s", user.Did, err)
479 }
480
481 s.pages.EditBioFragment(w, pages.EditBioParams{
482 LoggedInUser: user,
483 Profile: profile,
484 })
485}
486
487func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) {
488 user := s.oauth.GetUser(r)
489
490 profile, err := db.GetProfile(s.db, user.Did)
491 if err != nil {
492 log.Printf("getting profile data for %s: %s", user.Did, err)
493 }
494
495 repos, err := db.GetAllReposByDid(s.db, user.Did)
496 if err != nil {
497 log.Printf("getting repos for %s: %s", user.Did, err)
498 }
499
500 collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did)
501 if err != nil {
502 log.Printf("getting collaborating repos for %s: %s", user.Did, err)
503 }
504
505 allRepos := []pages.PinnedRepo{}
506
507 for _, r := range repos {
508 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
509 allRepos = append(allRepos, pages.PinnedRepo{
510 IsPinned: isPinned,
511 Repo: r,
512 })
513 }
514 for _, r := range collaboratingRepos {
515 isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt())
516 allRepos = append(allRepos, pages.PinnedRepo{
517 IsPinned: isPinned,
518 Repo: r,
519 })
520 }
521
522 s.pages.EditPinsFragment(w, pages.EditPinsParams{
523 LoggedInUser: user,
524 Profile: profile,
525 AllRepos: allRepos,
526 })
527}