forked from tangled.org/core
this repo has no description

appview: profile: activity timeline

Changed files
+354 -89
appview
+60 -5
appview/db/issues.go
···
issues i
left join
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
-
where
+
where
i.repo_at = ? and i.open = ?
group by
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
···
return issues, nil
}
+
func GetIssuesByOwnerDid(e Execer, ownerDid string) ([]Issue, error) {
+
var issues []Issue
+
+
rows, err := e.Query(
+
`select
+
i.owner_did,
+
i.repo_at,
+
i.issue_id,
+
i.created,
+
i.title,
+
i.body,
+
i.open,
+
count(c.id)
+
from
+
issues i
+
left join
+
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
+
where
+
i.owner_did = ?
+
group by
+
i.id, i.owner_did, i.repo_at, i.issue_id, i.created, i.title, i.body, i.open
+
order by
+
i.created desc`,
+
ownerDid)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
for rows.Next() {
+
var issue Issue
+
var createdAt string
+
var metadata IssueMetadata
+
err := rows.Scan(&issue.OwnerDid, &issue.RepoAt, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
+
if err != nil {
+
return nil, err
+
}
+
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
+
if err != nil {
+
return nil, err
+
}
+
issue.Created = &createdTime
+
issue.Metadata = &metadata
+
+
issues = append(issues, issue)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, err
+
}
+
+
return issues, nil
+
}
+
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
query := `select owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?`
row := e.QueryRow(query, repoAt, issueId)
···
var comments []Comment
rows, err := e.Query(`
-
select
+
select
owner_did,
issue_id,
comment_id,
···
deleted
from
comments
-
where
-
repo_at = ? and issue_id = ?
+
where
+
repo_at = ? and issue_id = ?
order by
created asc`,
repoAt,
···
func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
_, err := e.Exec(
`
-
update comments
+
update comments
set body = "",
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
where repo_at = ? and issue_id = ? and comment_id = ?
+75
appview/db/profile.go
···
+
package db
+
+
import (
+
"sort"
+
"time"
+
)
+
+
type ProfileTimelineEvent struct {
+
EventAt time.Time
+
Type string
+
*Issue
+
*Pull
+
*Repo
+
}
+
+
func MakeProfileTimeline(e Execer, forDid string) ([]ProfileTimelineEvent, error) {
+
timeline := []ProfileTimelineEvent{}
+
+
pulls, err := GetPullsByOwnerDid(e, forDid)
+
if err != nil {
+
return timeline, err
+
}
+
+
for _, pull := range pulls {
+
repo, err := GetRepoByAtUri(e, string(pull.RepoAt))
+
if err != nil {
+
return timeline, err
+
}
+
+
timeline = append(timeline, ProfileTimelineEvent{
+
EventAt: pull.Created,
+
Type: "pull",
+
Pull: &pull,
+
Repo: repo,
+
})
+
}
+
+
issues, err := GetIssuesByOwnerDid(e, forDid)
+
if err != nil {
+
return timeline, err
+
}
+
+
for _, issue := range issues {
+
repo, err := GetRepoByAtUri(e, string(issue.RepoAt))
+
if err != nil {
+
return timeline, err
+
}
+
+
timeline = append(timeline, ProfileTimelineEvent{
+
EventAt: *issue.Created,
+
Type: "issue",
+
Issue: &issue,
+
Repo: repo,
+
})
+
}
+
+
repos, err := GetAllReposByDid(e, forDid)
+
if err != nil {
+
return timeline, err
+
}
+
+
for _, repo := range repos {
+
timeline = append(timeline, ProfileTimelineEvent{
+
EventAt: repo.Created,
+
Type: "repo",
+
Repo: &repo,
+
})
+
}
+
+
sort.Slice(timeline, func(i, j int) bool {
+
return timeline[i].EventAt.After(timeline[j].EventAt)
+
})
+
+
return timeline, nil
+
}
+53
appview/db/pulls.go
···
return &pull, nil
}
+
func GetPullsByOwnerDid(e Execer, did string) ([]Pull, error) {
+
var pulls []Pull
+
+
rows, err := e.Query(`
+
select
+
owner_did,
+
repo_at,
+
pull_id,
+
created,
+
title,
+
state
+
from
+
pulls
+
where
+
owner_did = ?
+
order by
+
created desc`, did)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
for rows.Next() {
+
var pull Pull
+
var createdAt string
+
err := rows.Scan(
+
&pull.OwnerDid,
+
&pull.RepoAt,
+
&pull.PullId,
+
&createdAt,
+
&pull.Title,
+
&pull.State,
+
)
+
if err != nil {
+
return nil, err
+
}
+
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
+
if err != nil {
+
return nil, err
+
}
+
pull.Created = createdTime
+
+
pulls = append(pulls, pull)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, err
+
}
+
+
return pulls, nil
+
}
+
func NewPullComment(e Execer, comment *PullComment) (int64, error) {
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
res, err := e.Exec(
+1
appview/pages/pages.go
···
FollowStatus db.FollowStatus
DidHandleMap map[string]string
AvatarUri string
+
ProfileTimeline []db.ProfileTimelineEvent
}
type ProfileStats struct {
+74 -15
appview/pages/templates/user/profile.html
···
{{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }}
{{ define "content" }}
-
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
-
<div class="md:col-span-1">
-
{{ block "profileCard" . }}{{ end }}
-
</div>
+
<div class="grid grid-cols-1 md:grid-cols-5 gap-6">
+
<div class="md:col-span-1 order-1 md:order-1">
+
{{ block "profileCard" . }}{{ end }}
+
</div>
+
<div class="md:col-span-2 order-2 md:order-2">
+
{{ block "ownRepos" . }}{{ end }}
+
{{ block "collaboratingRepos" . }}{{ end }}
+
</div>
+
+
<div class="md:col-span-2 order-3 md:order-3">
+
{{ block "profileTimeline" . }}{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
-
<div class="md:col-span-3">
-
{{ block "ownRepos" . }}{{ end }}
-
{{ block "collaboratingRepos" . }}{{ end }}
-
</div>
-
</div>
+
{{ define "profileTimeline" }}
+
<div class="flex flex-col gap-3 relative">
+
<p class="px-6 text-sm font-bold py-2 dark:text-white">ACTIVITY</p>
+
{{ range .ProfileTimeline }}
+
{{ if eq .Type "issue" }}
+
<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">
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
+
{{ $icon := "ban" }}
+
{{ if .Issue.Open }}
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
+
{{ $icon = "circle-dot" }}
+
{{ end }}
+
<div class="{{ $bgColor }} text-white rounded-full p-1">
+
{{ i $icon "w-4 h-4 text-white" }}
+
</div>
+
<div>
+
<p class="text-gray-600 dark:text-gray-300">
+
<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>
+
on
+
<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>
+
<time class="text-gray-700 dark:text-gray-400 text-xs ml-2">{{ .Repo.Created | shortTimeFmt }}</time>
+
</p>
+
</div>
+
</div>
+
{{ else if eq .Type "pull" }}
+
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit flex items-center gap-3">
+
<div class="bg-purple-600 dark:bg-purple-700 text-white rounded-full p-1">
+
{{ i "git-pull-request" "w-4 h-4" }}
+
</div>
+
<div>
+
<p class="text-gray-600 dark:text-gray-300">
+
<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>
+
on
+
<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>
+
<time class="text-gray-700 dark:text-gray-400 text-xs ml-2">{{ .Repo.Created | shortTimeFmt }}</time>
+
</p>
+
</div>
+
</div>
+
{{ else if eq .Type "repo" }}
+
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit flex items-center gap-3">
+
<div class="bg-gray-200 dark:bg-gray-300 text-black rounded-full p-1">
+
{{ i "book-plus" "w-4 h-4" }}
+
</div>
+
<div>
+
<p class="text-gray-600 dark:text-gray-300">
+
<a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
+
<time class="text-gray-700 dark:text-gray-400 text-xs ml-2">{{ .Repo.Created | shortTimeFmt }}</time>
+
</p>
+
</div>
+
</div>
+
{{ end }}
+
{{ end }}
+
</div>
{{ end }}
{{ define "profileCard" }}
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
<div class="flex justify-center items-center">
{{ if .AvatarUri }}
-
<img class="w-1/2 rounded-full p-2" src="{{ .AvatarUri }}" />
+
<img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" />
{{ end }}
</div>
<p class="text-xl font-bold text-center dark:text-white">
···
{{ define "ownRepos" }}
<p class="text-sm font-bold py-2 px-6 dark:text-white">REPOS</p>
-
<div id="repos" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
{{ range .Repos }}
<div
id="repo-card"
···
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
{{ end }}
</div>
-
{{ end }}
-
{{ define "collaboratingRepos" }}
<p class="text-sm font-bold py-2 px-6 dark:text-white">COLLABORATING ON</p>
-
<div id="collaborating" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
+
<div id="collaborating" class="grid grid-cols-1 gap-4 mb-6">
{{ range .CollaboratingRepos }}
<div
id="repo-card"
···
<p class="px-6 dark:text-white">This user is not collaborating.</p>
{{ end }}
</div>
-
{{ end }}
+
{{ end }}
+91
appview/state/profile.go
···
+
package state
+
+
import (
+
"fmt"
+
"log"
+
"net/http"
+
+
"github.com/go-chi/chi/v5"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/pages"
+
)
+
+
func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) {
+
didOrHandle := chi.URLParam(r, "user")
+
if didOrHandle == "" {
+
http.Error(w, "Bad request", http.StatusBadRequest)
+
return
+
}
+
+
ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle)
+
if err != nil {
+
log.Printf("resolving identity: %s", err)
+
w.WriteHeader(http.StatusNotFound)
+
return
+
}
+
+
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
+
}
+
+
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
+
}
+
+
timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
+
}
+
+
var didsToResolve []string
+
for _, r := range collaboratingRepos {
+
didsToResolve = append(didsToResolve, r.Did)
+
}
+
for _, evt := range timeline {
+
didsToResolve = append(didsToResolve, evt.Repo.Did)
+
}
+
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
} else {
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
}
+
}
+
+
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
+
}
+
+
loggedInUser := s.auth.GetUser(r)
+
followStatus := db.IsNotFollowing
+
if loggedInUser != nil {
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
+
}
+
+
profileAvatarUri, err := GetAvatarUri(ident.Handle.String())
+
if err != nil {
+
log.Println("failed to fetch bsky avatar", err)
+
}
+
+
s.pages.ProfilePage(w, pages.ProfilePageParams{
+
LoggedInUser: loggedInUser,
+
UserDid: ident.DID.String(),
+
UserHandle: ident.Handle.String(),
+
Repos: repos,
+
CollaboratingRepos: collaboratingRepos,
+
ProfileStats: pages.ProfileStats{
+
Followers: followers,
+
Following: following,
+
},
+
FollowStatus: db.FollowStatus(followStatus),
+
DidHandleMap: didHandleMap,
+
AvatarUri: profileAvatarUri,
+
ProfileTimeline: timeline,
+
})
+
}
-69
appview/state/state.go
···
}
}
-
func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) {
-
didOrHandle := chi.URLParam(r, "user")
-
if didOrHandle == "" {
-
http.Error(w, "Bad request", http.StatusBadRequest)
-
return
-
}
-
-
ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle)
-
if err != nil {
-
log.Printf("resolving identity: %s", err)
-
w.WriteHeader(http.StatusNotFound)
-
return
-
}
-
-
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
-
if err != nil {
-
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
-
}
-
-
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
-
if err != nil {
-
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
-
}
-
var didsToResolve []string
-
for _, r := range collaboratingRepos {
-
didsToResolve = append(didsToResolve, r.Did)
-
}
-
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
-
didHandleMap := make(map[string]string)
-
for _, identity := range resolvedIds {
-
if !identity.Handle.IsInvalidHandle() {
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
-
} else {
-
didHandleMap[identity.DID.String()] = identity.DID.String()
-
}
-
}
-
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
-
if err != nil {
-
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
-
}
-
-
loggedInUser := s.auth.GetUser(r)
-
followStatus := db.IsNotFollowing
-
if loggedInUser != nil {
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
-
}
-
-
profileAvatarUri, err := GetAvatarUri(ident.Handle.String())
-
if err != nil {
-
log.Println("failed to fetch bsky avatar", err)
-
}
-
-
s.pages.ProfilePage(w, pages.ProfilePageParams{
-
LoggedInUser: loggedInUser,
-
UserDid: ident.DID.String(),
-
UserHandle: ident.Handle.String(),
-
Repos: repos,
-
CollaboratingRepos: collaboratingRepos,
-
ProfileStats: pages.ProfileStats{
-
Followers: followers,
-
Following: following,
-
},
-
FollowStatus: db.FollowStatus(followStatus),
-
DidHandleMap: didHandleMap,
-
AvatarUri: profileAvatarUri,
-
})
-
}
-
func GetAvatarUri(handle string) (string, error) {
return fmt.Sprintf("https://avatars.dog/%s@webp", handle), nil
}