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