appview: implement follower and following pages for users #484

merged
opened by ptr.pet targeting master from [deleted fork]: followers-following-list
Changed files
+288 -37
appview
+5
appview/pages/funcmap.go
··· 257 257 "layoutCenter": func() string { 258 258 return "col-span-1 md:col-span-8 lg:col-span-6" 259 259 }, 260 + 261 + "normalizeForHtmlId": func(s string) string { 262 + // TODO: extend this to handle other cases? 263 + return strings.ReplaceAll(s, ":", "_") 264 + }, 260 265 } 261 266 } 262 267
+32 -5
appview/pages/pages.go
··· 413 413 } 414 414 415 415 type ProfileCard struct { 416 - UserDid string 417 - UserHandle string 418 - FollowStatus db.FollowStatus 419 - Followers int 420 - Following int 416 + UserDid string 417 + UserHandle string 418 + FollowStatus db.FollowStatus 419 + FollowersCount int 420 + FollowingCount int 421 421 422 422 Profile *db.Profile 423 423 } ··· 438 438 return p.execute("user/repos", w, params) 439 439 } 440 440 441 + type FollowCard struct { 442 + UserDid string 443 + UserHandle string 444 + FollowStatus db.FollowStatus 445 + Profile *db.Profile 446 + } 447 + 448 + type FollowersPageParams struct { 449 + LoggedInUser *oauth.User 450 + Followers []FollowCard 451 + Card ProfileCard 452 + } 453 + 454 + func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error { 455 + return p.execute("user/followers", w, params) 456 + } 457 + 458 + type FollowingPageParams struct { 459 + LoggedInUser *oauth.User 460 + Following []FollowCard 461 + Card ProfileCard 462 + } 463 + 464 + func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error { 465 + return p.execute("user/following", w, params) 466 + } 467 + 441 468 type FollowFragmentParams struct { 442 469 UserDid string 443 470 FollowStatus db.FollowStatus
+30
appview/pages/templates/user/followers.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · followers {{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/followers" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 + <div class="md:col-span-3 order-1 md:order-1"> 13 + {{ template "user/fragments/profileCard" .Card }} 14 + </div> 15 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 16 + {{ block "followers" . }}{{ end }} 17 + </div> 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "followers" }} 22 + <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 23 + <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 24 + {{ range .Followers }} 25 + {{ template "user/fragments/followCard" . }} 26 + {{ else }} 27 + <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 28 + {{ end }} 29 + </div> 30 + {{ end }}
+30
appview/pages/templates/user/following.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · following {{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/following" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 + <div class="md:col-span-3 order-1 md:order-1"> 13 + {{ template "user/fragments/profileCard" .Card }} 14 + </div> 15 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 16 + {{ block "following" . }}{{ end }} 17 + </div> 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "following" }} 22 + <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 23 + <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 24 + {{ range .Following }} 25 + {{ template "user/fragments/followCard" . }} 26 + {{ else }} 27 + <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 28 + {{ end }} 29 + </div> 30 + {{ end }}
+2 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 - <button id="followBtn" 2 + <button id="{{ normalizeForHtmlId .UserDid }}" 3 3 class="btn mt-2 w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} ··· 9 9 {{ end }} 10 10 11 11 hx-trigger="click" 12 - hx-target="#followBtn" 12 + hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 13 hx-swap="outerHTML" 14 14 > 15 15 {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
+22
appview/pages/templates/user/fragments/followCard.html
··· 1 + {{ define "user/fragments/followCard" }} 2 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 3 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 4 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 5 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar .UserHandle }}" /> 6 + </div> 7 + 8 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 9 + <a href="/{{ .UserHandle }}"> 10 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ .UserHandle | truncateAt30 }}</span> 11 + </a> 12 + <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 13 + </div> 14 + 15 + {{ if ne .FollowStatus.String "IsSelf" }} 16 + <div class="max-w-24"> 17 + {{ template "user/fragments/follow" . }} 18 + </div> 19 + {{ end }} 20 + </div> 21 + </div> 22 + {{ end }}
+17 -14
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 + {{ $userIdent := didOrHandle .UserDid .UserHandle }} 2 3 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 4 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 5 <div id="avatar" class="col-span-1 flex justify-center items-center"> ··· 8 9 </div> 9 10 <div class="col-span-2"> 10 11 <div class="flex items-center flex-row flex-nowrap gap-2"> 11 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 12 + <p title="{{ $userIdent }}" 12 13 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 - {{ didOrHandle .UserDid .UserHandle }} 14 + {{ $userIdent }} 14 15 </p> 15 - <a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 + <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 17 </div> 17 18 18 19 <div class="md:hidden"> 19 - {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 20 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 20 21 </div> 21 22 </div> 22 23 <div class="col-span-3 md:col-span-full"> ··· 29 30 {{ end }} 30 31 31 32 <div class="hidden md:block"> 32 - {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 33 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 33 34 </div> 34 35 35 36 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 42 43 {{ if .IncludeBluesky }} 43 44 <div class="flex items-center gap-2"> 44 45 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 45 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 46 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 46 47 </div> 47 48 {{ end }} 48 49 {{ range $link := .Links }} ··· 88 89 {{ end }} 89 90 90 91 {{ define "followerFollowing" }} 91 - {{ $followers := index . 0 }} 92 - {{ $following := index . 1 }} 93 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 94 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 95 - <span id="followers">{{ $followers }} followers</span> 96 - <span class="select-none after:content-['·']"></span> 97 - <span id="following">{{ $following }} following</span> 98 - </div> 92 + {{ $root := index . 0 }} 93 + {{ $userIdent := index . 1 }} 94 + {{ with $root }} 95 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 96 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 97 + <span id="followers"><a href="/{{ $userIdent }}/followers">{{ .FollowersCount }} followers</a></span> 98 + <span class="select-none after:content-['·']"></span> 99 + <span id="following"><a href="/{{ $userIdent }}/following">{{ .FollowingCount }} following</a></span> 100 + </div> 101 + {{ end }} 99 102 {{ end }} 100 103
+141 -9
appview/state/profile.go
··· 147 147 CollaboratingRepos: pinnedCollaboratingRepos, 148 148 DidHandleMap: didHandleMap, 149 149 Card: pages.ProfileCard{ 150 - UserDid: ident.DID.String(), 151 - UserHandle: ident.Handle.String(), 152 - Profile: profile, 153 - FollowStatus: followStatus, 154 - Followers: followers, 155 - Following: following, 150 + UserDid: ident.DID.String(), 151 + UserHandle: ident.Handle.String(), 152 + Profile: profile, 153 + FollowStatus: followStatus, 154 + FollowersCount: followers, 155 + FollowingCount: following, 156 156 }, 157 157 Punchcard: punchcard, 158 158 ProfileTimeline: timeline, ··· 196 196 Repos: repos, 197 197 DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()}, 198 198 Card: pages.ProfileCard{ 199 - UserDid: ident.DID.String(), 199 + UserDid: ident.DID.String(), 200 + UserHandle: ident.Handle.String(), 201 + Profile: profile, 202 + FollowStatus: followStatus, 203 + FollowersCount: followers, 204 + FollowingCount: following, 205 + }, 206 + }) 207 + } 208 + 209 + func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 210 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 211 + if !ok { 212 + s.pages.Error404(w) 213 + return 214 + } 215 + 216 + profile, err := db.GetProfile(s.db, ident.DID.String()) 217 + if err != nil { 218 + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 219 + } 220 + 221 + loggedInUser := s.oauth.GetUser(r) 222 + 223 + followers, err := db.GetFollowers(s.db, ident.DID.String()) 224 + if err != nil { 225 + log.Printf("getting followers for %s: %s", ident.DID.String(), err) 226 + } 227 + followerCards := make([]pages.FollowCard, len(followers)) 228 + for i, follower := range followers { 229 + ident, err := s.idResolver.ResolveIdent(r.Context(), follower.UserDid) 230 + if err != nil { 231 + log.Printf("can't resolve handle for %s: %s", follower.UserDid, err) 232 + continue 233 + } 234 + profile, err := db.GetProfile(s.db, follower.UserDid) 235 + if err != nil { 236 + log.Printf("can't get profile for %s: %s", follower.UserDid, err) 237 + continue 238 + } 239 + followStatus := db.IsNotFollowing 240 + if loggedInUser != nil { 241 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, follower.UserDid) 242 + } 243 + followerCards[i] = pages.FollowCard{ 244 + UserDid: follower.UserDid, 200 245 UserHandle: ident.Handle.String(), 246 + FollowStatus: followStatus, 201 247 Profile: profile, 248 + } 249 + } 250 + 251 + followStatus := db.IsNotFollowing 252 + if loggedInUser != nil { 253 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 254 + } 255 + 256 + followersCount, followingCount, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 257 + if err != nil { 258 + log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 259 + } 260 + 261 + s.pages.FollowersPage(w, pages.FollowersPageParams{ 262 + LoggedInUser: loggedInUser, 263 + Followers: followerCards, 264 + Card: pages.ProfileCard{ 265 + UserDid: ident.DID.String(), 266 + UserHandle: ident.Handle.String(), 267 + Profile: profile, 268 + FollowStatus: followStatus, 269 + FollowersCount: followersCount, 270 + FollowingCount: followingCount, 271 + }, 272 + }) 273 + } 274 + 275 + func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 276 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 277 + if !ok { 278 + s.pages.Error404(w) 279 + return 280 + } 281 + 282 + profile, err := db.GetProfile(s.db, ident.DID.String()) 283 + if err != nil { 284 + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 285 + } 286 + 287 + loggedInUser := s.oauth.GetUser(r) 288 + 289 + following, err := db.GetFollowing(s.db, ident.DID.String()) 290 + if err != nil { 291 + log.Printf("getting following for %s: %s", ident.DID.String(), err) 292 + } 293 + followingCards := make([]pages.FollowCard, len(following)) 294 + for i, following := range following { 295 + ident, err := s.idResolver.ResolveIdent(r.Context(), following.SubjectDid) 296 + if err != nil { 297 + log.Printf("can't resolve handle for %s: %s", following.SubjectDid, err) 298 + continue 299 + } 300 + profile, err := db.GetProfile(s.db, following.SubjectDid) 301 + if err != nil { 302 + log.Printf("can't get profile for %s: %s", following.SubjectDid, err) 303 + continue 304 + } 305 + followStatus := db.IsNotFollowing 306 + if loggedInUser != nil { 307 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, following.SubjectDid) 308 + } 309 + followingCards[i] = pages.FollowCard{ 310 + UserDid: following.SubjectDid, 311 + UserHandle: ident.Handle.String(), 202 312 FollowStatus: followStatus, 203 - Followers: followers, 204 - Following: following, 313 + Profile: profile, 314 + } 315 + } 316 + 317 + followStatus := db.IsNotFollowing 318 + if loggedInUser != nil { 319 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 320 + } 321 + 322 + followersCount, followingCount, err := db.GetFollowerFollowingCount(s.db, ident.DID.String()) 323 + if err != nil { 324 + log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 325 + } 326 + 327 + s.pages.FollowingPage(w, pages.FollowingPageParams{ 328 + LoggedInUser: loggedInUser, 329 + Following: followingCards, 330 + Card: pages.ProfileCard{ 331 + UserDid: ident.DID.String(), 332 + UserHandle: ident.Handle.String(), 333 + Profile: profile, 334 + FollowStatus: followStatus, 335 + FollowersCount: followersCount, 336 + FollowingCount: followingCount, 205 337 }, 206 338 }) 207 339 }
+2
appview/state/router.go
··· 70 70 71 71 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 72 72 r.Get("/", s.Profile) 73 + r.Get("/followers", s.followersPage) 74 + r.Get("/following", s.followingPage) 73 75 r.Get("/feed.atom", s.AtomFeedPage) 74 76 75 77 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
+7 -7
appview/strings/strings.go
··· 182 182 followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 183 183 } 184 184 185 - followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 185 + followersCount, followingCount, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 186 186 if err != nil { 187 187 l.Error("failed to get follow stats", "err", err) 188 188 } ··· 190 190 s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 191 191 LoggedInUser: s.OAuth.GetUser(r), 192 192 Card: pages.ProfileCard{ 193 - UserDid: id.DID.String(), 194 - UserHandle: id.Handle.String(), 195 - Profile: profile, 196 - FollowStatus: followStatus, 197 - Followers: followers, 198 - Following: following, 193 + UserDid: id.DID.String(), 194 + UserHandle: id.Handle.String(), 195 + Profile: profile, 196 + FollowStatus: followStatus, 197 + FollowersCount: followersCount, 198 + FollowingCount: followingCount, 199 199 }, 200 200 Strings: all, 201 201 })