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