Monorepo for Tangled
0
fork

Configure Feed

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

appview/state: add vouch handlers

Signed-off-by: oppiliappan <me@oppi.li>

authored by

oppiliappan and committed by
Tangled
c21e94a9 84a9430c

+378 -39
+19 -18
appview/oauth/scopes.go
··· 3 3 var TangledScopes = []string{ 4 4 "atproto", 5 5 6 + "repo:sh.tangled.actor.profile", 7 + "repo:sh.tangled.feed.reaction", 8 + "repo:sh.tangled.feed.star", 9 + "repo:sh.tangled.graph.follow", 10 + "repo:sh.tangled.graph.vouch", 11 + "repo:sh.tangled.knot", 12 + "repo:sh.tangled.knot.member", 13 + "repo:sh.tangled.label.definition", 14 + "repo:sh.tangled.label.op", 6 15 "repo:sh.tangled.publicKey", 7 16 "repo:sh.tangled.repo", 8 - "repo:sh.tangled.repo.pull", 9 - "repo:sh.tangled.repo.pull.comment", 10 17 "repo:sh.tangled.repo.artifact", 18 + "repo:sh.tangled.repo.collaborator", 11 19 "repo:sh.tangled.repo.issue", 12 20 "repo:sh.tangled.repo.issue.comment", 13 - "repo:sh.tangled.repo.collaborator", 14 - "repo:sh.tangled.knot", 15 - "repo:sh.tangled.knot.member", 21 + "repo:sh.tangled.repo.pull", 22 + "repo:sh.tangled.repo.pull.comment", 16 23 "repo:sh.tangled.spindle", 17 24 "repo:sh.tangled.spindle.member", 18 - "repo:sh.tangled.graph.follow", 19 - "repo:sh.tangled.feed.star", 20 - "repo:sh.tangled.feed.reaction", 21 - "repo:sh.tangled.label.definition", 22 - "repo:sh.tangled.label.op", 23 25 "repo:sh.tangled.string", 24 - "repo:sh.tangled.actor.profile", 25 26 26 27 "blob:*/*", 27 28 29 + "rpc:sh.tangled.pipeline.cancelPipeline?aud=*", 30 + "rpc:sh.tangled.repo.addSecret?aud=*", 28 31 "rpc:sh.tangled.repo.create?aud=*", 29 32 "rpc:sh.tangled.repo.delete?aud=*", 30 - "rpc:sh.tangled.repo.merge?aud=*", 31 - "rpc:sh.tangled.repo.hiddenRef?aud=*", 32 33 "rpc:sh.tangled.repo.deleteBranch?aud=*", 33 - "rpc:sh.tangled.repo.setDefaultBranch?aud=*", 34 + "rpc:sh.tangled.repo.forkStatus?aud=*", 34 35 "rpc:sh.tangled.repo.forkSync?aud=*", 35 - "rpc:sh.tangled.repo.forkStatus?aud=*", 36 + "rpc:sh.tangled.repo.hiddenRef?aud=*", 37 + "rpc:sh.tangled.repo.listSecrets?aud=*", 38 + "rpc:sh.tangled.repo.merge?aud=*", 36 39 "rpc:sh.tangled.repo.mergeCheck?aud=*", 37 - "rpc:sh.tangled.pipeline.cancelPipeline?aud=*", 38 - "rpc:sh.tangled.repo.addSecret?aud=*", 39 40 "rpc:sh.tangled.repo.removeSecret?aud=*", 40 - "rpc:sh.tangled.repo.listSecrets?aud=*", 41 + "rpc:sh.tangled.repo.setDefaultBranch?aud=*", 41 42 }
+49 -12
appview/pages/templates/user/fragments/profileCard.html
··· 39 39 </div> 40 40 </div> 41 41 <div class="col-span-3 md:col-span-full"> 42 - <div id="profile-bio" class="text-sm"> 42 + <div id="profile-bio" class="text-sm space-y-2"> 43 43 {{ $profile := .Profile }} 44 44 {{ with .Profile }} 45 45 ··· 87 87 </div> 88 88 {{ end }} 89 89 90 - <div class="flex mt-2 items-center gap-2"> 90 + <div class="flex my-2 items-center gap-2"> 91 91 {{ if ne .FollowStatus.String "IsSelf" }} 92 92 {{ template "user/fragments/follow" . }} 93 93 {{ else }} ··· 107 107 {{ i "rss" "size-4" }} 108 108 </a> 109 109 </div> 110 + 111 + {{ if ne .FollowStatus.String "IsSelf" }} 112 + {{ template "user/fragments/vouch" . }} 113 + 114 + {{ if .VouchRelationship }} 115 + {{ if .VouchRelationship.IndirectVouches }} 116 + <div class="flex flex-col gap-2"> 117 + {{ template "user/fragments/networkVouches" .VouchRelationship }} 118 + </div> 119 + {{ end }} 120 + {{ end }} 121 + {{ end }} 110 122 111 123 </div> 112 124 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> ··· 115 127 {{ end }} 116 128 117 129 {{ define "followerFollowing" }} 118 - {{ $root := index . 0 }} 119 - {{ $userIdent := index . 1 }} 120 - {{ with $root }} 121 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 122 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 123 - <span id="followers" data-followers-did="{{ .UserDid }}"><a href="/{{ $userIdent }}?tab=followers">{{ 124 - .Stats.FollowersCount }} followers</a></span> 125 - <span class="select-none after:content-['·']"></span> 126 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 127 - </div> 130 + {{ $root := index . 0 }} 131 + {{ $userIdent := index . 1 }} 132 + {{ with $root }} 133 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 134 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 135 + <span id="followers" data-followers-did="{{ .UserDid }}"><a href="/{{ $userIdent }}?tab=followers">{{ 136 + .Stats.FollowersCount }} followers</a></span> 137 + <span class="select-none after:content-['·']"></span> 138 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 139 + </div> 140 + {{ end }} 128 141 {{ end }} 142 + 143 + 144 + {{ define "networkVouchItem" }} 145 + <div class="flex items-center gap-2 p-2 group/item"> 146 + <div class="flex-shrink-0 h-fit relative"> 147 + {{ template "user/fragments/picLink" (list .Did "size-8") }} 148 + </div> 149 + <div class="flex-1 min-w-0 flex items-center gap-2"> 150 + <div class="text-sm font-medium flex-shrink-0 text-green-900 dark:text-green-100"> 151 + {{ resolve .Did }} 152 + </div> 153 + <div class="flex-1 min-w-0 text-sm text-green-700 dark:text-green-300 truncate"> 154 + {{ .Reason | description }} 155 + </div> 156 + </div> 157 + <div class="flex-shrink-0 relative min-w-12 text-center"> 158 + <div class="text-xs text-green-600 dark:text-green-400 group-hover/item:opacity-0 transition-opacity"> 159 + {{ template "repo/fragments/shortTime" .CreatedAt }} 160 + </div> 161 + <div class="absolute inset-0 flex items-center gap-1 justify-center opacity-0 group-hover/item:opacity-100 transition-opacity text-xs font-medium text-green-900 dark:text-green-100"> 162 + view {{ i "arrow-right" "size-3" }} 163 + </div> 164 + </div> 165 + </div> 129 166 {{ end }}
+6
appview/pages/templates/user/fragments/vouch.html
··· 1 + {{ define "user/fragments/vouch" }} 2 + <div class="relative"> 3 + {{ template "user/fragments/vouchButton" . }} 4 + {{ template "user/fragments/vouchPopover" . }} 5 + </div> 6 + {{ end }}
+25
appview/pages/templates/user/fragments/vouchTooltip.html
··· 1 + {{ define "user/fragments/vouchTooltip" }} 2 + {{- $vr := . -}} 3 + {{- if not $vr.IsEmpty -}} 4 + {{- $networkPart := "" -}} 5 + {{- if and (gt $vr.VouchStrength 0) (eq $vr.DenounceStrength 0) -}} 6 + {{- $networkPart = printf "%s from your network vouched for this user." (plural $vr.VouchStrength "person" "people") -}} 7 + {{- else if and (gt $vr.DenounceStrength 0) (eq $vr.VouchStrength 0) -}} 8 + {{- $networkPart = printf "%s from your network denounced this user." (plural $vr.DenounceStrength "person" "people") -}} 9 + {{- else if and (eq $vr.DenounceStrength 0) (eq $vr.VouchStrength 0) -}} 10 + {{- $networkPart = "" -}} 11 + {{- else -}} 12 + {{- $networkPart = printf "%s and %s from your network." (plural $vr.VouchStrength "vouch" "vouches") (plural $vr.DenounceStrength "denounce" "") -}} 13 + {{- end -}} 14 + {{- $direct := $vr.GetDirectVouch -}} 15 + {{- if $direct -}} 16 + {{- if $direct.IsVouch -}} 17 + You vouched for {{ resolve $vr.SubjectDid }} {{ relTimeFmt $direct.CreatedAt }}. {{ $networkPart }} 18 + {{- else -}} 19 + You denounced {{ resolve $vr.SubjectDid }} {{ relTimeFmt $direct.CreatedAt }}. {{ $networkPart }} 20 + {{- end -}} 21 + {{- else -}} 22 + {{- $networkPart -}}. 23 + {{- end -}} 24 + {{- end -}} 25 + {{ end }}
+109
appview/pages/templates/user/vouches.html
··· 1 + {{ define "title" }}{{ resolve .Card.UserDid }} · vouches {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + {{ $isSelf := and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 5 + <div id="all-vouches" class="md:col-span-8 order-2 md:order-2 flex flex-col gap-6"> 6 + {{ if $isSelf }}{{ template "vouchSuggestions" . }}{{ end }} 7 + {{ block "vouchTimeline" . }}{{ end }} 8 + {{ if ge (len .Vouches) .Page.Limit }} 9 + {{ $handle := resolve .Card.UserDid }} 10 + {{ template "fragments/pagination" (dict 11 + "Page" .Page 12 + "TotalCount" (add (len .Vouches) .Page.Offset) 13 + "BasePath" (printf "/%s" $handle) 14 + "QueryParams" (queryParams "tab" "vouches") 15 + ) }} 16 + {{ end }} 17 + </div> 18 + {{ end }} 19 + 20 + {{ define "vouchTimeline" }} 21 + {{ $isSelf := and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 22 + {{ if not .LoggedInUser }} 23 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 24 + <span>You need to <a href="/login" class="underline">log in</a> to view vouches.</span> 25 + </div> 26 + {{ else if not .Vouches }} 27 + {{ if not (and $isSelf .Suggestions) }} 28 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 29 + <span>No vouches in your network yet.</span> 30 + </div> 31 + {{ end }} 32 + {{ else }} 33 + <div class="relative flex flex-col gap-6 ml-5 px-4 border border-transparent"> 34 + <div class="absolute top-8 bottom-8 w-0.5 bg-gray-200 dark:bg-gray-700 z-0"></div> 35 + {{ range .Vouches }} 36 + {{ template "vouchTimelineItem" (list $.Card.UserDid $.LoggedInUser.Did .) }} 37 + {{ end }} 38 + </div> 39 + {{ end }} 40 + {{ end }} 41 + 42 + {{ define "vouchSuggestions" }} 43 + {{ if .Suggestions }} 44 + <div class="flex flex-col gap-2"> 45 + {{ range .Suggestions }} 46 + <div class="flex items-center gap-3 border border-gray-200 dark:border-gray-700 rounded-sm p-4 bg-white dark:bg-gray-800"> 47 + <img src="{{ tinyAvatar .Did.String }}" alt="" class="rounded-full size-10 border border-gray-300 dark:border-gray-700 shrink-0" /> 48 + <div class="flex flex-col gap-0.5 min-w-0 flex-1"> 49 + <a href="/{{ resolve .Did.String }}" class="font-medium hover:underline dark:text-white truncate">{{ resolve .Did.String }}</a> 50 + <span class="text-sm text-gray-500 dark:text-gray-400">{{ .Reason }}</span> 51 + </div> 52 + <div class="shrink-0 w-32"> 53 + {{ template "user/fragments/vouch" . }} 54 + </div> 55 + </div> 56 + {{ end }} 57 + </div> 58 + {{ end }} 59 + {{ end }} 60 + 61 + {{ define "vouchTimelineItem" }} 62 + {{ $profileDid := index . 0 }} 63 + {{ $viewerDid := index . 1 }} 64 + {{ $v := index . 2 }} 65 + {{ $isOutgoing := eq $v.Did $profileDid }} 66 + {{ $isVouch := $v.IsVouch }} 67 + {{ $isSelf := eq $profileDid $viewerDid }} 68 + 69 + <div class="-ml-5 flex items-center gap-3 relative z-10"> 70 + <div class="flex-shrink-0 flex items-center justify-center size-10 rounded-full 71 + {{ if $isVouch }}bg-green-200 dark:bg-green-800{{ else }}bg-red-200 dark:bg-red-800{{ end }}"> 72 + {{ if $isVouch }} 73 + {{ if $isOutgoing }} 74 + {{ i "arrow-up-right" "size-5 text-green-500 dark:text-green-400" }} 75 + {{ else }} 76 + {{ i "arrow-down-left" "size-5 text-green-500 dark:text-green-400" }} 77 + {{ end }} 78 + {{ else }} 79 + {{ if $isOutgoing }} 80 + {{ i "arrow-up-right" "size-5 text-red-500 dark:text-red-400" }} 81 + {{ else }} 82 + {{ i "arrow-down-left" "size-5 text-red-500 dark:text-red-400" }} 83 + {{ end }} 84 + {{ end }} 85 + </div> 86 + 87 + <div class="flex flex-col gap-1"> 88 + <div class="flex flex-wrap items-center gap-1 text-sm dark:text-white"> 89 + {{ if and $isSelf $isOutgoing }} 90 + <span class="font-medium text-gray-700 dark:text-gray-300">you</span> 91 + {{ else }} 92 + <a href="/{{ resolve $v.Did.String }}" class="font-medium hover:underline">{{ resolve $v.Did.String }}</a> 93 + {{ end }} 94 + <span class="text-gray-500 dark:text-gray-400"> 95 + {{ if $isVouch }}vouched for{{ else }}denounced{{ end }} 96 + </span> 97 + {{ if and $isSelf (not $isOutgoing) }} 98 + <span class="font-medium text-gray-700 dark:text-gray-300">you</span> 99 + {{ else }} 100 + <a href="/{{ resolve $v.SubjectDid.String }}" class="font-medium hover:underline">{{ resolve $v.SubjectDid.String }}</a> 101 + {{ end }} 102 + <span class="text-gray-400 dark:text-gray-500 text-xs">{{ relTimeFmt $v.CreatedAt }}</span> 103 + </div> 104 + {{ with $v.Reason }} 105 + <p class="text-gray-500 dark:text-gray-400">{{ . }}</p> 106 + {{ end }} 107 + </div> 108 + </div> 109 + {{ end }}
+4
appview/state/router.go
··· 181 181 r.Delete("/", s.Follow) 182 182 }) 183 183 184 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/vouch", func(r chi.Router) { 185 + r.Post("/", s.Vouch) 186 + }) 187 + 184 188 r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) { 185 189 r.Post("/", s.Star) 186 190 r.Delete("/", s.Star)
+10 -9
appview/state/state.go
··· 123 123 config.Jetstream.Endpoint, 124 124 "appview", 125 125 []string{ 126 + tangled.ActorProfileNSID, 127 + tangled.FeedStarNSID, 126 128 tangled.GraphFollowNSID, 127 - tangled.FeedStarNSID, 129 + tangled.GraphVouchNSID, 130 + tangled.KnotMemberNSID, 131 + tangled.KnotNSID, 132 + tangled.LabelDefinitionNSID, 133 + tangled.LabelOpNSID, 128 134 tangled.PublicKeyNSID, 129 135 tangled.RepoArtifactNSID, 130 - tangled.ActorProfileNSID, 131 - tangled.KnotMemberNSID, 136 + tangled.RepoIssueCommentNSID, 137 + tangled.RepoIssueNSID, 138 + tangled.RepoPullNSID, 132 139 tangled.SpindleMemberNSID, 133 140 tangled.SpindleNSID, 134 - tangled.KnotNSID, 135 141 tangled.StringNSID, 136 - tangled.RepoPullNSID, 137 - tangled.RepoIssueNSID, 138 - tangled.RepoIssueCommentNSID, 139 - tangled.LabelDefinitionNSID, 140 - tangled.LabelOpNSID, 141 142 }, 142 143 nil, 143 144 tlog.SubLogger(logger, "jetstream"),
+156
appview/state/vouch.go
··· 1 + package state 2 + 3 + import ( 4 + "net/http" 5 + "time" 6 + 7 + comatproto "github.com/bluesky-social/indigo/api/atproto" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + lexutil "github.com/bluesky-social/indigo/lex/util" 10 + "github.com/ipfs/go-cid" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/log" 15 + ) 16 + 17 + func (s *State) Vouch(w http.ResponseWriter, r *http.Request) { 18 + l := log.SubLogger(s.logger, "vouch") 19 + l = s.logger.With("handler", "Vouch") 20 + currentUser := s.oauth.GetMultiAccountUser(r) 21 + 22 + var subject string 23 + 24 + subject = r.FormValue("subject") 25 + 26 + if subject == "" { 27 + l.Warn("invalid form: missing subject") 28 + s.pages.Notice(w, "error", "Missing subject user.") 29 + return 30 + } 31 + 32 + subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 33 + if err != nil { 34 + l.Error("failed to vouch, invalid did", "subject", subject, "err", err) 35 + s.pages.Notice(w, "error", "Could not find that user.") 36 + return 37 + } 38 + 39 + if currentUser.Did == subjectIdent.DID.String() { 40 + l.Warn("cant vouch or denounce yourself") 41 + s.pages.Notice(w, "error", "You cannot vouch for yourself.") 42 + return 43 + } 44 + 45 + client, err := s.oauth.AuthorizedClient(r) 46 + if err != nil { 47 + l.Error("failed to authorize client", "err", err) 48 + s.pages.Notice(w, "error", "Authentication required.") 49 + return 50 + } 51 + 52 + if err := r.ParseForm(); err != nil { 53 + l.Warn("failed to parse form", "err", err) 54 + s.pages.Notice(w, "error", "Invalid form data.") 55 + return 56 + } 57 + 58 + subjectDid := subjectIdent.DID.String() 59 + 60 + // handle "none" by deleting any existing vouch 61 + if r.FormValue("kind") == "none" { 62 + _, err := db.GetVouch(s.db, currentUser.Did, subjectDid) 63 + if err != nil { 64 + l.Info("no existing vouch to delete") 65 + s.pages.HxRefresh(w) 66 + return 67 + } 68 + 69 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 70 + Collection: tangled.GraphVouchNSID, 71 + Repo: currentUser.Did, 72 + Rkey: subjectDid, 73 + }) 74 + 75 + if err != nil { 76 + l.Error("failed to delete vouch record", "err", err) 77 + s.pages.Notice(w, "error", "Failed to delete vouch record.") 78 + return 79 + } 80 + 81 + err = db.DeleteVouch(s.db, currentUser.Did, subjectDid) 82 + if err != nil { 83 + l.Warn("failed to delete vouch from DB", "err", err) 84 + } 85 + 86 + l.Info("deleted vouch record") 87 + s.pages.HxRefresh(w) 88 + return 89 + } 90 + 91 + kind, err := models.ParseVouchKind(r.FormValue("kind")) 92 + if err != nil { 93 + l.Warn("failed to parse vouch kind", "err", err) 94 + s.pages.Notice(w, "error", "Invalid action type.") 95 + return 96 + } 97 + 98 + reason := r.FormValue("reason") 99 + createdAt := time.Now().Format(time.RFC3339) 100 + 101 + var reasonPtr *string 102 + if reason != "" { 103 + reasonPtr = &reason 104 + } 105 + 106 + var swapCid *string 107 + existingVouch, err := db.GetVouch(s.db, currentUser.Did, subjectDid) 108 + if err == nil { 109 + cidStr := existingVouch.Cid.String() 110 + swapCid = &cidStr 111 + } 112 + 113 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 114 + Collection: tangled.GraphVouchNSID, 115 + Repo: currentUser.Did, 116 + Rkey: subjectDid, 117 + SwapRecord: swapCid, 118 + Record: &lexutil.LexiconTypeDecoder{ 119 + Val: &tangled.GraphVouch{ 120 + Kind: string(kind), 121 + Reason: reasonPtr, 122 + CreatedAt: createdAt, 123 + }}, 124 + }) 125 + if err != nil { 126 + l.Error("failed to create atproto record", "err", err) 127 + s.pages.Notice(w, "error", "Failed to create vouch record.") 128 + return 129 + } 130 + 131 + l.Info("created atproto record", "uri", resp.Uri, "kind", kind) 132 + 133 + newCid, err := cid.Parse(resp.Cid) 134 + if err != nil { 135 + l.Error("failed to parse returned cid", "err", err) 136 + s.pages.Notice(w, "error", "Failed to save vouch.") 137 + return 138 + } 139 + 140 + vouch := &models.Vouch{ 141 + Did: syntax.DID(currentUser.Did), 142 + SubjectDid: subjectIdent.DID, 143 + Cid: newCid, 144 + Kind: kind, 145 + Reason: reasonPtr, 146 + } 147 + 148 + err = db.AddVouch(s.db, vouch) 149 + if err != nil { 150 + l.Error("failed to add vouch to db", "err", err) 151 + s.pages.Notice(w, "error", "Failed to save vouch.") 152 + return 153 + } 154 + 155 + s.pages.HxRefresh(w) 156 + }