appview: implement follower and following pages for users #484

merged
opened by ptr.pet targeting master from ptr.pet/core: followers-following-list
+2 -7
appview/db/profile.go
···
return tx.Commit()
}
-
func GetProfiles(e Execer, filters ...filter) ([]Profile, error) {
+
func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) {
var conditions []string
var args []any
for _, filter := range filters {
···
idxs[did] = idx + 1
}
-
var profiles []Profile
-
for _, p := range profileMap {
-
profiles = append(profiles, *p)
-
}
-
-
return profiles, nil
+
return profileMap, nil
}
func GetProfile(e Execer, did string) (*Profile, error) {
+2 -6
appview/db/timeline.go
···
return nil, nil
}
-
profileMap := make(map[string]Profile)
profiles, err := GetProfiles(e, FilterIn("did", subjects))
if err != nil {
return nil, err
}
-
for _, p := range profiles {
-
profileMap[p.Did] = p
-
}
followStatMap := make(map[string]FollowStats)
for _, s := range subjects {
···
var events []TimelineEvent
for _, f := range follows {
-
profile, _ := profileMap[f.SubjectDid]
+
profile, _ := profiles[f.SubjectDid]
followStatMap, _ := followStatMap[f.SubjectDid]
events = append(events, TimelineEvent{
Follow: &f,
-
Profile: &profile,
+
Profile: profile,
FollowStats: &followStatMap,
EventAt: f.FollowedAt,
})
+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, ":", "_")
+
},
}
}
+33 -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
+
FollowStatus db.FollowStatus
+
FollowersCount int
+
FollowingCount int
+
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
+3 -3
appview/pages/templates/timeline/timeline.html
···
{{ end }}
{{ end }}
{{ with $stat }}
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
-
<span id="followers">{{ .Followers }} followers</span>
+
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
<span class="select-none after:content-['·']"></span>
-
<span id="following">{{ .Following }} following</span>
+
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
</div>
{{ end }}
</div>
+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 }}?tab=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 }}?tab=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 }}
+29
appview/pages/templates/user/fragments/followCard.html
···
+
{{ define "user/fragments/followCard" }}
+
{{ $userIdent := resolve .UserDid }}
+
<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 $userIdent }}" />
+
</div>
+
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
+
<a href="/{{ $userIdent }}">
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
+
</a>
+
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
+
<span class="select-none after:content-['·']"></span>
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
+
</div>
+
</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 }}?tab=followers">{{ .FollowersCount }} followers</a></span>
+
<span class="select-none after:content-['·']"></span>
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
+
</div>
+
{{ end }}
{{ end }}
+1 -1
appview/pages/templates/user/repos.html
···
{{ define "extrameta" }}
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" />
<meta property="og:type" content="object" />
-
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" />
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" />
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
{{ end }}
+169 -12
appview/state/profile.go
···
import (
"context"
+
"errors"
"fmt"
"log"
"net/http"
···
"github.com/gorilla/feeds"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
)
···
s.profilePage(w, r)
case "repos":
s.reposPage(w, r)
+
case "followers":
+
s.followersPage(w, r)
+
case "following":
+
s.followingPage(w, r)
}
}
···
Repos: pinnedRepos,
CollaboratingRepos: pinnedCollaboratingRepos,
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,
···
LoggedInUser: loggedInUser,
Repos: repos,
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,
},
})
}
+
type FollowsPageParams struct {
+
LoggedInUser *oauth.User
+
Follows []pages.FollowCard
+
Card pages.ProfileCard
+
}
+
+
func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) {
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
+
if !ok {
+
s.pages.Error404(w)
+
return FollowsPageParams{}, errors.New("identity not found")
+
}
+
did := ident.DID.String()
+
+
profile, err := db.GetProfile(s.db, did)
+
if err != nil {
+
log.Printf("getting profile data for %s: %s", did, err)
+
return FollowsPageParams{}, err
+
}
+
+
loggedInUser := s.oauth.GetUser(r)
+
+
follows, err := fetchFollows(s.db, did)
+
if err != nil {
+
log.Printf("getting followers for %s: %s", did, err)
+
return FollowsPageParams{}, err
+
}
+
+
var loggedInUserFollowing map[string]struct{}
+
if loggedInUser != nil {
+
following, err := db.GetFollowing(s.db, loggedInUser.Did)
+
if err != nil {
+
return FollowsPageParams{}, err
+
}
+
if len(following) > 0 {
+
loggedInUserFollowing = make(map[string]struct{}, len(following))
+
for _, follow := range following {
+
loggedInUserFollowing[follow.SubjectDid] = struct{}{}
+
}
+
}
+
}
+
+
followStatus := db.IsNotFollowing
+
if loggedInUser != nil {
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
+
}
+
+
followersCount, followingCount, err := db.GetFollowerFollowingCount(s.db, did)
+
if err != nil {
+
log.Printf("getting follow stats followers for %s: %s", did, err)
+
return FollowsPageParams{}, err
+
}
+
+
if len(follows) == 0 {
+
return FollowsPageParams{
+
LoggedInUser: loggedInUser,
+
Follows: []pages.FollowCard{},
+
Card: pages.ProfileCard{
+
UserDid: did,
+
UserHandle: ident.Handle.String(),
+
Profile: profile,
+
FollowStatus: followStatus,
+
FollowersCount: followersCount,
+
FollowingCount: followingCount,
+
},
+
}, nil
+
}
+
+
followDids := make([]string, 0, len(follows))
+
for _, follow := range follows {
+
followDids = append(followDids, extractDid(follow))
+
}
+
+
profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids))
+
if err != nil {
+
log.Printf("getting profile for %s: %s", followDids, err)
+
return FollowsPageParams{}, err
+
}
+
+
followCards := make([]pages.FollowCard, 0, len(follows))
+
for _, did := range followDids {
+
followersCount, followingCount, err := db.GetFollowerFollowingCount(s.db, did)
+
if err != nil {
+
log.Printf("getting follow stats for %s: %s", did, err)
+
}
+
followStatus := db.IsNotFollowing
+
if loggedInUserFollowing != nil {
+
if _, exists := loggedInUserFollowing[did]; exists {
+
followStatus = db.IsFollowing
+
} else if loggedInUser.Did == did {
+
followStatus = db.IsSelf
+
}
+
}
+
var profile *db.Profile
+
if p, exists := profiles[did]; exists {
+
profile = p
+
} else {
+
profile = &db.Profile{}
+
profile.Did = did
+
}
+
followCards = append(followCards, pages.FollowCard{
+
UserDid: did,
+
FollowStatus: followStatus,
+
FollowersCount: followersCount,
+
FollowingCount: followingCount,
+
Profile: profile,
+
})
+
}
+
+
return FollowsPageParams{
+
LoggedInUser: loggedInUser,
+
Follows: followCards,
+
Card: pages.ProfileCard{
+
UserDid: did,
+
UserHandle: ident.Handle.String(),
+
Profile: profile,
+
FollowStatus: followStatus,
+
FollowersCount: followersCount,
+
FollowingCount: followingCount,
+
},
+
}, nil
+
}
+
+
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
+
followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
+
if err != nil {
+
s.pages.Notice(w, "all-followers", "Failed to load followers")
+
return
+
}
+
+
s.pages.FollowersPage(w, pages.FollowersPageParams{
+
LoggedInUser: followPage.LoggedInUser,
+
Followers: followPage.Follows,
+
Card: followPage.Card,
+
})
+
}
+
+
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
+
followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
+
if err != nil {
+
s.pages.Notice(w, "all-following", "Failed to load following")
+
return
+
}
+
+
s.pages.FollowingPage(w, pages.FollowingPageParams{
+
LoggedInUser: followPage.LoggedInUser,
+
Following: followPage.Follows,
+
Card: followPage.Card,
+
})
+
}
+
func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
if !ok {
+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,
})