appview: add reaction for issues/PRs (close #65) #265

merged
opened by boltless.me targeting master from boltless.me/core: push-uoymosxlmxvl

Add sh.tangled.feed.reaction lexicon and UI to react to issues/PRs close #65 close #113

Signed-off-by: Seongmin Lee boltlessengineer@proton.me

Changed files
+697 -5
api
appview
db
issues
pages
templates
layouts
repo
issues
pulls
fragments
pulls
state
cmd
lexicons
+164 -2
api/tangled/cbor_gen.go
···
return nil
}
+
func (t *FeedReaction) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
+
if _, err := cw.Write([]byte{163}); err != nil {
+
return err
+
}
+
+
// t.LexiconTypeID (string) (string)
+
if len("$type") > 1000000 {
+
return xerrors.Errorf("Value in field \"$type\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("$type")); err != nil {
+
return err
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.feed.reaction"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("sh.tangled.feed.reaction")); err != nil {
+
return err
+
}
+
+
// t.Subject (string) (string)
+
if len("subject") > 1000000 {
+
return xerrors.Errorf("Value in field \"subject\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("subject")); err != nil {
+
return err
+
}
+
+
if len(t.Subject) > 1000000 {
+
return xerrors.Errorf("Value in field t.Subject was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
+
return err
+
}
+
+
// t.CreatedAt (string) (string)
+
if len("createdAt") > 1000000 {
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
+
return err
+
}
+
+
if len(t.CreatedAt) > 1000000 {
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
+
return err
+
}
+
return nil
+
}
+
+
func (t *FeedReaction) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = FeedReaction{}
+
+
cr := cbg.NewCborReader(r)
+
+
maj, extra, err := cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
if err == io.EOF {
+
err = io.ErrUnexpectedEOF
+
}
+
}()
+
+
if maj != cbg.MajMap {
+
return fmt.Errorf("cbor input should be of type map")
+
}
+
+
if extra > cbg.MaxLength {
+
return fmt.Errorf("FeedReaction: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 9)
+
for i := uint64(0); i < n; i++ {
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+
if err != nil {
+
return err
+
}
+
+
if !ok {
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
continue
+
}
+
+
switch string(nameBuf[:nameLen]) {
+
// t.LexiconTypeID (string) (string)
+
case "$type":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.LexiconTypeID = string(sval)
+
}
+
// t.Subject (string) (string)
+
case "subject":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Subject = string(sval)
+
}
+
// t.CreatedAt (string) (string)
+
case "createdAt":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.CreatedAt = string(sval)
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
func (t *FeedStar) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
···
return nil
-
func (t *Pipeline_Step_Environment_Elem) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
···
return nil
-
func (t *Pipeline_Step) MarshalCBOR(w io.Writer) error {
if t == nil {
_, err := w.Write(cbg.CborNull)
+24
api/tangled/feedreaction.go
···
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
+
+
package tangled
+
+
// schema: sh.tangled.feed.reaction
+
+
import (
+
"github.com/bluesky-social/indigo/lex/util"
+
)
+
+
const (
+
FeedReactionNSID = "sh.tangled.feed.reaction"
+
)
+
+
func init() {
+
util.RegisterType("sh.tangled.feed.reaction", &FeedReaction{})
+
} //
+
// RECORDTYPE: FeedReaction
+
type FeedReaction struct {
+
LexiconTypeID string `json:"$type,const=sh.tangled.feed.reaction" cborgen:"$type,const=sh.tangled.feed.reaction"`
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
+
Reaction string `json:"reaction" cborgen:"reaction"`
+
Subject string `json:"subject" cborgen:"subject"`
+
}
+10
appview/db/db.go
···
unique(starred_by_did, repo_at)
);
+
create table if not exists reactions (
+
id integer primary key autoincrement,
+
reacted_by_did text not null,
+
thread_at text not null,
+
kind text not null,
+
rkey text not null,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
unique(reacted_by_did, thread_at, kind)
+
);
+
create table if not exists emails (
id integer primary key autoincrement,
did text not null,
+2 -2
appview/db/issues.go
···
}
func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) {
-
query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?`
+
query := `select owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?`
row := e.QueryRow(query, repoAt, issueId)
var issue Issue
var createdAt string
-
err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open)
+
err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt)
if err != nil {
return nil, nil, err
}
+178
appview/db/reaction.go
···
+
package db
+
+
import (
+
"log"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type ReactionKind string
+
+
const (
+
Like ReactionKind = "👍"
+
Unlike = "👎"
+
Laugh = "😆"
+
Celebration = "🎉"
+
Confused = "🫤"
+
Heart = "❤️"
+
Rocket = "🚀"
+
Eyes = "👀"
+
)
+
+
func (rk ReactionKind) String() string {
+
return string(rk)
+
}
+
+
var OrderedReactionKinds = []ReactionKind{
+
Like,
+
Unlike,
+
Laugh,
+
Celebration,
+
Confused,
+
Heart,
+
Rocket,
+
Eyes,
+
}
+
+
func ParseReactionKind(raw string) (ReactionKind, bool) {
+
k, ok := (map[string]ReactionKind{
+
"👍": Like,
+
"👎": Unlike,
+
"😆": Laugh,
+
"🎉": Celebration,
+
"🫤": Confused,
+
"❤️": Heart,
+
"🚀": Rocket,
+
"👀": Eyes,
+
})[raw]
+
return k, ok
+
}
+
+
type Reaction struct {
+
ReactedByDid string
+
ThreadAt syntax.ATURI
+
Created time.Time
+
Rkey string
+
Kind ReactionKind
+
}
+
+
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error {
+
query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)`
+
_, err := e.Exec(query, reactedByDid, threadAt, kind, rkey)
+
return err
+
}
+
+
// Get a reaction record
+
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) {
+
query := `
+
select reacted_by_did, thread_at, created, rkey
+
from reactions
+
where reacted_by_did = ? and thread_at = ? and kind = ?`
+
row := e.QueryRow(query, reactedByDid, threadAt, kind)
+
+
var reaction Reaction
+
var created string
+
err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey)
+
if err != nil {
+
return nil, err
+
}
+
+
createdAtTime, err := time.Parse(time.RFC3339, created)
+
if err != nil {
+
log.Println("unable to determine followed at time")
+
reaction.Created = time.Now()
+
} else {
+
reaction.Created = createdAtTime
+
}
+
+
return &reaction, nil
+
}
+
+
// Remove a reaction
+
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error {
+
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind)
+
return err
+
}
+
+
// Remove a reaction
+
func DeleteReactionByRkey(e Execer, reactedByDid string, rkey string) error {
+
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and rkey = ?`, reactedByDid, rkey)
+
return err
+
}
+
+
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) {
+
count := 0
+
err := e.QueryRow(
+
`select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count)
+
if err != nil {
+
return 0, err
+
}
+
return count, nil
+
}
+
+
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) {
+
like, err := GetReactionCount(e, threadAt, Like)
+
if err != nil {
+
return map[ReactionKind]int{}, nil
+
}
+
unlike, err := GetReactionCount(e, threadAt, Unlike)
+
if err != nil {
+
return map[ReactionKind]int{}, nil
+
}
+
laugh, err := GetReactionCount(e, threadAt, Laugh)
+
if err != nil {
+
return map[ReactionKind]int{}, nil
+
}
+
celebration, err := GetReactionCount(e, threadAt, Celebration)
+
if err != nil {
+
return map[ReactionKind]int{}, nil
+
}
+
confused, err := GetReactionCount(e, threadAt, Confused)
+
if err != nil {
+
return map[ReactionKind]int{}, nil
+
}
+
heart, err := GetReactionCount(e, threadAt, Heart)
+
if err != nil {
+
return map[ReactionKind]int{}, nil
+
}
+
rocket, err := GetReactionCount(e, threadAt, Rocket)
+
if err != nil {
+
return map[ReactionKind]int{}, nil
+
}
+
eyes, err := GetReactionCount(e, threadAt, Eyes)
+
if err != nil {
+
return map[ReactionKind]int{}, nil
+
}
+
return map[ReactionKind]int{
+
Like: like,
+
Unlike: unlike,
+
Laugh: laugh,
+
Celebration: celebration,
+
Confused: confused,
+
Heart: heart,
+
Rocket: rocket,
+
Eyes: eyes,
+
}, nil
+
}
+
+
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool {
+
if _, err := GetReaction(e, userDid, threadAt, kind); err != nil {
+
return false
+
} else {
+
return true
+
}
+
}
+
+
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool {
+
return map[ReactionKind]bool{
+
Like: GetReactionStatus(e, userDid, threadAt, Like),
+
Unlike: GetReactionStatus(e, userDid, threadAt, Unlike),
+
Laugh: GetReactionStatus(e, userDid, threadAt, Laugh),
+
Celebration: GetReactionStatus(e, userDid, threadAt, Celebration),
+
Confused: GetReactionStatus(e, userDid, threadAt, Confused),
+
Heart: GetReactionStatus(e, userDid, threadAt, Heart),
+
Rocket: GetReactionStatus(e, userDid, threadAt, Rocket),
+
Eyes: GetReactionStatus(e, userDid, threadAt, Eyes),
+
}
+
}
+16
appview/issues/issues.go
···
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/data"
+
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/go-chi/chi/v5"
"github.com/posthog/posthog-go"
···
return
}
+
reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt))
+
if err != nil {
+
log.Println("failed to get issue reactions")
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
+
}
+
+
userReactions := map[db.ReactionKind]bool{}
+
if user != nil {
+
userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt))
+
}
+
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
if err != nil {
log.Println("failed to resolve issue owner", err)
···
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
DidHandleMap: didHandleMap,
+
+
OrderedReactionKinds: db.OrderedReactionKinds,
+
Reactions: reactionCountMap,
+
UserReacted: userReactions,
})
}
+19
appview/pages/pages.go
···
IssueOwnerHandle string
DidHandleMap map[string]string
+
OrderedReactionKinds []db.ReactionKind
+
Reactions map[db.ReactionKind]int
+
UserReacted map[db.ReactionKind]bool
+
State string
}
+
type ThreadReactionFragmentParams struct {
+
ThreadAt syntax.ATURI
+
Kind db.ReactionKind
+
Count int
+
IsReacted bool
+
}
+
+
func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error {
+
return p.executePlain("repo/issues/fragments/reaction", w, params)
+
}
+
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
params.Active = "issues"
if params.Issue.Open {
···
AbandonedPulls []*db.Pull
MergeCheck types.MergeCheckResponse
ResubmitCheck ResubmitResult
+
+
OrderedReactionKinds []db.ReactionKind
+
Reactions map[db.ReactionKind]int
+
UserReacted map[db.ReactionKind]bool
}
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
+1 -1
appview/pages/templates/layouts/repobase.html
···
</div>
</nav>
<section
-
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"
+
class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"
>
{{ block "repoContent" . }}{{ end }}
</section>
+34
appview/pages/templates/repo/issues/fragments/reaction.html
···
+
{{ define "repo/issues/fragments/reaction" }}
+
<button
+
id="reactIndi-{{ .Kind }}"
+
class="flex justify-center items-center min-w-8 min-h-8 rounded border
+
leading-4 px-3 gap-1
+
{{ if eq .Count 0 }}
+
hidden
+
{{ end }}
+
{{ if .IsReacted }}
+
bg-sky-100
+
border-sky-400
+
dark:bg-sky-900
+
dark:border-sky-500
+
{{ else }}
+
border-gray-200
+
hover:bg-gray-50
+
hover:border-gray-300
+
dark:border-gray-700
+
dark:hover:bg-gray-700
+
dark:hover:border-gray-600
+
{{ end }}
+
"
+
{{ if .IsReacted }}
+
hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
+
{{ else }}
+
hx-post="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
+
{{ end }}
+
hx-swap="outerHTML"
+
hx-trigger="click from:(#reactBtn-{{ .Kind }}, #reactIndi-{{ .Kind }})"
+
hx-disabled-elt="this"
+
>
+
<span>{{ .Kind }}</span> <span>{{ .Count }}</span>
+
</button>
+
{{ end }}
+34
appview/pages/templates/repo/issues/issue.html
···
{{ .Issue.Body | markdown }}
</article>
{{ end }}
+
+
<div class="flex items-center gap-2 mt-2">
+
<details class="relative inline-block">
+
<summary
+
class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700
+
hover:bg-gray-50
+
hover:border-gray-300
+
dark:hover:bg-gray-700
+
dark:hover:border-gray-600
+
cursor-pointer list-none"
+
>
+
{{ i "smile" "size-4" }}
+
</summary>
+
<div
+
class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg"
+
>
+
{{ range $kind := .OrderedReactionKinds }}
+
<button id="reactBtn-{{ $kind }}" class="size-12 dark:hover:bg-gray-700">
+
{{ $kind }}
+
</button>
+
{{ end }}
+
</div>
+
</details>
+
{{ range $kind := .OrderedReactionKinds }}
+
{{
+
template "repo/issues/fragments/reaction"
+
(dict
+
"Kind" $kind
+
"Count" (index $.Reactions $kind)
+
"IsReacted" (index $.UserReacted $kind)
+
"ThreadAt" $.Issue.IssueAt)
+
}}
+
{{ end }}
+
</div>
</section>
{{ end }}
+34
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
{{ .Pull.Body | markdown }}
</article>
{{ end }}
+
+
<div class="flex items-center gap-2 mt-2">
+
<details class="relative inline-block">
+
<summary
+
class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700
+
hover:bg-gray-50
+
hover:border-gray-300
+
dark:hover:bg-gray-700
+
dark:hover:border-gray-600
+
cursor-pointer list-none"
+
>
+
{{ i "smile" "size-4" }}
+
</summary>
+
<div
+
class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg"
+
>
+
{{ range $kind := .OrderedReactionKinds }}
+
<button id="reactBtn-{{ $kind }}" class="size-12 dark:hover:bg-gray-700">
+
{{ $kind }}
+
</button>
+
{{ end }}
+
</div>
+
</details>
+
{{ range $kind := .OrderedReactionKinds }}
+
{{
+
template "repo/issues/fragments/reaction"
+
(dict
+
"Kind" $kind
+
"Count" (index $.Reactions $kind)
+
"IsReacted" (index $.UserReacted $kind)
+
"ThreadAt" $.Pull.PullAt)
+
}}
+
{{ end }}
+
</div>
</section>
+15
appview/pulls/pulls.go
···
resubmitResult = s.resubmitCheck(f, pull, stack)
}
+
reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt())
+
if err != nil {
+
log.Println("failed to get pull reactions")
+
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
+
}
+
+
userReactions := map[db.ReactionKind]bool{}
+
if user != nil {
+
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
+
}
+
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
LoggedInUser: user,
RepoInfo: f.RepoInfo(user),
···
AbandonedPulls: abandonedPulls,
MergeCheck: mergeCheckResponse,
ResubmitCheck: resubmitResult,
+
+
OrderedReactionKinds: db.OrderedReactionKinds,
+
Reactions: reactionCountMap,
+
UserReacted: userReactions,
})
}
+126
appview/state/reaction.go
···
+
package state
+
+
import (
+
"log"
+
"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"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/pages"
+
)
+
+
func (s *State) React(w http.ResponseWriter, r *http.Request) {
+
currentUser := s.oauth.GetUser(r)
+
+
subject := r.URL.Query().Get("subject")
+
if subject == "" {
+
log.Println("invalid form")
+
return
+
}
+
+
subjectUri, err := syntax.ParseATURI(subject)
+
if err != nil {
+
log.Println("invalid form")
+
return
+
}
+
+
reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind"))
+
if !ok {
+
log.Println("invalid reaction kind")
+
return
+
}
+
+
client, err := s.oauth.AuthorizedClient(r)
+
if err != nil {
+
log.Println("failed to authorize client", err)
+
return
+
}
+
+
switch r.Method {
+
case http.MethodPost:
+
createdAt := time.Now().Format(time.RFC3339)
+
rkey := appview.TID()
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
Collection: tangled.FeedReactionNSID,
+
Repo: currentUser.Did,
+
Rkey: rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.FeedReaction{
+
Subject: subjectUri.String(),
+
Reaction: reactionKind.String(),
+
CreatedAt: createdAt,
+
},
+
},
+
})
+
if err != nil {
+
log.Println("failed to create atproto record", err)
+
return
+
}
+
+
err = db.AddReaction(s.db, currentUser.Did, subjectUri, reactionKind, rkey)
+
if err != nil {
+
log.Println("failed to react", err)
+
return
+
}
+
+
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
+
if err != nil {
+
log.Println("failed to get reaction count for ", subjectUri)
+
}
+
+
log.Println("created atproto record: ", resp.Uri)
+
+
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
+
ThreadAt: subjectUri,
+
Kind: reactionKind,
+
Count: count,
+
IsReacted: true,
+
})
+
+
return
+
case http.MethodDelete:
+
reaction, err := db.GetReaction(s.db, currentUser.Did, subjectUri, reactionKind)
+
if err != nil {
+
log.Println("failed to get reaction relationship for", currentUser.Did, subjectUri)
+
return
+
}
+
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
Collection: tangled.FeedReactionNSID,
+
Repo: currentUser.Did,
+
Rkey: reaction.Rkey,
+
})
+
+
if err != nil {
+
log.Println("failed to remove reaction")
+
return
+
}
+
+
err = db.DeleteReactionByRkey(s.db, currentUser.Did, reaction.Rkey)
+
if err != nil {
+
log.Println("failed to delete reaction from DB")
+
// this is not an issue, the firehose event might have already done this
+
}
+
+
count, err := db.GetReactionCount(s.db, subjectUri, reactionKind)
+
if err != nil {
+
log.Println("failed to get reaction count for ", subjectUri)
+
return
+
}
+
+
s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{
+
ThreadAt: subjectUri,
+
Kind: reactionKind,
+
Count: count,
+
IsReacted: false,
+
})
+
+
return
+
}
+
}
+5
appview/state/router.go
···
r.Delete("/", s.Star)
})
+
r.With(middleware.AuthMiddleware(s.oauth)).Route("/react", func(r chi.Router) {
+
r.Post("/", s.React)
+
r.Delete("/", s.React)
+
})
+
r.Route("/profile", func(r chi.Router) {
r.Use(middleware.AuthMiddleware(s.oauth))
r.Get("/edit-bio", s.EditBioFragment)
+1
cmd/gen.go
···
"api/tangled/cbor_gen.go",
"tangled",
tangled.ActorProfile{},
+
tangled.FeedReaction{},
tangled.FeedStar{},
tangled.GitRefUpdate{},
tangled.GitRefUpdate_Meta{},
+34
lexicons/feed/reaction.json
···
+
{
+
"lexicon": 1,
+
"id": "sh.tangled.feed.reaction",
+
"needsCbor": true,
+
"needsType": true,
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": [
+
"subject",
+
"reaction",
+
"createdAt"
+
],
+
"properties": {
+
"subject": {
+
"type": "string",
+
"format": "at-uri"
+
},
+
"reaction": {
+
"type": "string",
+
"enum": [ "👍", "👎", "😆", "🎉", "🫤", "❤️", "🚀", "👀" ]
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}