Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

appview: implement follower and following pages for users

- rename Followers/Following from ProfileCard to FollowersCount/FollowingCount
- in profileCard, define a single $userIdent to use instead of duplicating the didOrHandle expression

fixes https://tangled.sh/@tangled.sh/core/issues/169

Signed-off-by: dusk <y.bera003.06@protonmail.com>

authored by

dusk and committed by
Tangled
5b6b7aea d1f3d357

+330 -57
+2 -7
appview/db/profile.go
··· 348 348 return tx.Commit() 349 349 } 350 350 351 - func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { 351 + func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 352 352 var conditions []string 353 353 var args []any 354 354 for _, filter := range filters { ··· 448 448 idxs[did] = idx + 1 449 449 } 450 450 451 - var profiles []Profile 452 - for _, p := range profileMap { 453 - profiles = append(profiles, *p) 454 - } 455 - 456 - return profiles, nil 451 + return profileMap, nil 457 452 } 458 453 459 454 func GetProfile(e Execer, did string) (*Profile, error) {
+2 -6
appview/db/timeline.go
··· 151 151 return nil, nil 152 152 } 153 153 154 - profileMap := make(map[string]Profile) 155 154 profiles, err := GetProfiles(e, FilterIn("did", subjects)) 156 155 if err != nil { 157 156 return nil, err 158 - } 159 - for _, p := range profiles { 160 - profileMap[p.Did] = p 161 157 } 162 158 163 159 followStatMap := make(map[string]FollowStats) ··· 170 174 171 175 var events []TimelineEvent 172 176 for _, f := range follows { 173 - profile, _ := profileMap[f.SubjectDid] 177 + profile, _ := profiles[f.SubjectDid] 174 178 followStatMap, _ := followStatMap[f.SubjectDid] 175 179 176 180 events = append(events, TimelineEvent{ 177 181 Follow: &f, 178 - Profile: &profile, 182 + Profile: profile, 179 183 FollowStats: &followStatMap, 180 184 EventAt: f.FollowedAt, 181 185 })
+5
appview/pages/funcmap.go
··· 277 277 "layoutCenter": func() string { 278 278 return "col-span-1 md:col-span-8 lg:col-span-6" 279 279 }, 280 + 281 + "normalizeForHtmlId": func(s string) string { 282 + // TODO: extend this to handle other cases? 283 + return strings.ReplaceAll(s, ":", "_") 284 + }, 280 285 } 281 286 } 282 287
+33 -5
appview/pages/pages.go
··· 418 418 } 419 419 420 420 type ProfileCard struct { 421 - UserDid string 422 - UserHandle string 423 - FollowStatus db.FollowStatus 424 - Followers int 425 - Following int 421 + UserDid string 422 + UserHandle string 423 + FollowStatus db.FollowStatus 424 + FollowersCount int 425 + FollowingCount int 426 426 427 427 Profile *db.Profile 428 428 } ··· 439 439 440 440 func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 441 441 return p.execute("user/repos", w, params) 442 + } 443 + 444 + type FollowCard struct { 445 + UserDid string 446 + FollowStatus db.FollowStatus 447 + FollowersCount int 448 + FollowingCount int 449 + Profile *db.Profile 450 + } 451 + 452 + type FollowersPageParams struct { 453 + LoggedInUser *oauth.User 454 + Followers []FollowCard 455 + Card ProfileCard 456 + } 457 + 458 + func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error { 459 + return p.execute("user/followers", w, params) 460 + } 461 + 462 + type FollowingPageParams struct { 463 + LoggedInUser *oauth.User 464 + Following []FollowCard 465 + Card ProfileCard 466 + } 467 + 468 + func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error { 469 + return p.execute("user/following", w, params) 442 470 } 443 471 444 472 type FollowFragmentParams struct {
+3 -3
appview/pages/templates/timeline/timeline.html
··· 171 171 {{ end }} 172 172 {{ end }} 173 173 {{ with $stat }} 174 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 174 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 175 175 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 176 - <span id="followers">{{ .Followers }} followers</span> 176 + <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 177 177 <span class="select-none after:content-['·']"></span> 178 - <span id="following">{{ .Following }} following</span> 178 + <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 179 179 </div> 180 180 {{ end }} 181 181 </div>
+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 }}?tab=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 }}?tab=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 }}
+29
appview/pages/templates/user/fragments/followCard.html
··· 1 + {{ define "user/fragments/followCard" }} 2 + {{ $userIdent := resolve .UserDid }} 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 4 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 + </div> 8 + 9 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 + <a href="/{{ $userIdent }}"> 11 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 + </a> 13 + <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 + <span class="select-none after:content-['·']"></span> 18 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 19 + </div> 20 + </div> 21 + 22 + {{ if ne .FollowStatus.String "IsSelf" }} 23 + <div class="max-w-24"> 24 + {{ template "user/fragments/follow" . }} 25 + </div> 26 + {{ end }} 27 + </div> 28 + </div> 29 + {{ 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"> ··· 9 8 </div> 10 9 <div class="col-span-2"> 11 10 <div class="flex items-center flex-row flex-nowrap gap-2"> 12 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 11 + <p title="{{ $userIdent }}" 13 12 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 14 - {{ didOrHandle .UserDid .UserHandle }} 13 + {{ $userIdent }} 15 14 </p> 16 - <a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a> 15 + <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 17 16 </div> 18 17 19 18 <div class="md:hidden"> 20 - {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 19 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 21 20 </div> 22 21 </div> 23 22 <div class="col-span-3 md:col-span-full"> ··· 30 29 {{ end }} 31 30 32 31 <div class="hidden md:block"> 33 - {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 32 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 34 33 </div> 35 34 36 35 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 43 42 {{ if .IncludeBluesky }} 44 43 <div class="flex items-center gap-2"> 45 44 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 46 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 45 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 47 46 </div> 48 47 {{ end }} 49 48 {{ range $link := .Links }} ··· 89 88 {{ end }} 90 89 91 90 {{ define "followerFollowing" }} 92 - {{ $followers := index . 0 }} 93 - {{ $following := index . 1 }} 94 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 95 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 96 - <span id="followers">{{ $followers }} followers</span> 97 - <span class="select-none after:content-['·']"></span> 98 - <span id="following">{{ $following }} following</span> 99 - </div> 91 + {{ $root := index . 0 }} 92 + {{ $userIdent := index . 1 }} 93 + {{ with $root }} 94 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 95 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 96 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 97 + <span class="select-none after:content-['·']"></span> 98 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 99 + </div> 100 + {{ end }} 100 101 {{ end }} 101 102
+1 -1
appview/pages/templates/user/repos.html
··· 3 3 {{ define "extrameta" }} 4 4 <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 5 <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" /> 7 7 <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 8 {{ end }} 9 9
+169 -12
appview/state/profile.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 6 7 "log" 7 8 "net/http" ··· 18 17 "github.com/gorilla/feeds" 19 18 "tangled.sh/tangled.sh/core/api/tangled" 20 19 "tangled.sh/tangled.sh/core/appview/db" 20 + "tangled.sh/tangled.sh/core/appview/oauth" 21 21 "tangled.sh/tangled.sh/core/appview/pages" 22 22 ) 23 23 ··· 29 27 s.profilePage(w, r) 30 28 case "repos": 31 29 s.reposPage(w, r) 30 + case "followers": 31 + s.followersPage(w, r) 32 + case "following": 33 + s.followingPage(w, r) 32 34 } 33 35 } 34 36 ··· 123 117 Repos: pinnedRepos, 124 118 CollaboratingRepos: pinnedCollaboratingRepos, 125 119 Card: pages.ProfileCard{ 126 - UserDid: ident.DID.String(), 127 - UserHandle: ident.Handle.String(), 128 - Profile: profile, 129 - FollowStatus: followStatus, 130 - Followers: followers, 131 - Following: following, 120 + UserDid: ident.DID.String(), 121 + UserHandle: ident.Handle.String(), 122 + Profile: profile, 123 + FollowStatus: followStatus, 124 + FollowersCount: followers, 125 + FollowingCount: following, 132 126 }, 133 127 Punchcard: punchcard, 134 128 ProfileTimeline: timeline, ··· 171 165 LoggedInUser: loggedInUser, 172 166 Repos: repos, 173 167 Card: pages.ProfileCard{ 174 - UserDid: ident.DID.String(), 175 - UserHandle: ident.Handle.String(), 176 - Profile: profile, 177 - FollowStatus: followStatus, 178 - Followers: followers, 179 - Following: following, 168 + UserDid: ident.DID.String(), 169 + UserHandle: ident.Handle.String(), 170 + Profile: profile, 171 + FollowStatus: followStatus, 172 + FollowersCount: followers, 173 + FollowingCount: following, 180 174 }, 175 + }) 176 + } 177 + 178 + type FollowsPageParams struct { 179 + LoggedInUser *oauth.User 180 + Follows []pages.FollowCard 181 + Card pages.ProfileCard 182 + } 183 + 184 + 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) { 185 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 186 + if !ok { 187 + s.pages.Error404(w) 188 + return FollowsPageParams{}, errors.New("identity not found") 189 + } 190 + did := ident.DID.String() 191 + 192 + profile, err := db.GetProfile(s.db, did) 193 + if err != nil { 194 + log.Printf("getting profile data for %s: %s", did, err) 195 + return FollowsPageParams{}, err 196 + } 197 + 198 + loggedInUser := s.oauth.GetUser(r) 199 + 200 + follows, err := fetchFollows(s.db, did) 201 + if err != nil { 202 + log.Printf("getting followers for %s: %s", did, err) 203 + return FollowsPageParams{}, err 204 + } 205 + 206 + var loggedInUserFollowing map[string]struct{} 207 + if loggedInUser != nil { 208 + following, err := db.GetFollowing(s.db, loggedInUser.Did) 209 + if err != nil { 210 + return FollowsPageParams{}, err 211 + } 212 + if len(following) > 0 { 213 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 214 + for _, follow := range following { 215 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 216 + } 217 + } 218 + } 219 + 220 + followStatus := db.IsNotFollowing 221 + if loggedInUser != nil { 222 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 223 + } 224 + 225 + followersCount, followingCount, err := db.GetFollowerFollowingCount(s.db, did) 226 + if err != nil { 227 + log.Printf("getting follow stats followers for %s: %s", did, err) 228 + return FollowsPageParams{}, err 229 + } 230 + 231 + if len(follows) == 0 { 232 + return FollowsPageParams{ 233 + LoggedInUser: loggedInUser, 234 + Follows: []pages.FollowCard{}, 235 + Card: pages.ProfileCard{ 236 + UserDid: did, 237 + UserHandle: ident.Handle.String(), 238 + Profile: profile, 239 + FollowStatus: followStatus, 240 + FollowersCount: followersCount, 241 + FollowingCount: followingCount, 242 + }, 243 + }, nil 244 + } 245 + 246 + followDids := make([]string, 0, len(follows)) 247 + for _, follow := range follows { 248 + followDids = append(followDids, extractDid(follow)) 249 + } 250 + 251 + profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 252 + if err != nil { 253 + log.Printf("getting profile for %s: %s", followDids, err) 254 + return FollowsPageParams{}, err 255 + } 256 + 257 + followCards := make([]pages.FollowCard, 0, len(follows)) 258 + for _, did := range followDids { 259 + followersCount, followingCount, err := db.GetFollowerFollowingCount(s.db, did) 260 + if err != nil { 261 + log.Printf("getting follow stats for %s: %s", did, err) 262 + } 263 + followStatus := db.IsNotFollowing 264 + if loggedInUserFollowing != nil { 265 + if _, exists := loggedInUserFollowing[did]; exists { 266 + followStatus = db.IsFollowing 267 + } else if loggedInUser.Did == did { 268 + followStatus = db.IsSelf 269 + } 270 + } 271 + var profile *db.Profile 272 + if p, exists := profiles[did]; exists { 273 + profile = p 274 + } else { 275 + profile = &db.Profile{} 276 + profile.Did = did 277 + } 278 + followCards = append(followCards, pages.FollowCard{ 279 + UserDid: did, 280 + FollowStatus: followStatus, 281 + FollowersCount: followersCount, 282 + FollowingCount: followingCount, 283 + Profile: profile, 284 + }) 285 + } 286 + 287 + return FollowsPageParams{ 288 + LoggedInUser: loggedInUser, 289 + Follows: followCards, 290 + Card: pages.ProfileCard{ 291 + UserDid: did, 292 + UserHandle: ident.Handle.String(), 293 + Profile: profile, 294 + FollowStatus: followStatus, 295 + FollowersCount: followersCount, 296 + FollowingCount: followingCount, 297 + }, 298 + }, nil 299 + } 300 + 301 + func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 302 + followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 303 + if err != nil { 304 + s.pages.Notice(w, "all-followers", "Failed to load followers") 305 + return 306 + } 307 + 308 + s.pages.FollowersPage(w, pages.FollowersPageParams{ 309 + LoggedInUser: followPage.LoggedInUser, 310 + Followers: followPage.Follows, 311 + Card: followPage.Card, 312 + }) 313 + } 314 + 315 + func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 316 + followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 317 + if err != nil { 318 + s.pages.Notice(w, "all-following", "Failed to load following") 319 + return 320 + } 321 + 322 + s.pages.FollowingPage(w, pages.FollowingPageParams{ 323 + LoggedInUser: followPage.LoggedInUser, 324 + Following: followPage.Follows, 325 + Card: followPage.Card, 181 326 }) 182 327 } 183 328
+7 -7
appview/strings/strings.go
··· 202 202 followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 203 203 } 204 204 205 - followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 205 + followersCount, followingCount, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 206 206 if err != nil { 207 207 l.Error("failed to get follow stats", "err", err) 208 208 } ··· 210 210 s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 211 211 LoggedInUser: s.OAuth.GetUser(r), 212 212 Card: pages.ProfileCard{ 213 - UserDid: id.DID.String(), 214 - UserHandle: id.Handle.String(), 215 - Profile: profile, 216 - FollowStatus: followStatus, 217 - Followers: followers, 218 - Following: following, 213 + UserDid: id.DID.String(), 214 + UserHandle: id.Handle.String(), 215 + Profile: profile, 216 + FollowStatus: followStatus, 217 + FollowersCount: followersCount, 218 + FollowingCount: followingCount, 219 219 }, 220 220 Strings: all, 221 221 })