forked from tangled.org/core
Monorepo for Tangled

appview: update followers count on follow/unfollow

The follow button now returns the updated followers count alongside the button state, keeping them in sync

Signed-off-by: moshyfawn <email@moshyfawn.dev>

Changed files
+128 -109
appview
pages
state
+4 -3
appview/pages/pages.go
··· 601 601 } 602 602 603 603 type FollowFragmentParams struct { 604 - UserDid string 605 - FollowStatus models.FollowStatus 604 + UserDid string 605 + FollowStatus models.FollowStatus 606 + FollowersCount int64 606 607 } 607 608 608 609 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 609 - return p.executePlain("user/fragments/follow", w, params) 610 + return p.executePlain("user/fragments/follow-oob", w, params) 610 611 } 611 612 612 613 type EditBioParams struct {
+6
appview/pages/templates/user/fragments/follow-oob.html
··· 1 + {{ define "user/fragments/follow-oob" }} 2 + {{ template "user/fragments/follow" . }} 3 + <span hx-swap-oob='innerHTML:[data-followers-did="{{ .UserDid }}"]'> 4 + <a href="/{{ resolve .UserDid }}?tab=followers">{{ .FollowersCount }} followers</a> 5 + </span> 6 + {{ end }}
+5 -3
appview/pages/templates/user/fragments/followCard.html
··· 9 9 <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 10 10 <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 11 <a href="/{{ $userIdent }}"> 12 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ 13 + $userIdent | truncateAt30 }}</span> 13 14 </a> 14 15 {{ with .Profile }} 15 16 <p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p> 16 17 {{ end }} 17 18 <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 19 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 19 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 20 + <span id="followers" data-followers-did="{{ .UserDid }}"><a href="/{{ $userIdent }}?tab=followers">{{ 21 + .FollowersCount }} followers</a></span> 20 22 <span class="select-none after:content-['·']"></span> 21 23 <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 22 24 </div> ··· 29 31 </div> 30 32 </div> 31 33 </div> 32 - {{ end }} 34 + {{ end }}
+97 -99
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 - {{ $userIdent := resolve .UserDid }} 3 - <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 - <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 - <div class="w-3/4 aspect-square relative"> 6 - <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 7 - </div> 8 - </div> 9 - <div class="col-span-2"> 10 - <div class="flex items-center flex-row flex-nowrap gap-2"> 11 - <p title="{{ $userIdent }}" 12 - class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 - {{ $userIdent }} 14 - </p> 15 - {{ with .Profile }} 16 - {{ if .Pronouns }} 17 - <p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p> 18 - {{ end }} 19 - {{ end }} 20 - </div> 2 + {{ $userIdent := resolve .UserDid }} 3 + <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 + <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 + <div class="w-3/4 aspect-square relative"> 6 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 7 + </div> 8 + </div> 9 + <div class="col-span-2"> 10 + <div class="flex items-center flex-row flex-nowrap gap-2"> 11 + <p title="{{ $userIdent }}" 12 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 + {{ $userIdent }} 14 + </p> 15 + {{ with .Profile }} 16 + {{ if .Pronouns }} 17 + <p class="text-gray-500 dark:text-gray-400">{{ .Pronouns }}</p> 18 + {{ end }} 19 + {{ end }} 20 + </div> 21 21 22 - <div class="md:hidden"> 23 - {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 24 - </div> 25 - </div> 26 - <div class="col-span-3 md:col-span-full"> 27 - <div id="profile-bio" class="text-sm"> 28 - {{ $profile := .Profile }} 29 - {{ with .Profile }} 22 + <div class="md:hidden"> 23 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 24 + </div> 25 + </div> 26 + <div class="col-span-3 md:col-span-full"> 27 + <div id="profile-bio" class="text-sm"> 28 + {{ $profile := .Profile }} 29 + {{ with .Profile }} 30 30 31 - {{ if .Description }} 32 - <p class="text-base pb-4 md:pb-2">{{ .Description }}</p> 33 - {{ end }} 31 + {{ if .Description }} 32 + <p class="text-base pb-4 md:pb-2">{{ .Description }}</p> 33 + {{ end }} 34 34 35 - <div class="hidden md:block"> 36 - {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 37 - </div> 35 + <div class="hidden md:block"> 36 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 37 + </div> 38 38 39 - <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 40 - {{ if .Location }} 41 - <div class="flex items-center gap-2"> 42 - <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 43 - <span>{{ .Location }}</span> 44 - </div> 45 - {{ end }} 46 - {{ if .IncludeBluesky }} 47 - <div class="flex items-center gap-2"> 48 - <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 49 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 50 - </div> 51 - {{ end }} 52 - {{ range $link := .Links }} 53 - {{ if $link }} 54 - <div class="flex items-center gap-2"> 55 - <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 56 - <a href="{{ $link }}">{{ $link }}</a> 57 - </div> 58 - {{ end }} 59 - {{ end }} 60 - {{ if not $profile.IsStatsEmpty }} 61 - <div class="flex items-center justify-evenly gap-2 py-2"> 62 - {{ range $stat := .Stats }} 63 - {{ if $stat.Kind }} 64 - <div class="flex flex-col items-center gap-2"> 65 - <span class="text-xl font-bold">{{ $stat.Value }}</span> 66 - <span>{{ $stat.Kind.String }}</span> 67 - </div> 68 - {{ end }} 69 - {{ end }} 70 - </div> 71 - {{ end }} 39 + <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 40 + {{ if .Location }} 41 + <div class="flex items-center gap-2"> 42 + <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 43 + <span>{{ .Location }}</span> 44 + </div> 45 + {{ end }} 46 + {{ if .IncludeBluesky }} 47 + <div class="flex items-center gap-2"> 48 + <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" 49 + }}</span> 50 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 51 + </div> 52 + {{ end }} 53 + {{ range $link := .Links }} 54 + {{ if $link }} 55 + <div class="flex items-center gap-2"> 56 + <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 57 + <a href="{{ $link }}">{{ $link }}</a> 58 + </div> 59 + {{ end }} 60 + {{ end }} 61 + {{ if not $profile.IsStatsEmpty }} 62 + <div class="flex items-center justify-evenly gap-2 py-2"> 63 + {{ range $stat := .Stats }} 64 + {{ if $stat.Kind }} 65 + <div class="flex flex-col items-center gap-2"> 66 + <span class="text-xl font-bold">{{ $stat.Value }}</span> 67 + <span>{{ $stat.Kind.String }}</span> 72 68 </div> 73 69 {{ end }} 74 - 75 - <div class="flex mt-2 items-center gap-2"> 76 - {{ if ne .FollowStatus.String "IsSelf" }} 77 - {{ template "user/fragments/follow" . }} 78 - {{ else }} 79 - <button id="editBtn" 80 - class="btn w-full flex items-center gap-2 group" 81 - hx-target="#profile-bio" 82 - hx-get="/profile/edit-bio" 83 - hx-swap="innerHTML"> 84 - {{ i "pencil" "w-4 h-4" }} 85 - edit 86 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 87 - </button> 88 - {{ end }} 70 + {{ end }} 71 + </div> 72 + {{ end }} 73 + </div> 74 + {{ end }} 89 75 90 - <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 91 - href="/{{ $userIdent }}/feed.atom"> 92 - {{ i "rss" "size-4" }} 93 - </a> 94 - </div> 76 + <div class="flex mt-2 items-center gap-2"> 77 + {{ if ne .FollowStatus.String "IsSelf" }} 78 + {{ template "user/fragments/follow" . }} 79 + {{ else }} 80 + <button id="editBtn" class="btn w-full flex items-center gap-2 group" hx-target="#profile-bio" 81 + hx-get="/profile/edit-bio" hx-swap="innerHTML"> 82 + {{ i "pencil" "w-4 h-4" }} 83 + edit 84 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 85 + </button> 86 + {{ end }} 95 87 96 - </div> 97 - <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 88 + <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 89 + href="/{{ $userIdent }}/feed.atom"> 90 + {{ i "rss" "size-4" }} 91 + </a> 98 92 </div> 93 + 99 94 </div> 95 + <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 96 + </div> 97 + </div> 100 98 {{ end }} 101 99 102 100 {{ define "followerFollowing" }} 103 - {{ $root := index . 0 }} 104 - {{ $userIdent := index . 1 }} 105 - {{ with $root }} 106 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 107 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 108 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 109 - <span class="select-none after:content-['·']"></span> 110 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 111 - </div> 112 - {{ end }} 101 + {{ $root := index . 0 }} 102 + {{ $userIdent := index . 1 }} 103 + {{ with $root }} 104 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 105 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 106 + <span id="followers" data-followers-did="{{ .UserDid }}"><a href="/{{ $userIdent }}?tab=followers">{{ 107 + .Stats.FollowersCount }} followers</a></span> 108 + <span class="select-none after:content-['·']"></span> 109 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 110 + </div> 113 111 {{ end }} 114 - 112 + {{ end }}
+16 -4
appview/state/follow.go
··· 75 75 76 76 s.notifier.NewFollow(r.Context(), follow) 77 77 78 + followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) 79 + if err != nil { 80 + log.Println("failed to get follow stats", err) 81 + } 82 + 78 83 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 79 - UserDid: subjectIdent.DID.String(), 80 - FollowStatus: models.IsFollowing, 84 + UserDid: subjectIdent.DID.String(), 85 + FollowStatus: models.IsFollowing, 86 + FollowersCount: followStats.Followers, 81 87 }) 82 88 83 89 return ··· 106 112 // this is not an issue, the firehose event might have already done this 107 113 } 108 114 115 + followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) 116 + if err != nil { 117 + log.Println("failed to get follow stats", err) 118 + } 119 + 109 120 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 110 - UserDid: subjectIdent.DID.String(), 111 - FollowStatus: models.IsNotFollowing, 121 + UserDid: subjectIdent.DID.String(), 122 + FollowStatus: models.IsNotFollowing, 123 + FollowersCount: followStats.Followers, 112 124 }) 113 125 114 126 s.notifier.DeleteFollow(r.Context(), follow)