forked from tangled.org/core
Monorepo for Tangled

appview: profile: activity timeline

Changed files
+354 -89
appview
+60 -5
appview/db/issues.go
··· 118 118 issues i 119 119 left join 120 120 comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 121 - where 121 + where 122 122 i.repo_at = ? and i.open = ? 123 123 group by 124 124 i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open ··· 156 156 return issues, nil 157 157 } 158 158 159 + func GetIssuesByOwnerDid(e Execer, ownerDid string) ([]Issue, error) { 160 + var issues []Issue 161 + 162 + rows, err := e.Query( 163 + `select 164 + i.owner_did, 165 + i.repo_at, 166 + i.issue_id, 167 + i.created, 168 + i.title, 169 + i.body, 170 + i.open, 171 + count(c.id) 172 + from 173 + issues i 174 + left join 175 + comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 176 + where 177 + i.owner_did = ? 178 + group by 179 + i.id, i.owner_did, i.repo_at, i.issue_id, i.created, i.title, i.body, i.open 180 + order by 181 + i.created desc`, 182 + ownerDid) 183 + if err != nil { 184 + return nil, err 185 + } 186 + defer rows.Close() 187 + 188 + for rows.Next() { 189 + var issue Issue 190 + var createdAt string 191 + var metadata IssueMetadata 192 + err := rows.Scan(&issue.OwnerDid, &issue.RepoAt, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 193 + if err != nil { 194 + return nil, err 195 + } 196 + 197 + createdTime, err := time.Parse(time.RFC3339, createdAt) 198 + if err != nil { 199 + return nil, err 200 + } 201 + issue.Created = &createdTime 202 + issue.Metadata = &metadata 203 + 204 + issues = append(issues, issue) 205 + } 206 + 207 + if err := rows.Err(); err != nil { 208 + return nil, err 209 + } 210 + 211 + return issues, nil 212 + } 213 + 159 214 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 160 215 query := `select owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 161 216 row := e.QueryRow(query, repoAt, issueId) ··· 219 274 var comments []Comment 220 275 221 276 rows, err := e.Query(` 222 - select 277 + select 223 278 owner_did, 224 279 issue_id, 225 280 comment_id, ··· 230 285 deleted 231 286 from 232 287 comments 233 - where 234 - repo_at = ? and issue_id = ? 288 + where 289 + repo_at = ? and issue_id = ? 235 290 order by 236 291 created asc`, 237 292 repoAt, ··· 354 409 func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error { 355 410 _, err := e.Exec( 356 411 ` 357 - update comments 412 + update comments 358 413 set body = "", 359 414 deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 360 415 where repo_at = ? and issue_id = ? and comment_id = ?
+75
appview/db/profile.go
··· 1 + package db 2 + 3 + import ( 4 + "sort" 5 + "time" 6 + ) 7 + 8 + type ProfileTimelineEvent struct { 9 + EventAt time.Time 10 + Type string 11 + *Issue 12 + *Pull 13 + *Repo 14 + } 15 + 16 + func MakeProfileTimeline(e Execer, forDid string) ([]ProfileTimelineEvent, error) { 17 + timeline := []ProfileTimelineEvent{} 18 + 19 + pulls, err := GetPullsByOwnerDid(e, forDid) 20 + if err != nil { 21 + return timeline, err 22 + } 23 + 24 + for _, pull := range pulls { 25 + repo, err := GetRepoByAtUri(e, string(pull.RepoAt)) 26 + if err != nil { 27 + return timeline, err 28 + } 29 + 30 + timeline = append(timeline, ProfileTimelineEvent{ 31 + EventAt: pull.Created, 32 + Type: "pull", 33 + Pull: &pull, 34 + Repo: repo, 35 + }) 36 + } 37 + 38 + issues, err := GetIssuesByOwnerDid(e, forDid) 39 + if err != nil { 40 + return timeline, err 41 + } 42 + 43 + for _, issue := range issues { 44 + repo, err := GetRepoByAtUri(e, string(issue.RepoAt)) 45 + if err != nil { 46 + return timeline, err 47 + } 48 + 49 + timeline = append(timeline, ProfileTimelineEvent{ 50 + EventAt: *issue.Created, 51 + Type: "issue", 52 + Issue: &issue, 53 + Repo: repo, 54 + }) 55 + } 56 + 57 + repos, err := GetAllReposByDid(e, forDid) 58 + if err != nil { 59 + return timeline, err 60 + } 61 + 62 + for _, repo := range repos { 63 + timeline = append(timeline, ProfileTimelineEvent{ 64 + EventAt: repo.Created, 65 + Type: "repo", 66 + Repo: &repo, 67 + }) 68 + } 69 + 70 + sort.Slice(timeline, func(i, j int) bool { 71 + return timeline[i].EventAt.After(timeline[j].EventAt) 72 + }) 73 + 74 + return timeline, nil 75 + }
+53
appview/db/pulls.go
··· 433 433 return &pull, nil 434 434 } 435 435 436 + func GetPullsByOwnerDid(e Execer, did string) ([]Pull, error) { 437 + var pulls []Pull 438 + 439 + rows, err := e.Query(` 440 + select 441 + owner_did, 442 + repo_at, 443 + pull_id, 444 + created, 445 + title, 446 + state 447 + from 448 + pulls 449 + where 450 + owner_did = ? 451 + order by 452 + created desc`, did) 453 + if err != nil { 454 + return nil, err 455 + } 456 + defer rows.Close() 457 + 458 + for rows.Next() { 459 + var pull Pull 460 + var createdAt string 461 + err := rows.Scan( 462 + &pull.OwnerDid, 463 + &pull.RepoAt, 464 + &pull.PullId, 465 + &createdAt, 466 + &pull.Title, 467 + &pull.State, 468 + ) 469 + if err != nil { 470 + return nil, err 471 + } 472 + 473 + createdTime, err := time.Parse(time.RFC3339, createdAt) 474 + if err != nil { 475 + return nil, err 476 + } 477 + pull.Created = createdTime 478 + 479 + pulls = append(pulls, pull) 480 + } 481 + 482 + if err := rows.Err(); err != nil { 483 + return nil, err 484 + } 485 + 486 + return pulls, nil 487 + } 488 + 436 489 func NewPullComment(e Execer, comment *PullComment) (int64, error) { 437 490 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 438 491 res, err := e.Exec(
+1
appview/pages/pages.go
··· 168 168 FollowStatus db.FollowStatus 169 169 DidHandleMap map[string]string 170 170 AvatarUri string 171 + ProfileTimeline []db.ProfileTimelineEvent 171 172 } 172 173 173 174 type ProfileStats struct {
+74 -15
appview/pages/templates/user/profile.html
··· 1 1 {{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="grid grid-cols-1 md:grid-cols-4 gap-6"> 5 - <div class="md:col-span-1"> 6 - {{ block "profileCard" . }}{{ end }} 7 - </div> 4 + <div class="grid grid-cols-1 md:grid-cols-5 gap-6"> 5 + <div class="md:col-span-1 order-1 md:order-1"> 6 + {{ block "profileCard" . }}{{ end }} 7 + </div> 8 + <div class="md:col-span-2 order-2 md:order-2"> 9 + {{ block "ownRepos" . }}{{ end }} 10 + {{ block "collaboratingRepos" . }}{{ end }} 11 + </div> 12 + 13 + <div class="md:col-span-2 order-3 md:order-3"> 14 + {{ block "profileTimeline" . }}{{ end }} 15 + </div> 16 + </div> 17 + {{ end }} 18 + 19 + 8 20 9 - <div class="md:col-span-3"> 10 - {{ block "ownRepos" . }}{{ end }} 11 - {{ block "collaboratingRepos" . }}{{ end }} 12 - </div> 13 - </div> 21 + {{ define "profileTimeline" }} 22 + <div class="flex flex-col gap-3 relative"> 23 + <p class="px-6 text-sm font-bold py-2 dark:text-white">ACTIVITY</p> 24 + {{ range .ProfileTimeline }} 25 + {{ if eq .Type "issue" }} 26 + <div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit max-w-full flex items-center gap-2"> 27 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 28 + {{ $icon := "ban" }} 29 + {{ if .Issue.Open }} 30 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 31 + {{ $icon = "circle-dot" }} 32 + {{ end }} 33 + <div class="{{ $bgColor }} text-white rounded-full p-1"> 34 + {{ i $icon "w-4 h-4 text-white" }} 35 + </div> 36 + <div> 37 + <p class="text-gray-600 dark:text-gray-300"> 38 + <a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .Issue.IssueId }}" class="no-underline hover:underline">{{ .Issue.Title }} <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span></a> 39 + on 40 + <a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ index $.DidHandleMap .Repo.Did }}<span class="select-none">/</span>{{ .Repo.Name }}</a> 41 + <time class="text-gray-700 dark:text-gray-400 text-xs ml-2">{{ .Repo.Created | shortTimeFmt }}</time> 42 + </p> 43 + </div> 44 + </div> 45 + {{ else if eq .Type "pull" }} 46 + <div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit flex items-center gap-3"> 47 + <div class="bg-purple-600 dark:bg-purple-700 text-white rounded-full p-1"> 48 + {{ i "git-pull-request" "w-4 h-4" }} 49 + </div> 50 + <div> 51 + <p class="text-gray-600 dark:text-gray-300"> 52 + <a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}/pulls/{{ .Pull.PullId }}" class="no-underline hover:underline">{{ .Pull.Title }} <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span></a> 53 + on 54 + <a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 55 + {{ index $.DidHandleMap .Repo.Did }}<span class="select-none">/</span>{{ .Repo.Name }}</a> 56 + <time class="text-gray-700 dark:text-gray-400 text-xs ml-2">{{ .Repo.Created | shortTimeFmt }}</time> 57 + </p> 58 + </div> 59 + </div> 60 + {{ else if eq .Type "repo" }} 61 + <div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit flex items-center gap-3"> 62 + <div class="bg-gray-200 dark:bg-gray-300 text-black rounded-full p-1"> 63 + {{ i "book-plus" "w-4 h-4" }} 64 + </div> 65 + <div> 66 + <p class="text-gray-600 dark:text-gray-300"> 67 + <a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a> 68 + <time class="text-gray-700 dark:text-gray-400 text-xs ml-2">{{ .Repo.Created | shortTimeFmt }}</time> 69 + </p> 70 + </div> 71 + </div> 72 + {{ end }} 73 + {{ end }} 74 + </div> 14 75 {{ end }} 15 76 16 77 {{ define "profileCard" }} 17 78 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 18 79 <div class="flex justify-center items-center"> 19 80 {{ if .AvatarUri }} 20 - <img class="w-1/2 rounded-full p-2" src="{{ .AvatarUri }}" /> 81 + <img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" /> 21 82 {{ end }} 22 83 </div> 23 84 <p class="text-xl font-bold text-center dark:text-white"> ··· 39 100 40 101 {{ define "ownRepos" }} 41 102 <p class="text-sm font-bold py-2 px-6 dark:text-white">REPOS</p> 42 - <div id="repos" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> 103 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 43 104 {{ range .Repos }} 44 105 <div 45 106 id="repo-card" ··· 71 132 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 72 133 {{ end }} 73 134 </div> 74 - {{ end }} 75 135 76 - {{ define "collaboratingRepos" }} 77 136 <p class="text-sm font-bold py-2 px-6 dark:text-white">COLLABORATING ON</p> 78 - <div id="collaborating" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> 137 + <div id="collaborating" class="grid grid-cols-1 gap-4 mb-6"> 79 138 {{ range .CollaboratingRepos }} 80 139 <div 81 140 id="repo-card" ··· 105 164 <p class="px-6 dark:text-white">This user is not collaborating.</p> 106 165 {{ end }} 107 166 </div> 108 - {{ end }} 167 + {{ end }}
+91
appview/state/profile.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "tangled.sh/tangled.sh/core/appview/db" 10 + "tangled.sh/tangled.sh/core/appview/pages" 11 + ) 12 + 13 + func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { 14 + didOrHandle := chi.URLParam(r, "user") 15 + if didOrHandle == "" { 16 + http.Error(w, "Bad request", http.StatusBadRequest) 17 + return 18 + } 19 + 20 + ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle) 21 + if err != nil { 22 + log.Printf("resolving identity: %s", err) 23 + w.WriteHeader(http.StatusNotFound) 24 + return 25 + } 26 + 27 + repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 28 + if err != nil { 29 + log.Printf("getting repos for %s: %s", ident.DID.String(), err) 30 + } 31 + 32 + collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 33 + if err != nil { 34 + log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 35 + } 36 + 37 + timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 38 + if err != nil { 39 + log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 40 + } 41 + 42 + var didsToResolve []string 43 + for _, r := range collaboratingRepos { 44 + didsToResolve = append(didsToResolve, r.Did) 45 + } 46 + for _, evt := range timeline { 47 + didsToResolve = append(didsToResolve, evt.Repo.Did) 48 + } 49 + 50 + resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 51 + didHandleMap := make(map[string]string) 52 + for _, identity := range resolvedIds { 53 + if !identity.Handle.IsInvalidHandle() { 54 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 55 + } else { 56 + didHandleMap[identity.DID.String()] = identity.DID.String() 57 + } 58 + } 59 + 60 + followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 61 + if err != nil { 62 + log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 63 + } 64 + 65 + loggedInUser := s.auth.GetUser(r) 66 + followStatus := db.IsNotFollowing 67 + if loggedInUser != nil { 68 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 69 + } 70 + 71 + profileAvatarUri, err := GetAvatarUri(ident.Handle.String()) 72 + if err != nil { 73 + log.Println("failed to fetch bsky avatar", err) 74 + } 75 + 76 + s.pages.ProfilePage(w, pages.ProfilePageParams{ 77 + LoggedInUser: loggedInUser, 78 + UserDid: ident.DID.String(), 79 + UserHandle: ident.Handle.String(), 80 + Repos: repos, 81 + CollaboratingRepos: collaboratingRepos, 82 + ProfileStats: pages.ProfileStats{ 83 + Followers: followers, 84 + Following: following, 85 + }, 86 + FollowStatus: db.FollowStatus(followStatus), 87 + DidHandleMap: didHandleMap, 88 + AvatarUri: profileAvatarUri, 89 + ProfileTimeline: timeline, 90 + }) 91 + }
-69
appview/state/state.go
··· 740 740 } 741 741 } 742 742 743 - func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { 744 - didOrHandle := chi.URLParam(r, "user") 745 - if didOrHandle == "" { 746 - http.Error(w, "Bad request", http.StatusBadRequest) 747 - return 748 - } 749 - 750 - ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle) 751 - if err != nil { 752 - log.Printf("resolving identity: %s", err) 753 - w.WriteHeader(http.StatusNotFound) 754 - return 755 - } 756 - 757 - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 758 - if err != nil { 759 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 760 - } 761 - 762 - collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 763 - if err != nil { 764 - log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 765 - } 766 - var didsToResolve []string 767 - for _, r := range collaboratingRepos { 768 - didsToResolve = append(didsToResolve, r.Did) 769 - } 770 - resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 771 - didHandleMap := make(map[string]string) 772 - for _, identity := range resolvedIds { 773 - if !identity.Handle.IsInvalidHandle() { 774 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 775 - } else { 776 - didHandleMap[identity.DID.String()] = identity.DID.String() 777 - } 778 - } 779 - 780 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 781 - if err != nil { 782 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 783 - } 784 - 785 - loggedInUser := s.auth.GetUser(r) 786 - followStatus := db.IsNotFollowing 787 - if loggedInUser != nil { 788 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 789 - } 790 - 791 - profileAvatarUri, err := GetAvatarUri(ident.Handle.String()) 792 - if err != nil { 793 - log.Println("failed to fetch bsky avatar", err) 794 - } 795 - 796 - s.pages.ProfilePage(w, pages.ProfilePageParams{ 797 - LoggedInUser: loggedInUser, 798 - UserDid: ident.DID.String(), 799 - UserHandle: ident.Handle.String(), 800 - Repos: repos, 801 - CollaboratingRepos: collaboratingRepos, 802 - ProfileStats: pages.ProfileStats{ 803 - Followers: followers, 804 - Following: following, 805 - }, 806 - FollowStatus: db.FollowStatus(followStatus), 807 - DidHandleMap: didHandleMap, 808 - AvatarUri: profileAvatarUri, 809 - }) 810 - } 811 - 812 743 func GetAvatarUri(handle string) (string, error) { 813 744 return fmt.Sprintf("https://avatars.dog/%s@webp", handle), nil 814 745 }