diff --git a/appview/oauth/scopes.go b/appview/oauth/scopes.go
index 351e05d9..83397f86 100644
--- a/appview/oauth/scopes.go
+++ b/appview/oauth/scopes.go
@@ -3,39 +3,40 @@ package oauth
var TangledScopes = []string{
"atproto",
+ "repo:sh.tangled.actor.profile",
+ "repo:sh.tangled.feed.reaction",
+ "repo:sh.tangled.feed.star",
+ "repo:sh.tangled.graph.follow",
+ "repo:sh.tangled.graph.vouch",
+ "repo:sh.tangled.knot",
+ "repo:sh.tangled.knot.member",
+ "repo:sh.tangled.label.definition",
+ "repo:sh.tangled.label.op",
"repo:sh.tangled.publicKey",
"repo:sh.tangled.repo",
- "repo:sh.tangled.repo.pull",
- "repo:sh.tangled.repo.pull.comment",
"repo:sh.tangled.repo.artifact",
+ "repo:sh.tangled.repo.collaborator",
"repo:sh.tangled.repo.issue",
"repo:sh.tangled.repo.issue.comment",
- "repo:sh.tangled.repo.collaborator",
- "repo:sh.tangled.knot",
- "repo:sh.tangled.knot.member",
+ "repo:sh.tangled.repo.pull",
+ "repo:sh.tangled.repo.pull.comment",
"repo:sh.tangled.spindle",
"repo:sh.tangled.spindle.member",
- "repo:sh.tangled.graph.follow",
- "repo:sh.tangled.feed.star",
- "repo:sh.tangled.feed.reaction",
- "repo:sh.tangled.label.definition",
- "repo:sh.tangled.label.op",
"repo:sh.tangled.string",
- "repo:sh.tangled.actor.profile",
"blob:*/*",
+ "rpc:sh.tangled.pipeline.cancelPipeline?aud=*",
+ "rpc:sh.tangled.repo.addSecret?aud=*",
"rpc:sh.tangled.repo.create?aud=*",
"rpc:sh.tangled.repo.delete?aud=*",
- "rpc:sh.tangled.repo.merge?aud=*",
- "rpc:sh.tangled.repo.hiddenRef?aud=*",
"rpc:sh.tangled.repo.deleteBranch?aud=*",
- "rpc:sh.tangled.repo.setDefaultBranch?aud=*",
- "rpc:sh.tangled.repo.forkSync?aud=*",
"rpc:sh.tangled.repo.forkStatus?aud=*",
+ "rpc:sh.tangled.repo.forkSync?aud=*",
+ "rpc:sh.tangled.repo.hiddenRef?aud=*",
+ "rpc:sh.tangled.repo.listSecrets?aud=*",
+ "rpc:sh.tangled.repo.merge?aud=*",
"rpc:sh.tangled.repo.mergeCheck?aud=*",
- "rpc:sh.tangled.pipeline.cancelPipeline?aud=*",
- "rpc:sh.tangled.repo.addSecret?aud=*",
"rpc:sh.tangled.repo.removeSecret?aud=*",
- "rpc:sh.tangled.repo.listSecrets?aud=*",
+ "rpc:sh.tangled.repo.setDefaultBranch?aud=*",
}
diff --git a/appview/pages/templates/user/fragments/profileCard.html b/appview/pages/templates/user/fragments/profileCard.html
index 95ed743a..135301b6 100644
--- a/appview/pages/templates/user/fragments/profileCard.html
+++ b/appview/pages/templates/user/fragments/profileCard.html
@@ -39,7 +39,7 @@
-
+
{{ $profile := .Profile }}
{{ with .Profile }}
@@ -87,7 +87,7 @@
{{ end }}
-
+
{{ if ne .FollowStatus.String "IsSelf" }}
{{ template "user/fragments/follow" . }}
{{ else }}
@@ -108,6 +108,18 @@
+ {{ if ne .FollowStatus.String "IsSelf" }}
+ {{ template "user/fragments/vouch" . }}
+
+ {{ if .VouchRelationship }}
+ {{ if .VouchRelationship.IndirectVouches }}
+
+ {{ template "user/fragments/networkVouches" .VouchRelationship }}
+
+ {{ end }}
+ {{ end }}
+ {{ end }}
+
@@ -115,15 +127,40 @@
{{ end }}
{{ define "followerFollowing" }}
-{{ $root := index . 0 }}
-{{ $userIdent := index . 1 }}
-{{ with $root }}
-
+ {{ $root := index . 0 }}
+ {{ $userIdent := index . 1 }}
+ {{ with $root }}
+
+ {{ end }}
{{ end }}
+
+
+{{ define "networkVouchItem" }}
+
+
+ {{ template "user/fragments/picLink" (list .Did "size-8") }}
+
+
+
+ {{ resolve .Did }}
+
+
+ {{ .Reason | description }}
+
+
+
+
+ {{ template "repo/fragments/shortTime" .CreatedAt }}
+
+
+ view {{ i "arrow-right" "size-3" }}
+
+
+
{{ end }}
diff --git a/appview/pages/templates/user/fragments/vouch.html b/appview/pages/templates/user/fragments/vouch.html
new file mode 100644
index 00000000..375e502c
--- /dev/null
+++ b/appview/pages/templates/user/fragments/vouch.html
@@ -0,0 +1,6 @@
+{{ define "user/fragments/vouch" }}
+
+ {{ template "user/fragments/vouchButton" . }}
+ {{ template "user/fragments/vouchPopover" . }}
+
+{{ end }}
diff --git a/appview/pages/templates/user/fragments/vouchTooltip.html b/appview/pages/templates/user/fragments/vouchTooltip.html
new file mode 100644
index 00000000..914da99b
--- /dev/null
+++ b/appview/pages/templates/user/fragments/vouchTooltip.html
@@ -0,0 +1,25 @@
+{{ define "user/fragments/vouchTooltip" }}
+ {{- $vr := . -}}
+ {{- if not $vr.IsEmpty -}}
+ {{- $networkPart := "" -}}
+ {{- if and (gt $vr.VouchStrength 0) (eq $vr.DenounceStrength 0) -}}
+ {{- $networkPart = printf "%s from your network vouched for this user." (plural $vr.VouchStrength "person" "people") -}}
+ {{- else if and (gt $vr.DenounceStrength 0) (eq $vr.VouchStrength 0) -}}
+ {{- $networkPart = printf "%s from your network denounced this user." (plural $vr.DenounceStrength "person" "people") -}}
+ {{- else if and (eq $vr.DenounceStrength 0) (eq $vr.VouchStrength 0) -}}
+ {{- $networkPart = "" -}}
+ {{- else -}}
+ {{- $networkPart = printf "%s and %s from your network." (plural $vr.VouchStrength "vouch" "vouches") (plural $vr.DenounceStrength "denounce" "") -}}
+ {{- end -}}
+ {{- $direct := $vr.GetDirectVouch -}}
+ {{- if $direct -}}
+ {{- if $direct.IsVouch -}}
+ You vouched for {{ resolve $vr.SubjectDid }} {{ relTimeFmt $direct.CreatedAt }}. {{ $networkPart }}
+ {{- else -}}
+ You denounced {{ resolve $vr.SubjectDid }} {{ relTimeFmt $direct.CreatedAt }}. {{ $networkPart }}
+ {{- end -}}
+ {{- else -}}
+ {{- $networkPart -}}.
+ {{- end -}}
+ {{- end -}}
+{{ end }}
diff --git a/appview/pages/templates/user/vouches.html b/appview/pages/templates/user/vouches.html
new file mode 100644
index 00000000..6b9f675f
--- /dev/null
+++ b/appview/pages/templates/user/vouches.html
@@ -0,0 +1,109 @@
+{{ define "title" }}{{ resolve .Card.UserDid }} · vouches {{ end }}
+
+{{ define "profileContent" }}
+ {{ $isSelf := and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
+
+ {{ if $isSelf }}{{ template "vouchSuggestions" . }}{{ end }}
+ {{ block "vouchTimeline" . }}{{ end }}
+ {{ if ge (len .Vouches) .Page.Limit }}
+ {{ $handle := resolve .Card.UserDid }}
+ {{ template "fragments/pagination" (dict
+ "Page" .Page
+ "TotalCount" (add (len .Vouches) .Page.Offset)
+ "BasePath" (printf "/%s" $handle)
+ "QueryParams" (queryParams "tab" "vouches")
+ ) }}
+ {{ end }}
+
+{{ end }}
+
+{{ define "vouchTimeline" }}
+ {{ $isSelf := and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }}
+ {{ if not .LoggedInUser }}
+
+
You need to log in to view vouches.
+
+ {{ else if not .Vouches }}
+ {{ if not (and $isSelf .Suggestions) }}
+
+ No vouches in your network yet.
+
+ {{ end }}
+ {{ else }}
+
+
+ {{ range .Vouches }}
+ {{ template "vouchTimelineItem" (list $.Card.UserDid $.LoggedInUser.Did .) }}
+ {{ end }}
+
+ {{ end }}
+{{ end }}
+
+{{ define "vouchSuggestions" }}
+ {{ if .Suggestions }}
+
+ {{ range .Suggestions }}
+
+

+
+
+ {{ template "user/fragments/vouch" . }}
+
+
+ {{ end }}
+
+ {{ end }}
+{{ end }}
+
+{{ define "vouchTimelineItem" }}
+ {{ $profileDid := index . 0 }}
+ {{ $viewerDid := index . 1 }}
+ {{ $v := index . 2 }}
+ {{ $isOutgoing := eq $v.Did $profileDid }}
+ {{ $isVouch := $v.IsVouch }}
+ {{ $isSelf := eq $profileDid $viewerDid }}
+
+
+
+ {{ if $isVouch }}
+ {{ if $isOutgoing }}
+ {{ i "arrow-up-right" "size-5 text-green-500 dark:text-green-400" }}
+ {{ else }}
+ {{ i "arrow-down-left" "size-5 text-green-500 dark:text-green-400" }}
+ {{ end }}
+ {{ else }}
+ {{ if $isOutgoing }}
+ {{ i "arrow-up-right" "size-5 text-red-500 dark:text-red-400" }}
+ {{ else }}
+ {{ i "arrow-down-left" "size-5 text-red-500 dark:text-red-400" }}
+ {{ end }}
+ {{ end }}
+
+
+
+
+ {{ if and $isSelf $isOutgoing }}
+
you
+ {{ else }}
+
{{ resolve $v.Did.String }}
+ {{ end }}
+
+ {{ if $isVouch }}vouched for{{ else }}denounced{{ end }}
+
+ {{ if and $isSelf (not $isOutgoing) }}
+
you
+ {{ else }}
+
{{ resolve $v.SubjectDid.String }}
+ {{ end }}
+
{{ relTimeFmt $v.CreatedAt }}
+
+ {{ with $v.Reason }}
+
{{ . }}
+ {{ end }}
+
+
+{{ end }}
diff --git a/appview/state/router.go b/appview/state/router.go
index df931238..9a5f11d8 100644
--- a/appview/state/router.go
+++ b/appview/state/router.go
@@ -181,6 +181,10 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler {
r.Delete("/", s.Follow)
})
+ r.With(middleware.AuthMiddleware(s.oauth)).Route("/vouch", func(r chi.Router) {
+ r.Post("/", s.Vouch)
+ })
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) {
r.Post("/", s.Star)
r.Delete("/", s.Star)
diff --git a/appview/state/state.go b/appview/state/state.go
index b6d60199..fc18104f 100644
--- a/appview/state/state.go
+++ b/appview/state/state.go
@@ -123,21 +123,22 @@ func Make(ctx context.Context, config *config.Config) (*State, error) {
config.Jetstream.Endpoint,
"appview",
[]string{
- tangled.GraphFollowNSID,
+ tangled.ActorProfileNSID,
tangled.FeedStarNSID,
+ tangled.GraphFollowNSID,
+ tangled.GraphVouchNSID,
+ tangled.KnotMemberNSID,
+ tangled.KnotNSID,
+ tangled.LabelDefinitionNSID,
+ tangled.LabelOpNSID,
tangled.PublicKeyNSID,
tangled.RepoArtifactNSID,
- tangled.ActorProfileNSID,
- tangled.KnotMemberNSID,
+ tangled.RepoIssueCommentNSID,
+ tangled.RepoIssueNSID,
+ tangled.RepoPullNSID,
tangled.SpindleMemberNSID,
tangled.SpindleNSID,
- tangled.KnotNSID,
tangled.StringNSID,
- tangled.RepoPullNSID,
- tangled.RepoIssueNSID,
- tangled.RepoIssueCommentNSID,
- tangled.LabelDefinitionNSID,
- tangled.LabelOpNSID,
},
nil,
tlog.SubLogger(logger, "jetstream"),
diff --git a/appview/state/vouch.go b/appview/state/vouch.go
new file mode 100644
index 00000000..bcf2f22d
--- /dev/null
+++ b/appview/state/vouch.go
@@ -0,0 +1,156 @@
+package state
+
+import (
+ "net/http"
+ "time"
+
+ comatproto "github.com/bluesky-social/indigo/api/atproto"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+ lexutil "github.com/bluesky-social/indigo/lex/util"
+ "github.com/ipfs/go-cid"
+ "tangled.org/core/api/tangled"
+ "tangled.org/core/appview/db"
+ "tangled.org/core/appview/models"
+ "tangled.org/core/log"
+)
+
+func (s *State) Vouch(w http.ResponseWriter, r *http.Request) {
+ l := log.SubLogger(s.logger, "vouch")
+ l = s.logger.With("handler", "Vouch")
+ currentUser := s.oauth.GetMultiAccountUser(r)
+
+ var subject string
+
+ subject = r.FormValue("subject")
+
+ if subject == "" {
+ l.Warn("invalid form: missing subject")
+ s.pages.Notice(w, "error", "Missing subject user.")
+ return
+ }
+
+ subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject)
+ if err != nil {
+ l.Error("failed to vouch, invalid did", "subject", subject, "err", err)
+ s.pages.Notice(w, "error", "Could not find that user.")
+ return
+ }
+
+ if currentUser.Did == subjectIdent.DID.String() {
+ l.Warn("cant vouch or denounce yourself")
+ s.pages.Notice(w, "error", "You cannot vouch for yourself.")
+ return
+ }
+
+ client, err := s.oauth.AuthorizedClient(r)
+ if err != nil {
+ l.Error("failed to authorize client", "err", err)
+ s.pages.Notice(w, "error", "Authentication required.")
+ return
+ }
+
+ if err := r.ParseForm(); err != nil {
+ l.Warn("failed to parse form", "err", err)
+ s.pages.Notice(w, "error", "Invalid form data.")
+ return
+ }
+
+ subjectDid := subjectIdent.DID.String()
+
+ // handle "none" by deleting any existing vouch
+ if r.FormValue("kind") == "none" {
+ _, err := db.GetVouch(s.db, currentUser.Did, subjectDid)
+ if err != nil {
+ l.Info("no existing vouch to delete")
+ s.pages.HxRefresh(w)
+ return
+ }
+
+ _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
+ Collection: tangled.GraphVouchNSID,
+ Repo: currentUser.Did,
+ Rkey: subjectDid,
+ })
+
+ if err != nil {
+ l.Error("failed to delete vouch record", "err", err)
+ s.pages.Notice(w, "error", "Failed to delete vouch record.")
+ return
+ }
+
+ err = db.DeleteVouch(s.db, currentUser.Did, subjectDid)
+ if err != nil {
+ l.Warn("failed to delete vouch from DB", "err", err)
+ }
+
+ l.Info("deleted vouch record")
+ s.pages.HxRefresh(w)
+ return
+ }
+
+ kind, err := models.ParseVouchKind(r.FormValue("kind"))
+ if err != nil {
+ l.Warn("failed to parse vouch kind", "err", err)
+ s.pages.Notice(w, "error", "Invalid action type.")
+ return
+ }
+
+ reason := r.FormValue("reason")
+ createdAt := time.Now().Format(time.RFC3339)
+
+ var reasonPtr *string
+ if reason != "" {
+ reasonPtr = &reason
+ }
+
+ var swapCid *string
+ existingVouch, err := db.GetVouch(s.db, currentUser.Did, subjectDid)
+ if err == nil {
+ cidStr := existingVouch.Cid.String()
+ swapCid = &cidStr
+ }
+
+ resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+ Collection: tangled.GraphVouchNSID,
+ Repo: currentUser.Did,
+ Rkey: subjectDid,
+ SwapRecord: swapCid,
+ Record: &lexutil.LexiconTypeDecoder{
+ Val: &tangled.GraphVouch{
+ Kind: string(kind),
+ Reason: reasonPtr,
+ CreatedAt: createdAt,
+ }},
+ })
+ if err != nil {
+ l.Error("failed to create atproto record", "err", err)
+ s.pages.Notice(w, "error", "Failed to create vouch record.")
+ return
+ }
+
+ l.Info("created atproto record", "uri", resp.Uri, "kind", kind)
+
+ newCid, err := cid.Parse(resp.Cid)
+ if err != nil {
+ l.Error("failed to parse returned cid", "err", err)
+ s.pages.Notice(w, "error", "Failed to save vouch.")
+ return
+ }
+
+ vouch := &models.Vouch{
+ Did: syntax.DID(currentUser.Did),
+ SubjectDid: subjectIdent.DID,
+ Cid: newCid,
+ Kind: kind,
+ Reason: reasonPtr,
+ }
+
+ err = db.AddVouch(s.db, vouch)
+ if err != nil {
+ l.Error("failed to add vouch to db", "err", err)
+ s.pages.Notice(w, "error", "Failed to save vouch.")
+ return
+ }
+
+ s.pages.HxRefresh(w)
+}