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
··· 504 504 505 505 return nil 506 506 } 507 + func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 508 + if t == nil { 509 + _, err := w.Write(cbg.CborNull) 510 + return err 511 + } 512 + 513 + cw := cbg.NewCborWriter(w) 514 + 515 + if _, err := cw.Write([]byte{163}); err != nil { 516 + return err 517 + } 518 + 519 + // t.LexiconTypeID (string) (string) 520 + if len("$type") > 1000000 { 521 + return xerrors.Errorf("Value in field \"$type\" was too long") 522 + } 523 + 524 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 525 + return err 526 + } 527 + if _, err := cw.WriteString(string("$type")); err != nil { 528 + return err 529 + } 530 + 531 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.feed.reaction"))); err != nil { 532 + return err 533 + } 534 + if _, err := cw.WriteString(string("sh.tangled.feed.reaction")); err != nil { 535 + return err 536 + } 537 + 538 + // t.Subject (string) (string) 539 + if len("subject") > 1000000 { 540 + return xerrors.Errorf("Value in field \"subject\" was too long") 541 + } 542 + 543 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 544 + return err 545 + } 546 + if _, err := cw.WriteString(string("subject")); err != nil { 547 + return err 548 + } 549 + 550 + if len(t.Subject) > 1000000 { 551 + return xerrors.Errorf("Value in field t.Subject was too long") 552 + } 553 + 554 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 555 + return err 556 + } 557 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 558 + return err 559 + } 560 + 561 + // t.CreatedAt (string) (string) 562 + if len("createdAt") > 1000000 { 563 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 564 + } 565 + 566 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 567 + return err 568 + } 569 + if _, err := cw.WriteString(string("createdAt")); err != nil { 570 + return err 571 + } 572 + 573 + if len(t.CreatedAt) > 1000000 { 574 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 575 + } 576 + 577 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 578 + return err 579 + } 580 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 581 + return err 582 + } 583 + return nil 584 + } 585 + 586 + func (t *FeedReaction) UnmarshalCBOR(r io.Reader) (err error) { 587 + *t = FeedReaction{} 588 + 589 + cr := cbg.NewCborReader(r) 590 + 591 + maj, extra, err := cr.ReadHeader() 592 + if err != nil { 593 + return err 594 + } 595 + defer func() { 596 + if err == io.EOF { 597 + err = io.ErrUnexpectedEOF 598 + } 599 + }() 600 + 601 + if maj != cbg.MajMap { 602 + return fmt.Errorf("cbor input should be of type map") 603 + } 604 + 605 + if extra > cbg.MaxLength { 606 + return fmt.Errorf("FeedReaction: map struct too large (%d)", extra) 607 + } 608 + 609 + n := extra 610 + 611 + nameBuf := make([]byte, 9) 612 + for i := uint64(0); i < n; i++ { 613 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 614 + if err != nil { 615 + return err 616 + } 617 + 618 + if !ok { 619 + // Field doesn't exist on this type, so ignore it 620 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 621 + return err 622 + } 623 + continue 624 + } 625 + 626 + switch string(nameBuf[:nameLen]) { 627 + // t.LexiconTypeID (string) (string) 628 + case "$type": 629 + 630 + { 631 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 632 + if err != nil { 633 + return err 634 + } 635 + 636 + t.LexiconTypeID = string(sval) 637 + } 638 + // t.Subject (string) (string) 639 + case "subject": 640 + 641 + { 642 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 643 + if err != nil { 644 + return err 645 + } 646 + 647 + t.Subject = string(sval) 648 + } 649 + // t.CreatedAt (string) (string) 650 + case "createdAt": 651 + 652 + { 653 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 654 + if err != nil { 655 + return err 656 + } 657 + 658 + t.CreatedAt = string(sval) 659 + } 660 + 661 + default: 662 + // Field doesn't exist on this type, so ignore it 663 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 664 + return err 665 + } 666 + } 667 + } 668 + 669 + return nil 670 + } 507 671 func (t *FeedStar) MarshalCBOR(w io.Writer) error { 508 672 if t == nil { 509 673 _, err := w.Write(cbg.CborNull) ··· 3014 3178 3015 3179 return nil 3016 3180 } 3017 - 3018 3181 func (t *Pipeline_Step_Environment_Elem) MarshalCBOR(w io.Writer) error { 3019 3182 if t == nil { 3020 3183 _, err := w.Write(cbg.CborNull) ··· 3511 3674 3512 3675 return nil 3513 3676 } 3514 - 3515 3677 func (t *Pipeline_Step) MarshalCBOR(w io.Writer) error { 3516 3678 if t == nil { 3517 3679 _, err := w.Write(cbg.CborNull)
+24
api/tangled/feedreaction.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.feed.reaction 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + FeedReactionNSID = "sh.tangled.feed.reaction" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.feed.reaction", &FeedReaction{}) 17 + } // 18 + // RECORDTYPE: FeedReaction 19 + type FeedReaction struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.feed.reaction" cborgen:"$type,const=sh.tangled.feed.reaction"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + Reaction string `json:"reaction" cborgen:"reaction"` 23 + Subject string `json:"subject" cborgen:"subject"` 24 + }
+10
appview/db/db.go
··· 199 199 unique(starred_by_did, repo_at) 200 200 ); 201 201 202 + create table if not exists reactions ( 203 + id integer primary key autoincrement, 204 + reacted_by_did text not null, 205 + thread_at text not null, 206 + kind text not null, 207 + rkey text not null, 208 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 209 + unique(reacted_by_did, thread_at, kind) 210 + ); 211 + 202 212 create table if not exists emails ( 203 213 id integer primary key autoincrement, 204 214 did text not null,
+2 -2
appview/db/issues.go
··· 277 277 } 278 278 279 279 func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 280 - query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 280 + query := `select owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 281 281 row := e.QueryRow(query, repoAt, issueId) 282 282 283 283 var issue Issue 284 284 var createdAt string 285 - err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 285 + err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 286 286 if err != nil { 287 287 return nil, nil, err 288 288 }
+178
appview/db/reaction.go
··· 1 + package db 2 + 3 + import ( 4 + "log" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type ReactionKind string 11 + 12 + const ( 13 + Like ReactionKind = "👍" 14 + Unlike = "👎" 15 + Laugh = "😆" 16 + Celebration = "🎉" 17 + Confused = "🫤" 18 + Heart = "❤️" 19 + Rocket = "🚀" 20 + Eyes = "👀" 21 + ) 22 + 23 + func (rk ReactionKind) String() string { 24 + return string(rk) 25 + } 26 + 27 + var OrderedReactionKinds = []ReactionKind{ 28 + Like, 29 + Unlike, 30 + Laugh, 31 + Celebration, 32 + Confused, 33 + Heart, 34 + Rocket, 35 + Eyes, 36 + } 37 + 38 + func ParseReactionKind(raw string) (ReactionKind, bool) { 39 + k, ok := (map[string]ReactionKind{ 40 + "👍": Like, 41 + "👎": Unlike, 42 + "😆": Laugh, 43 + "🎉": Celebration, 44 + "🫤": Confused, 45 + "❤️": Heart, 46 + "🚀": Rocket, 47 + "👀": Eyes, 48 + })[raw] 49 + return k, ok 50 + } 51 + 52 + type Reaction struct { 53 + ReactedByDid string 54 + ThreadAt syntax.ATURI 55 + Created time.Time 56 + Rkey string 57 + Kind ReactionKind 58 + } 59 + 60 + func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error { 61 + query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` 62 + _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) 63 + return err 64 + } 65 + 66 + // Get a reaction record 67 + func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) { 68 + query := ` 69 + select reacted_by_did, thread_at, created, rkey 70 + from reactions 71 + where reacted_by_did = ? and thread_at = ? and kind = ?` 72 + row := e.QueryRow(query, reactedByDid, threadAt, kind) 73 + 74 + var reaction Reaction 75 + var created string 76 + err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey) 77 + if err != nil { 78 + return nil, err 79 + } 80 + 81 + createdAtTime, err := time.Parse(time.RFC3339, created) 82 + if err != nil { 83 + log.Println("unable to determine followed at time") 84 + reaction.Created = time.Now() 85 + } else { 86 + reaction.Created = createdAtTime 87 + } 88 + 89 + return &reaction, nil 90 + } 91 + 92 + // Remove a reaction 93 + func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error { 94 + _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) 95 + return err 96 + } 97 + 98 + // Remove a reaction 99 + func DeleteReactionByRkey(e Execer, reactedByDid string, rkey string) error { 100 + _, err := e.Exec(`delete from reactions where reacted_by_did = ? and rkey = ?`, reactedByDid, rkey) 101 + return err 102 + } 103 + 104 + func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) { 105 + count := 0 106 + err := e.QueryRow( 107 + `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) 108 + if err != nil { 109 + return 0, err 110 + } 111 + return count, nil 112 + } 113 + 114 + func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) { 115 + like, err := GetReactionCount(e, threadAt, Like) 116 + if err != nil { 117 + return map[ReactionKind]int{}, nil 118 + } 119 + unlike, err := GetReactionCount(e, threadAt, Unlike) 120 + if err != nil { 121 + return map[ReactionKind]int{}, nil 122 + } 123 + laugh, err := GetReactionCount(e, threadAt, Laugh) 124 + if err != nil { 125 + return map[ReactionKind]int{}, nil 126 + } 127 + celebration, err := GetReactionCount(e, threadAt, Celebration) 128 + if err != nil { 129 + return map[ReactionKind]int{}, nil 130 + } 131 + confused, err := GetReactionCount(e, threadAt, Confused) 132 + if err != nil { 133 + return map[ReactionKind]int{}, nil 134 + } 135 + heart, err := GetReactionCount(e, threadAt, Heart) 136 + if err != nil { 137 + return map[ReactionKind]int{}, nil 138 + } 139 + rocket, err := GetReactionCount(e, threadAt, Rocket) 140 + if err != nil { 141 + return map[ReactionKind]int{}, nil 142 + } 143 + eyes, err := GetReactionCount(e, threadAt, Eyes) 144 + if err != nil { 145 + return map[ReactionKind]int{}, nil 146 + } 147 + return map[ReactionKind]int{ 148 + Like: like, 149 + Unlike: unlike, 150 + Laugh: laugh, 151 + Celebration: celebration, 152 + Confused: confused, 153 + Heart: heart, 154 + Rocket: rocket, 155 + Eyes: eyes, 156 + }, nil 157 + } 158 + 159 + func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool { 160 + if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 161 + return false 162 + } else { 163 + return true 164 + } 165 + } 166 + 167 + func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool { 168 + return map[ReactionKind]bool{ 169 + Like: GetReactionStatus(e, userDid, threadAt, Like), 170 + Unlike: GetReactionStatus(e, userDid, threadAt, Unlike), 171 + Laugh: GetReactionStatus(e, userDid, threadAt, Laugh), 172 + Celebration: GetReactionStatus(e, userDid, threadAt, Celebration), 173 + Confused: GetReactionStatus(e, userDid, threadAt, Confused), 174 + Heart: GetReactionStatus(e, userDid, threadAt, Heart), 175 + Rocket: GetReactionStatus(e, userDid, threadAt, Rocket), 176 + Eyes: GetReactionStatus(e, userDid, threadAt, Eyes), 177 + } 178 + }
+16
appview/issues/issues.go
··· 11 11 12 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 13 "github.com/bluesky-social/indigo/atproto/data" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 14 15 lexutil "github.com/bluesky-social/indigo/lex/util" 15 16 "github.com/go-chi/chi/v5" 16 17 "github.com/posthog/posthog-go" ··· 79 80 return 80 81 } 81 82 83 + reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt)) 84 + if err != nil { 85 + log.Println("failed to get issue reactions") 86 + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 87 + } 88 + 89 + userReactions := map[db.ReactionKind]bool{} 90 + if user != nil { 91 + userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt)) 92 + } 93 + 82 94 issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 83 95 if err != nil { 84 96 log.Println("failed to resolve issue owner", err) ··· 106 118 107 119 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 108 120 DidHandleMap: didHandleMap, 121 + 122 + OrderedReactionKinds: db.OrderedReactionKinds, 123 + Reactions: reactionCountMap, 124 + UserReacted: userReactions, 109 125 }) 110 126 111 127 }
+19
appview/pages/pages.go
··· 690 690 IssueOwnerHandle string 691 691 DidHandleMap map[string]string 692 692 693 + OrderedReactionKinds []db.ReactionKind 694 + Reactions map[db.ReactionKind]int 695 + UserReacted map[db.ReactionKind]bool 696 + 693 697 State string 694 698 } 695 699 700 + type ThreadReactionFragmentParams struct { 701 + ThreadAt syntax.ATURI 702 + Kind db.ReactionKind 703 + Count int 704 + IsReacted bool 705 + } 706 + 707 + func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error { 708 + return p.executePlain("repo/issues/fragments/reaction", w, params) 709 + } 710 + 696 711 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 697 712 params.Active = "issues" 698 713 if params.Issue.Open { ··· 797 812 AbandonedPulls []*db.Pull 798 813 MergeCheck types.MergeCheckResponse 799 814 ResubmitCheck ResubmitResult 815 + 816 + OrderedReactionKinds []db.ReactionKind 817 + Reactions map[db.ReactionKind]int 818 + UserReacted map[db.ReactionKind]bool 800 819 } 801 820 802 821 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
+1 -1
appview/pages/templates/layouts/repobase.html
··· 64 64 </div> 65 65 </nav> 66 66 <section 67 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full drop-shadow-sm dark:text-white" 67 + class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white" 68 68 > 69 69 {{ block "repoContent" . }}{{ end }} 70 70 </section>
+34
appview/pages/templates/repo/issues/fragments/reaction.html
··· 1 + {{ define "repo/issues/fragments/reaction" }} 2 + <button 3 + id="reactIndi-{{ .Kind }}" 4 + class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 + leading-4 px-3 gap-1 6 + {{ if eq .Count 0 }} 7 + hidden 8 + {{ end }} 9 + {{ if .IsReacted }} 10 + bg-sky-100 11 + border-sky-400 12 + dark:bg-sky-900 13 + dark:border-sky-500 14 + {{ else }} 15 + border-gray-200 16 + hover:bg-gray-50 17 + hover:border-gray-300 18 + dark:border-gray-700 19 + dark:hover:bg-gray-700 20 + dark:hover:border-gray-600 21 + {{ end }} 22 + " 23 + {{ if .IsReacted }} 24 + hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 + {{ else }} 26 + hx-post="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 27 + {{ end }} 28 + hx-swap="outerHTML" 29 + hx-trigger="click from:(#reactBtn-{{ .Kind }}, #reactIndi-{{ .Kind }})" 30 + hx-disabled-elt="this" 31 + > 32 + <span>{{ .Kind }}</span> <span>{{ .Count }}</span> 33 + </button> 34 + {{ end }}
+34
appview/pages/templates/repo/issues/issue.html
··· 46 46 {{ .Issue.Body | markdown }} 47 47 </article> 48 48 {{ end }} 49 + 50 + <div class="flex items-center gap-2 mt-2"> 51 + <details class="relative inline-block"> 52 + <summary 53 + class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700 54 + hover:bg-gray-50 55 + hover:border-gray-300 56 + dark:hover:bg-gray-700 57 + dark:hover:border-gray-600 58 + cursor-pointer list-none" 59 + > 60 + {{ i "smile" "size-4" }} 61 + </summary> 62 + <div 63 + 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" 64 + > 65 + {{ range $kind := .OrderedReactionKinds }} 66 + <button id="reactBtn-{{ $kind }}" class="size-12 dark:hover:bg-gray-700"> 67 + {{ $kind }} 68 + </button> 69 + {{ end }} 70 + </div> 71 + </details> 72 + {{ range $kind := .OrderedReactionKinds }} 73 + {{ 74 + template "repo/issues/fragments/reaction" 75 + (dict 76 + "Kind" $kind 77 + "Count" (index $.Reactions $kind) 78 + "IsReacted" (index $.UserReacted $kind) 79 + "ThreadAt" $.Issue.IssueAt) 80 + }} 81 + {{ end }} 82 + </div> 49 83 </section> 50 84 {{ end }} 51 85
+34
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 61 61 {{ .Pull.Body | markdown }} 62 62 </article> 63 63 {{ end }} 64 + 65 + <div class="flex items-center gap-2 mt-2"> 66 + <details class="relative inline-block"> 67 + <summary 68 + class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700 69 + hover:bg-gray-50 70 + hover:border-gray-300 71 + dark:hover:bg-gray-700 72 + dark:hover:border-gray-600 73 + cursor-pointer list-none" 74 + > 75 + {{ i "smile" "size-4" }} 76 + </summary> 77 + <div 78 + 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" 79 + > 80 + {{ range $kind := .OrderedReactionKinds }} 81 + <button id="reactBtn-{{ $kind }}" class="size-12 dark:hover:bg-gray-700"> 82 + {{ $kind }} 83 + </button> 84 + {{ end }} 85 + </div> 86 + </details> 87 + {{ range $kind := .OrderedReactionKinds }} 88 + {{ 89 + template "repo/issues/fragments/reaction" 90 + (dict 91 + "Kind" $kind 92 + "Count" (index $.Reactions $kind) 93 + "IsReacted" (index $.UserReacted $kind) 94 + "ThreadAt" $.Pull.PullAt) 95 + }} 96 + {{ end }} 97 + </div> 64 98 </section> 65 99 66 100
+15
appview/pulls/pulls.go
··· 167 167 resubmitResult = s.resubmitCheck(f, pull, stack) 168 168 } 169 169 170 + reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 171 + if err != nil { 172 + log.Println("failed to get pull reactions") 173 + s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 174 + } 175 + 176 + userReactions := map[db.ReactionKind]bool{} 177 + if user != nil { 178 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 179 + } 180 + 170 181 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 171 182 LoggedInUser: user, 172 183 RepoInfo: f.RepoInfo(user), ··· 176 187 AbandonedPulls: abandonedPulls, 177 188 MergeCheck: mergeCheckResponse, 178 189 ResubmitCheck: resubmitResult, 190 + 191 + OrderedReactionKinds: db.OrderedReactionKinds, 192 + Reactions: reactionCountMap, 193 + UserReacted: userReactions, 179 194 }) 180 195 } 181 196
+126
appview/state/reaction.go
··· 1 + package state 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "time" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + 11 + lexutil "github.com/bluesky-social/indigo/lex/util" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/appview" 14 + "tangled.sh/tangled.sh/core/appview/db" 15 + "tangled.sh/tangled.sh/core/appview/pages" 16 + ) 17 + 18 + func (s *State) React(w http.ResponseWriter, r *http.Request) { 19 + currentUser := s.oauth.GetUser(r) 20 + 21 + subject := r.URL.Query().Get("subject") 22 + if subject == "" { 23 + log.Println("invalid form") 24 + return 25 + } 26 + 27 + subjectUri, err := syntax.ParseATURI(subject) 28 + if err != nil { 29 + log.Println("invalid form") 30 + return 31 + } 32 + 33 + reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind")) 34 + if !ok { 35 + log.Println("invalid reaction kind") 36 + return 37 + } 38 + 39 + client, err := s.oauth.AuthorizedClient(r) 40 + if err != nil { 41 + log.Println("failed to authorize client", err) 42 + return 43 + } 44 + 45 + switch r.Method { 46 + case http.MethodPost: 47 + createdAt := time.Now().Format(time.RFC3339) 48 + rkey := appview.TID() 49 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + Collection: tangled.FeedReactionNSID, 51 + Repo: currentUser.Did, 52 + Rkey: rkey, 53 + Record: &lexutil.LexiconTypeDecoder{ 54 + Val: &tangled.FeedReaction{ 55 + Subject: subjectUri.String(), 56 + Reaction: reactionKind.String(), 57 + CreatedAt: createdAt, 58 + }, 59 + }, 60 + }) 61 + if err != nil { 62 + log.Println("failed to create atproto record", err) 63 + return 64 + } 65 + 66 + err = db.AddReaction(s.db, currentUser.Did, subjectUri, reactionKind, rkey) 67 + if err != nil { 68 + log.Println("failed to react", err) 69 + return 70 + } 71 + 72 + count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 73 + if err != nil { 74 + log.Println("failed to get reaction count for ", subjectUri) 75 + } 76 + 77 + log.Println("created atproto record: ", resp.Uri) 78 + 79 + s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 80 + ThreadAt: subjectUri, 81 + Kind: reactionKind, 82 + Count: count, 83 + IsReacted: true, 84 + }) 85 + 86 + return 87 + case http.MethodDelete: 88 + reaction, err := db.GetReaction(s.db, currentUser.Did, subjectUri, reactionKind) 89 + if err != nil { 90 + log.Println("failed to get reaction relationship for", currentUser.Did, subjectUri) 91 + return 92 + } 93 + 94 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + Collection: tangled.FeedReactionNSID, 96 + Repo: currentUser.Did, 97 + Rkey: reaction.Rkey, 98 + }) 99 + 100 + if err != nil { 101 + log.Println("failed to remove reaction") 102 + return 103 + } 104 + 105 + err = db.DeleteReactionByRkey(s.db, currentUser.Did, reaction.Rkey) 106 + if err != nil { 107 + log.Println("failed to delete reaction from DB") 108 + // this is not an issue, the firehose event might have already done this 109 + } 110 + 111 + count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 112 + if err != nil { 113 + log.Println("failed to get reaction count for ", subjectUri) 114 + return 115 + } 116 + 117 + s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 118 + ThreadAt: subjectUri, 119 + Kind: reactionKind, 120 + Count: count, 121 + IsReacted: false, 122 + }) 123 + 124 + return 125 + } 126 + }
+5
appview/state/router.go
··· 137 137 r.Delete("/", s.Star) 138 138 }) 139 139 140 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/react", func(r chi.Router) { 141 + r.Post("/", s.React) 142 + r.Delete("/", s.React) 143 + }) 144 + 140 145 r.Route("/profile", func(r chi.Router) { 141 146 r.Use(middleware.AuthMiddleware(s.oauth)) 142 147 r.Get("/edit-bio", s.EditBioFragment)
+1
cmd/gen.go
··· 15 15 "api/tangled/cbor_gen.go", 16 16 "tangled", 17 17 tangled.ActorProfile{}, 18 + tangled.FeedReaction{}, 18 19 tangled.FeedStar{}, 19 20 tangled.GitRefUpdate{}, 20 21 tangled.GitRefUpdate_Meta{},
+34
lexicons/feed/reaction.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.feed.reaction", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "reaction", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "reaction": { 23 + "type": "string", 24 + "enum": [ "👍", "👎", "😆", "🎉", "🫤", "❤️", "🚀", "👀" ] 25 + }, 26 + "createdAt": { 27 + "type": "string", 28 + "format": "datetime" 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }