diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go
index 00cd563f..ac74d47e 100644
--- a/api/tangled/cbor_gen.go
+++ b/api/tangled/cbor_gen.go
@@ -504,6 +504,204 @@ func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) {
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{164}); 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.Reaction (string) (string)
+ if len("reaction") > 1000000 {
+ return xerrors.Errorf("Value in field \"reaction\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("reaction"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("reaction")); err != nil {
+ return err
+ }
+
+ if len(t.Reaction) > 1000000 {
+ return xerrors.Errorf("Value in field t.Reaction was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Reaction))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(t.Reaction)); 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.Reaction (string) (string)
+ case "reaction":
+
+ {
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.Reaction = 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)
diff --git a/api/tangled/feedreaction.go b/api/tangled/feedreaction.go
new file mode 100644
index 00000000..df300b46
--- /dev/null
+++ b/api/tangled/feedreaction.go
@@ -0,0 +1,24 @@
+// 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"`
+}
diff --git a/appview/db/db.go b/appview/db/db.go
index b2f9dffd..df9382fd 100644
--- a/appview/db/db.go
+++ b/appview/db/db.go
@@ -199,6 +199,16 @@ func Make(dbPath string) (*DB, error) {
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,
diff --git a/appview/db/issues.go b/appview/db/issues.go
index fe5b441b..35647279 100644
--- a/appview/db/issues.go
+++ b/appview/db/issues.go
@@ -277,12 +277,12 @@ func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
}
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
}
diff --git a/appview/db/reaction.go b/appview/db/reaction.go
new file mode 100644
index 00000000..81b854ca
--- /dev/null
+++ b/appview/db/reaction.go
@@ -0,0 +1,141 @@
+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) {
+ countMap := map[ReactionKind]int{}
+ for _, kind := range OrderedReactionKinds {
+ count, err := GetReactionCount(e, threadAt, kind)
+ if err != nil {
+ return map[ReactionKind]int{}, nil
+ }
+ countMap[kind] = count
+ }
+ return countMap, 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 {
+ statusMap := map[ReactionKind]bool{}
+ for _, kind := range OrderedReactionKinds {
+ count := GetReactionStatus(e, userDid, threadAt, kind)
+ statusMap[kind] = count
+ }
+ return statusMap
+}
diff --git a/appview/issues/issues.go b/appview/issues/issues.go
index fb227d3d..4ecbd101 100644
--- a/appview/issues/issues.go
+++ b/appview/issues/issues.go
@@ -11,6 +11,7 @@ import (
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"
@@ -79,6 +80,17 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
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)
@@ -106,6 +118,10 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
DidHandleMap: didHandleMap,
+
+ OrderedReactionKinds: db.OrderedReactionKinds,
+ Reactions: reactionCountMap,
+ UserReacted: userReactions,
})
}
diff --git a/appview/pages/pages.go b/appview/pages/pages.go
index a7e73989..8922c046 100644
--- a/appview/pages/pages.go
+++ b/appview/pages/pages.go
@@ -690,9 +690,24 @@ type RepoSingleIssueParams struct {
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/fragments/reaction", w, params)
+}
+
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
params.Active = "issues"
if params.Issue.Open {
@@ -798,6 +813,10 @@ type RepoSinglePullParams struct {
MergeCheck types.MergeCheckResponse
ResubmitCheck ResubmitResult
Pipelines map[string]db.Pipeline
+
+ OrderedReactionKinds []db.ReactionKind
+ Reactions map[db.ReactionKind]int
+ UserReacted map[db.ReactionKind]bool
}
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
diff --git a/appview/pages/templates/layouts/repobase.html b/appview/pages/templates/layouts/repobase.html
index 940df809..a223495e 100644
--- a/appview/pages/templates/layouts/repobase.html
+++ b/appview/pages/templates/layouts/repobase.html
@@ -64,7 +64,7 @@
+ {{ i "smile" "size-4" }}
+
+