appview: implement follower and following pages for users #484

merged
opened by ptr.pet targeting master from ptr.pet/core: followers-following-list
Changed files
+288 -37
appview
+5
appview/pages/funcmap.go
···
"layoutCenter": func() string {
return "col-span-1 md:col-span-8 lg:col-span-6"
},
+
+
"normalizeForHtmlId": func(s string) string {
+
// TODO: extend this to handle other cases?
+
return strings.ReplaceAll(s, ":", "_")
+
},
}
}
+32 -5
appview/pages/pages.go
···
}
type ProfileCard struct {
-
UserDid string
-
UserHandle string
-
FollowStatus db.FollowStatus
-
Followers int
-
Following int
+
UserDid string
+
UserHandle string
+
FollowStatus db.FollowStatus
+
FollowersCount int
+
FollowingCount int
Profile *db.Profile
}
···
return p.execute("user/repos", w, params)
}
+
type FollowCard struct {
+
UserDid string
+
UserHandle string
+
FollowStatus db.FollowStatus
+
Profile *db.Profile
+
}
+
+
type FollowersPageParams struct {
+
LoggedInUser *oauth.User
+
Followers []FollowCard
+
Card ProfileCard
+
}
+
+
func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error {
+
return p.execute("user/followers", w, params)
+
}
+
+
type FollowingPageParams struct {
+
LoggedInUser *oauth.User
+
Following []FollowCard
+
Card ProfileCard
+
}
+
+
func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error {
+
return p.execute("user/following", w, params)
+
}
+
type FollowFragmentParams struct {
UserDid string
FollowStatus db.FollowStatus
+30
appview/pages/templates/user/followers.html
···
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · followers {{ end }}
+
+
{{ define "extrameta" }}
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" />
+
<meta property="og:type" content="object" />
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/followers" />
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
+
{{ end }}
+
+
{{ define "content" }}
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
+
<div class="md:col-span-3 order-1 md:order-1">
+
{{ template "user/fragments/profileCard" .Card }}
+
</div>
+
<div id="all-followers" class="md:col-span-8 order-2 md:order-2">
+
{{ block "followers" . }}{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "followers" }}
+
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
+
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
+
{{ range .Followers }}
+
{{ template "user/fragments/followCard" . }}
+
{{ else }}
+
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
+
{{ end }}
+
</div>
+
{{ end }}
+30
appview/pages/templates/user/following.html
···
+
{{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · following {{ end }}
+
+
{{ define "extrameta" }}
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" />
+
<meta property="og:type" content="object" />
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/following" />
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
+
{{ end }}
+
+
{{ define "content" }}
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
+
<div class="md:col-span-3 order-1 md:order-1">
+
{{ template "user/fragments/profileCard" .Card }}
+
</div>
+
<div id="all-following" class="md:col-span-8 order-2 md:order-2">
+
{{ block "following" . }}{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
{{ define "following" }}
+
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
+
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
+
{{ range .Following }}
+
{{ template "user/fragments/followCard" . }}
+
{{ else }}
+
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
+
{{ end }}
+
</div>
+
{{ end }}
+2 -2
appview/pages/templates/user/fragments/follow.html
···
{{ define "user/fragments/follow" }}
-
<button id="followBtn"
+
<button id="{{ normalizeForHtmlId .UserDid }}"
class="btn mt-2 w-full flex gap-2 items-center group"
{{ if eq .FollowStatus.String "IsNotFollowing" }}
···
{{ end }}
hx-trigger="click"
-
hx-target="#followBtn"
+
hx-target="#{{ normalizeForHtmlId .UserDid }}"
hx-swap="outerHTML"
>
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
+22
appview/pages/templates/user/fragments/followCard.html
···
+
{{ define "user/fragments/followCard" }}
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
+
<div class="flex-shrink-0 max-h-full w-24 h-24">
+
<img class="object-cover rounded-full p-2" src="{{ fullAvatar .UserHandle }}" />
+
</div>
+
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
+
<a href="/{{ .UserHandle }}">
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ .UserHandle | truncateAt30 }}</span>
+
</a>
+
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
+
</div>
+
+
{{ if ne .FollowStatus.String "IsSelf" }}
+
<div class="max-w-24">
+
{{ template "user/fragments/follow" . }}
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+17 -14
appview/pages/templates/user/fragments/profileCard.html
···
{{ define "user/fragments/profileCard" }}
+
{{ $userIdent := didOrHandle .UserDid .UserHandle }}
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
<div id="avatar" class="col-span-1 flex justify-center items-center">
···
</div>
<div class="col-span-2">
<div class="flex items-center flex-row flex-nowrap gap-2">
-
<p title="{{ didOrHandle .UserDid .UserHandle }}"
+
<p title="{{ $userIdent }}"
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
-
{{ didOrHandle .UserDid .UserHandle }}
+
{{ $userIdent }}
</p>
-
<a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a>
+
<a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a>
</div>
<div class="md:hidden">
-
{{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
+
{{ block "followerFollowing" (list . $userIdent) }} {{ end }}
</div>
</div>
<div class="col-span-3 md:col-span-full">
···
{{ end }}
<div class="hidden md:block">
-
{{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }}
+
{{ block "followerFollowing" (list $ $userIdent) }} {{ end }}
</div>
<div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
···
{{ if .IncludeBluesky }}
<div class="flex items-center gap-2">
<span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span>
-
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a>
+
<a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a>
</div>
{{ end }}
{{ range $link := .Links }}
···
{{ end }}
{{ define "followerFollowing" }}
-
{{ $followers := index . 0 }}
-
{{ $following := index . 1 }}
-
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
-
<span id="followers">{{ $followers }} followers</span>
-
<span class="select-none after:content-['·']"></span>
-
<span id="following">{{ $following }} following</span>
-
</div>
+
{{ $root := index . 0 }}
+
{{ $userIdent := index . 1 }}
+
{{ with $root }}
+
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+
<span id="followers"><a href="/{{ $userIdent }}/followers">{{ .FollowersCount }} followers</a></span>
+
<span class="select-none after:content-['·']"></span>
+
<span id="following"><a href="/{{ $userIdent }}/following">{{ .FollowingCount }} following</a></span>
+
</div>
+
{{ end }}
{{ end }}
+141 -9
appview/state/profile.go
···
CollaboratingRepos: pinnedCollaboratingRepos,
DidHandleMap: didHandleMap,
Card: pages.ProfileCard{
-
UserDid: ident.DID.String(),
-
UserHandle: ident.Handle.String(),
-
Profile: profile,
-
FollowStatus: followStatus,
-
Followers: followers,
-
Following: following,
+
UserDid: ident.DID.String(),
+
UserHandle: ident.Handle.String(),
+
Profile: profile,
+
FollowStatus: followStatus,
+
FollowersCount: followers,
+
FollowingCount: following,
},
Punchcard: punchcard,
ProfileTimeline: timeline,
···
Repos: repos,
DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()},
Card: pages.ProfileCard{
-
UserDid: ident.DID.String(),
+
UserDid: ident.DID.String(),
+
UserHandle: ident.Handle.String(),
+
Profile: profile,
+
FollowStatus: followStatus,
+
FollowersCount: followers,
+
FollowingCount: following,
+
},
+
})
+
}
+
+
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
+
if !ok {
+
s.pages.Error404(w)
+
return
+
}
+
+
profile, err := db.GetProfile(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
+
}
+
+
loggedInUser := s.oauth.GetUser(r)
+
+
followers, err := db.GetFollowers(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting followers for %s: %s", ident.DID.String(), err)
+
}
+
followerCards := make([]pages.FollowCard, len(followers))
+
for i, follower := range followers {
+
ident, err := s.idResolver.ResolveIdent(r.Context(), follower.UserDid)
+
if err != nil {
+
log.Printf("can't resolve handle for %s: %s", follower.UserDid, err)
+
continue
+
}
+
profile, err := db.GetProfile(s.db, follower.UserDid)
+
if err != nil {
+
log.Printf("can't get profile for %s: %s", follower.UserDid, err)
+
continue
+
}
+
followStatus := db.IsNotFollowing
+
if loggedInUser != nil {
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, follower.UserDid)
+
}
+
followerCards[i] = pages.FollowCard{
+
UserDid: follower.UserDid,
UserHandle: ident.Handle.String(),
+
FollowStatus: followStatus,
Profile: profile,
+
}
+
}
+
+
followStatus := db.IsNotFollowing
+
if loggedInUser != nil {
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
+
}
+
+
followersCount, followingCount, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
+
}
+
+
s.pages.FollowersPage(w, pages.FollowersPageParams{
+
LoggedInUser: loggedInUser,
+
Followers: followerCards,
+
Card: pages.ProfileCard{
+
UserDid: ident.DID.String(),
+
UserHandle: ident.Handle.String(),
+
Profile: profile,
+
FollowStatus: followStatus,
+
FollowersCount: followersCount,
+
FollowingCount: followingCount,
+
},
+
})
+
}
+
+
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
+
if !ok {
+
s.pages.Error404(w)
+
return
+
}
+
+
profile, err := db.GetProfile(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting profile data for %s: %s", ident.DID.String(), err)
+
}
+
+
loggedInUser := s.oauth.GetUser(r)
+
+
following, err := db.GetFollowing(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting following for %s: %s", ident.DID.String(), err)
+
}
+
followingCards := make([]pages.FollowCard, len(following))
+
for i, following := range following {
+
ident, err := s.idResolver.ResolveIdent(r.Context(), following.SubjectDid)
+
if err != nil {
+
log.Printf("can't resolve handle for %s: %s", following.SubjectDid, err)
+
continue
+
}
+
profile, err := db.GetProfile(s.db, following.SubjectDid)
+
if err != nil {
+
log.Printf("can't get profile for %s: %s", following.SubjectDid, err)
+
continue
+
}
+
followStatus := db.IsNotFollowing
+
if loggedInUser != nil {
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, following.SubjectDid)
+
}
+
followingCards[i] = pages.FollowCard{
+
UserDid: following.SubjectDid,
+
UserHandle: ident.Handle.String(),
FollowStatus: followStatus,
-
Followers: followers,
-
Following: following,
+
Profile: profile,
+
}
+
}
+
+
followStatus := db.IsNotFollowing
+
if loggedInUser != nil {
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
+
}
+
+
followersCount, followingCount, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
+
if err != nil {
+
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
+
}
+
+
s.pages.FollowingPage(w, pages.FollowingPageParams{
+
LoggedInUser: loggedInUser,
+
Following: followingCards,
+
Card: pages.ProfileCard{
+
UserDid: ident.DID.String(),
+
UserHandle: ident.Handle.String(),
+
Profile: profile,
+
FollowStatus: followStatus,
+
FollowersCount: followersCount,
+
FollowingCount: followingCount,
},
})
}
+2
appview/state/router.go
···
r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
r.Get("/", s.Profile)
+
r.Get("/followers", s.followersPage)
+
r.Get("/following", s.followingPage)
r.Get("/feed.atom", s.AtomFeedPage)
r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
+7 -7
appview/strings/strings.go
···
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
}
-
followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String())
+
followersCount, followingCount, err := db.GetFollowerFollowingCount(s.Db, id.DID.String())
if err != nil {
l.Error("failed to get follow stats", "err", err)
}
···
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
LoggedInUser: s.OAuth.GetUser(r),
Card: pages.ProfileCard{
-
UserDid: id.DID.String(),
-
UserHandle: id.Handle.String(),
-
Profile: profile,
-
FollowStatus: followStatus,
-
Followers: followers,
-
Following: following,
+
UserDid: id.DID.String(),
+
UserHandle: id.Handle.String(),
+
Profile: profile,
+
FollowStatus: followStatus,
+
FollowersCount: followersCount,
+
FollowingCount: followingCount,
},
Strings: all,
})