diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go index 77e06c31..3683e1f6 100644 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -661,6 +661,422 @@ func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) { return nil } +func (t *Comment) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 7 + + if t.Mentions == nil { + fieldCount-- + } + + if t.References == nil { + fieldCount-- + } + + if t.ReplyTo == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.Body (string) (string) + if len("body") > 1000000 { + return xerrors.Errorf("Value in field \"body\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { + return err + } + if _, err := cw.WriteString(string("body")); err != nil { + return err + } + + if len(t.Body) > 1000000 { + return xerrors.Errorf("Value in field t.Body was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Body)); 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.comment"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sh.tangled.comment")); err != nil { + return err + } + + // t.ReplyTo (string) (string) + if t.ReplyTo != nil { + + if len("replyTo") > 1000000 { + return xerrors.Errorf("Value in field \"replyTo\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { + return err + } + if _, err := cw.WriteString(string("replyTo")); err != nil { + return err + } + + if t.ReplyTo == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.ReplyTo) > 1000000 { + return xerrors.Errorf("Value in field t.ReplyTo was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.ReplyTo)); 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.Mentions ([]string) (slice) + if t.Mentions != nil { + + if len("mentions") > 1000000 { + return xerrors.Errorf("Value in field \"mentions\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { + return err + } + if _, err := cw.WriteString(string("mentions")); err != nil { + return err + } + + if len(t.Mentions) > 8192 { + return xerrors.Errorf("Slice value in field t.Mentions was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { + return err + } + for _, v := range t.Mentions { + if len(v) > 1000000 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); 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 + } + + // t.References ([]string) (slice) + if t.References != nil { + + if len("references") > 1000000 { + return xerrors.Errorf("Value in field \"references\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { + return err + } + if _, err := cw.WriteString(string("references")); err != nil { + return err + } + + if len(t.References) > 8192 { + return xerrors.Errorf("Slice value in field t.References was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { + return err + } + for _, v := range t.References { + if len(v) > 1000000 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + } + return nil +} + +func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) { + *t = Comment{} + + 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("Comment: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 10) + 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.Body (string) (string) + case "body": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Body = string(sval) + } + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.ReplyTo (string) (string) + case "replyTo": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.ReplyTo = (*string)(&sval) + } + } + // t.Subject (string) (string) + case "subject": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Subject = string(sval) + } + // t.Mentions ([]string) (slice) + case "mentions": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Mentions: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Mentions = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Mentions[i] = string(sval) + } + + } + } + // t.CreatedAt (string) (string) + case "createdAt": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.CreatedAt = string(sval) + } + // t.References ([]string) (slice) + case "references": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.References: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.References = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.References[i] = 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 *FeedReaction) MarshalCBOR(w io.Writer) error { if t == nil { _, err := w.Write(cbg.CborNull) diff --git a/api/tangled/tangledcomment.go b/api/tangled/tangledcomment.go new file mode 100644 index 00000000..5b759e23 --- /dev/null +++ b/api/tangled/tangledcomment.go @@ -0,0 +1,27 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.comment + +import ( + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + CommentNSID = "sh.tangled.comment" +) + +func init() { + util.RegisterType("sh.tangled.comment", &Comment{}) +} // +// RECORDTYPE: Comment +type Comment struct { + LexiconTypeID string `json:"$type,const=sh.tangled.comment" cborgen:"$type,const=sh.tangled.comment"` + Body string `json:"body" cborgen:"body"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` + References []string `json:"references,omitempty" cborgen:"references,omitempty"` + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` + Subject string `json:"subject" cborgen:"subject"` +} diff --git a/cmd/cborgen/cborgen.go b/cmd/cborgen/cborgen.go index 33641e4e..844585bb 100644 --- a/cmd/cborgen/cborgen.go +++ b/cmd/cborgen/cborgen.go @@ -15,6 +15,7 @@ func main() { "api/tangled/cbor_gen.go", "tangled", tangled.ActorProfile{}, + tangled.Comment{}, tangled.FeedReaction{}, tangled.FeedStar{}, tangled.GitRefUpdate{}, diff --git a/lexicons/comment/comment.json b/lexicons/comment/comment.json new file mode 100644 index 00000000..f1e47837 --- /dev/null +++ b/lexicons/comment/comment.json @@ -0,0 +1,51 @@ +{ + "lexicon": 1, + "id": "sh.tangled.comment", + "needsCbor": true, + "needsType": true, + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "subject", + "body", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri" + }, + "body": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "replyTo": { + "type": "string", + "format": "at-uri" + }, + "mentions": { + "type": "array", + "items": { + "type": "string", + "format": "did" + } + }, + "references": { + "type": "array", + "items": { + "type": "string", + "format": "at-uri" + } + } + } + } + } + } +} -- 2.51.2 From a96e99fbb7d03747dbae5d9403397153659d7464 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Mon, 23 Mar 2026 20:26:27 +0900 Subject: [PATCH 02/10] lexicons: `com.atproto.repo.strongRef` and `sh.tangled.markup.markdown` use `com.atproto.repo.strongRef` for more explicit reference & `markup.markdown` type to give clear semantic meaning in markdown Close: Signed-off-by: Seongmin Lee --- api/tangled/cbor_gen.go | 555 +++++++++++++++-------- api/tangled/feedcomment.go | 88 ++++ api/tangled/markupmarkdown.go | 29 ++ api/tangled/tangledcomment.go | 27 -- cmd/cborgen/cborgen.go | 3 +- lexicon-build-config.json | 6 + lexicons/com/atproto/repo/strongRef.json | 15 + lexicons/{comment => feed}/comment.json | 30 +- lexicons/markup/markdown.json | 30 ++ 9 files changed, 545 insertions(+), 238 deletions(-) create mode 100644 api/tangled/feedcomment.go create mode 100644 api/tangled/markupmarkdown.go delete mode 100644 api/tangled/tangledcomment.go create mode 100644 lexicons/com/atproto/repo/strongRef.json rename lexicons/{comment => feed}/comment.json (51%) create mode 100644 lexicons/markup/markdown.json diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go index 3683e1f6..de3338cf 100644 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -8,6 +8,7 @@ import ( "math" "sort" + atproto "github.com/bluesky-social/indigo/api/atproto" util "github.com/bluesky-social/indigo/lex/util" cid "github.com/ipfs/go-cid" cbg "github.com/whyrusleeping/cbor-gen" @@ -661,20 +662,16 @@ func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) { return nil } -func (t *Comment) MarshalCBOR(w io.Writer) error { +func (t *FeedComment) MarshalCBOR(w io.Writer) error { if t == nil { _, err := w.Write(cbg.CborNull) return err } cw := cbg.NewCborWriter(w) - fieldCount := 7 - - if t.Mentions == nil { - fieldCount-- - } + fieldCount := 6 - if t.References == nil { + if t.PullRoundIdx == nil { fieldCount-- } @@ -686,7 +683,7 @@ func (t *Comment) MarshalCBOR(w io.Writer) error { return err } - // t.Body (string) (string) + // t.Body (tangled.FeedComment_Body) (struct) if len("body") > 1000000 { return xerrors.Errorf("Value in field \"body\" was too long") } @@ -698,14 +695,7 @@ func (t *Comment) MarshalCBOR(w io.Writer) error { return err } - if len(t.Body) > 1000000 { - return xerrors.Errorf("Value in field t.Body was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil { - return err - } - if _, err := cw.WriteString(string(t.Body)); err != nil { + if err := t.Body.MarshalCBOR(cw); err != nil { return err } @@ -721,14 +711,14 @@ func (t *Comment) MarshalCBOR(w io.Writer) error { return err } - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.comment"))); err != nil { + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.feed.comment"))); err != nil { return err } - if _, err := cw.WriteString(string("sh.tangled.comment")); err != nil { + if _, err := cw.WriteString(string("sh.tangled.feed.comment")); err != nil { return err } - // t.ReplyTo (string) (string) + // t.ReplyTo (atproto.RepoStrongRef) (struct) if t.ReplyTo != nil { if len("replyTo") > 1000000 { @@ -742,25 +732,12 @@ func (t *Comment) MarshalCBOR(w io.Writer) error { return err } - if t.ReplyTo == nil { - if _, err := cw.Write(cbg.CborNull); err != nil { - return err - } - } else { - if len(*t.ReplyTo) > 1000000 { - return xerrors.Errorf("Value in field t.ReplyTo was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { - return err - } - if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { - return err - } + if err := t.ReplyTo.MarshalCBOR(cw); err != nil { + return err } } - // t.Subject (string) (string) + // t.Subject (atproto.RepoStrongRef) (struct) if len("subject") > 1000000 { return xerrors.Errorf("Value in field \"subject\" was too long") } @@ -772,53 +749,10 @@ func (t *Comment) MarshalCBOR(w io.Writer) error { 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 { + if err := t.Subject.MarshalCBOR(cw); err != nil { return err } - // t.Mentions ([]string) (slice) - if t.Mentions != nil { - - if len("mentions") > 1000000 { - return xerrors.Errorf("Value in field \"mentions\" was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { - return err - } - if _, err := cw.WriteString(string("mentions")); err != nil { - return err - } - - if len(t.Mentions) > 8192 { - return xerrors.Errorf("Slice value in field t.Mentions was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { - return err - } - for _, v := range t.Mentions { - if len(v) > 1000000 { - return xerrors.Errorf("Value in field v was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { - return err - } - if _, err := cw.WriteString(string(v)); err != nil { - return err - } - - } - } - // t.CreatedAt (string) (string) if len("createdAt") > 1000000 { return xerrors.Errorf("Value in field \"createdAt\" was too long") @@ -842,46 +776,42 @@ func (t *Comment) MarshalCBOR(w io.Writer) error { return err } - // t.References ([]string) (slice) - if t.References != nil { + // t.PullRoundIdx (int64) (int64) + if t.PullRoundIdx != nil { - if len("references") > 1000000 { - return xerrors.Errorf("Value in field \"references\" was too long") + if len("pullRoundIdx") > 1000000 { + return xerrors.Errorf("Value in field \"pullRoundIdx\" was too long") } - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pullRoundIdx"))); err != nil { return err } - if _, err := cw.WriteString(string("references")); err != nil { + if _, err := cw.WriteString(string("pullRoundIdx")); err != nil { return err } - if len(t.References) > 8192 { - return xerrors.Errorf("Slice value in field t.References was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { - return err - } - for _, v := range t.References { - if len(v) > 1000000 { - return xerrors.Errorf("Value in field v was too long") - } - - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + if t.PullRoundIdx == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { return err } - if _, err := cw.WriteString(string(v)); err != nil { - return err + } else { + if *t.PullRoundIdx >= 0 { + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.PullRoundIdx)); err != nil { + return err + } + } else { + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.PullRoundIdx-1)); err != nil { + return err + } } - } + } return nil } -func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) { - *t = Comment{} +func (t *FeedComment) UnmarshalCBOR(r io.Reader) (err error) { + *t = FeedComment{} cr := cbg.NewCborReader(r) @@ -900,12 +830,12 @@ func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) { } if extra > cbg.MaxLength { - return fmt.Errorf("Comment: map struct too large (%d)", extra) + return fmt.Errorf("FeedComment: map struct too large (%d)", extra) } n := extra - nameBuf := make([]byte, 10) + nameBuf := make([]byte, 12) for i := uint64(0); i < n; i++ { nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) if err != nil { @@ -921,16 +851,25 @@ func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) { } switch string(nameBuf[:nameLen]) { - // t.Body (string) (string) + // t.Body (tangled.FeedComment_Body) (struct) case "body": { - sval, err := cbg.ReadStringWithMax(cr, 1000000) + + b, err := cr.ReadByte() if err != nil { return err } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Body = new(FeedComment_Body) + if err := t.Body.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Body pointer: %w", err) + } + } - t.Body = string(sval) } // t.LexiconTypeID (string) (string) case "$type": @@ -943,10 +882,11 @@ func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) { t.LexiconTypeID = string(sval) } - // t.ReplyTo (string) (string) + // t.ReplyTo (atproto.RepoStrongRef) (struct) case "replyTo": { + b, err := cr.ReadByte() if err != nil { return err @@ -955,65 +895,32 @@ func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) { if err := cr.UnreadByte(); err != nil { return err } - - sval, err := cbg.ReadStringWithMax(cr, 1000000) - if err != nil { - return err + t.ReplyTo = new(atproto.RepoStrongRef) + if err := t.ReplyTo.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.ReplyTo pointer: %w", err) } - - t.ReplyTo = (*string)(&sval) } + } - // t.Subject (string) (string) + // t.Subject (atproto.RepoStrongRef) (struct) case "subject": { - sval, err := cbg.ReadStringWithMax(cr, 1000000) + + b, err := cr.ReadByte() if err != nil { return err } - - t.Subject = string(sval) - } - // t.Mentions ([]string) (slice) - case "mentions": - - maj, extra, err = cr.ReadHeader() - if err != nil { - return err - } - - if extra > 8192 { - return fmt.Errorf("t.Mentions: array too large (%d)", extra) - } - - if maj != cbg.MajArray { - return fmt.Errorf("expected cbor array") - } - - if extra > 0 { - t.Mentions = make([]string, extra) - } - - for i := 0; i < int(extra); i++ { - { - var maj byte - var extra uint64 - var err error - _ = maj - _ = extra - _ = err - - { - sval, err := cbg.ReadStringWithMax(cr, 1000000) - if err != nil { - return err - } - - t.Mentions[i] = string(sval) + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Subject = new(atproto.RepoStrongRef) + if err := t.Subject.UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Subject pointer: %w", err) } - } + } // t.CreatedAt (string) (string) case "createdAt": @@ -1026,44 +933,40 @@ func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) { t.CreatedAt = string(sval) } - // t.References ([]string) (slice) - case "references": - - maj, extra, err = cr.ReadHeader() - if err != nil { - return err - } - - if extra > 8192 { - return fmt.Errorf("t.References: array too large (%d)", extra) - } - - if maj != cbg.MajArray { - return fmt.Errorf("expected cbor array") - } - - if extra > 0 { - t.References = make([]string, extra) - } - - for i := 0; i < int(extra); i++ { - { - var maj byte - var extra uint64 - var err error - _ = maj - _ = extra - _ = err + // t.PullRoundIdx (int64) (int64) + case "pullRoundIdx": + { - { - sval, err := cbg.ReadStringWithMax(cr, 1000000) - if err != nil { - return err + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + var extraI int64 + switch maj { + case cbg.MajUnsignedInt: + extraI = int64(extra) + if extraI < 0 { + return fmt.Errorf("int64 positive overflow") } - - t.References[i] = string(sval) + case cbg.MajNegativeInt: + extraI = int64(extra) + if extraI < 0 { + return fmt.Errorf("int64 negative overflow") + } + extraI = -1 - extraI + default: + return fmt.Errorf("wrong type for int64 field: %d", maj) } + t.PullRoundIdx = (*int64)(&extraI) } } @@ -4260,6 +4163,276 @@ func (t *LabelOp_Operand) UnmarshalCBOR(r io.Reader) (err error) { return nil } +func (t *MarkupMarkdown) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 4 + + if t.Blobs == nil { + fieldCount-- + } + + if t.Original == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.Text (string) (string) + if len("text") > 1000000 { + return xerrors.Errorf("Value in field \"text\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("text"))); err != nil { + return err + } + if _, err := cw.WriteString(string("text")); err != nil { + return err + } + + if len(t.Text) > 1000000 { + return xerrors.Errorf("Value in field t.Text was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Text))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Text)); 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.markup.markdown"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sh.tangled.markup.markdown")); err != nil { + return err + } + + // t.Blobs ([]*util.LexBlob) (slice) + if t.Blobs != nil { + + if len("blobs") > 1000000 { + return xerrors.Errorf("Value in field \"blobs\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("blobs"))); err != nil { + return err + } + if _, err := cw.WriteString(string("blobs")); err != nil { + return err + } + + if len(t.Blobs) > 8192 { + return xerrors.Errorf("Slice value in field t.Blobs was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Blobs))); err != nil { + return err + } + for _, v := range t.Blobs { + if err := v.MarshalCBOR(cw); err != nil { + return err + } + + } + } + + // t.Original (string) (string) + if t.Original != nil { + + if len("original") > 1000000 { + return xerrors.Errorf("Value in field \"original\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("original"))); err != nil { + return err + } + if _, err := cw.WriteString(string("original")); err != nil { + return err + } + + if t.Original == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Original) > 1000000 { + return xerrors.Errorf("Value in field t.Original was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Original))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Original)); err != nil { + return err + } + } + } + return nil +} + +func (t *MarkupMarkdown) UnmarshalCBOR(r io.Reader) (err error) { + *t = MarkupMarkdown{} + + 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("MarkupMarkdown: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 8) + 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.Text (string) (string) + case "text": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Text = string(sval) + } + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.Blobs ([]*util.LexBlob) (slice) + case "blobs": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Blobs: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Blobs = make([]*util.LexBlob, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + t.Blobs[i] = new(util.LexBlob) + if err := t.Blobs[i].UnmarshalCBOR(cr); err != nil { + return xerrors.Errorf("unmarshaling t.Blobs[i] pointer: %w", err) + } + } + + } + + } + } + // t.Original (string) (string) + case "original": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Original = (*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 *Pipeline) MarshalCBOR(w io.Writer) error { if t == nil { _, err := w.Write(cbg.CborNull) diff --git a/api/tangled/feedcomment.go b/api/tangled/feedcomment.go new file mode 100644 index 00000000..d535ec6d --- /dev/null +++ b/api/tangled/feedcomment.go @@ -0,0 +1,88 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.feed.comment + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + + comatprototypes "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/lex/util" + cbg "github.com/whyrusleeping/cbor-gen" +) + +const ( + FeedCommentNSID = "sh.tangled.feed.comment" +) + +func init() { + util.RegisterType("sh.tangled.feed.comment", &FeedComment{}) +} // +// RECORDTYPE: FeedComment +type FeedComment struct { + LexiconTypeID string `json:"$type,const=sh.tangled.feed.comment" cborgen:"$type,const=sh.tangled.feed.comment"` + Body *FeedComment_Body `json:"body" cborgen:"body"` + CreatedAt string `json:"createdAt" cborgen:"createdAt"` + // pullRoundIdx: optional pull submission round index. required when subject is sh.tangled.repo.pull + PullRoundIdx *int64 `json:"pullRoundIdx,omitempty" cborgen:"pullRoundIdx,omitempty"` + ReplyTo *comatprototypes.RepoStrongRef `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` + Subject *comatprototypes.RepoStrongRef `json:"subject" cborgen:"subject"` +} + +type FeedComment_Body struct { + MarkupMarkdown *MarkupMarkdown +} + +func (t *FeedComment_Body) MarshalJSON() ([]byte, error) { + if t.MarkupMarkdown != nil { + t.MarkupMarkdown.LexiconTypeID = "sh.tangled.markup.markdown" + return json.Marshal(t.MarkupMarkdown) + } + return nil, fmt.Errorf("cannot marshal empty enum") +} +func (t *FeedComment_Body) UnmarshalJSON(b []byte) error { + typ, err := util.TypeExtract(b) + if err != nil { + return err + } + + switch typ { + case "sh.tangled.markup.markdown": + t.MarkupMarkdown = new(MarkupMarkdown) + return json.Unmarshal(b, t.MarkupMarkdown) + + default: + return nil + } +} + +func (t *FeedComment_Body) MarshalCBOR(w io.Writer) error { + + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + if t.MarkupMarkdown != nil { + return t.MarkupMarkdown.MarshalCBOR(w) + } + return fmt.Errorf("cannot cbor marshal empty enum") +} +func (t *FeedComment_Body) UnmarshalCBOR(r io.Reader) error { + typ, b, err := util.CborTypeExtractReader(r) + if err != nil { + return err + } + + switch typ { + case "sh.tangled.markup.markdown": + t.MarkupMarkdown = new(MarkupMarkdown) + return t.MarkupMarkdown.UnmarshalCBOR(bytes.NewReader(b)) + + default: + return nil + } +} diff --git a/api/tangled/markupmarkdown.go b/api/tangled/markupmarkdown.go new file mode 100644 index 00000000..d4db789a --- /dev/null +++ b/api/tangled/markupmarkdown.go @@ -0,0 +1,29 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.markup.markdown + +import ( + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + MarkupMarkdownNSID = "sh.tangled.markup.markdown" +) + +func init() { + util.RegisterType("sh.tangled.markup.markdown#main", &MarkupMarkdown{}) +} // MarkupMarkdown is a "main" in the sh.tangled.markup.markdown schema. +// Tangled Flavored Markdown format text +// +// RECORDTYPE: MarkupMarkdown +type MarkupMarkdown struct { + LexiconTypeID string `json:"$type,const=sh.tangled.markup.markdown" cborgen:"$type,const=sh.tangled.markup.markdown"` + // blobs: list of blobs referenced in markdown + Blobs []*util.LexBlob `json:"blobs,omitempty" cborgen:"blobs,omitempty"` + // original: Original Markdown before post-processing. Used to restore original input on edit. + Original *string `json:"original,omitempty" cborgen:"original,omitempty"` + // text: Final post-processed markdown content that will be rendered + Text string `json:"text" cborgen:"text"` +} diff --git a/api/tangled/tangledcomment.go b/api/tangled/tangledcomment.go deleted file mode 100644 index 5b759e23..00000000 --- a/api/tangled/tangledcomment.go +++ /dev/null @@ -1,27 +0,0 @@ -// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. - -package tangled - -// schema: sh.tangled.comment - -import ( - "github.com/bluesky-social/indigo/lex/util" -) - -const ( - CommentNSID = "sh.tangled.comment" -) - -func init() { - util.RegisterType("sh.tangled.comment", &Comment{}) -} // -// RECORDTYPE: Comment -type Comment struct { - LexiconTypeID string `json:"$type,const=sh.tangled.comment" cborgen:"$type,const=sh.tangled.comment"` - Body string `json:"body" cborgen:"body"` - CreatedAt string `json:"createdAt" cborgen:"createdAt"` - Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` - References []string `json:"references,omitempty" cborgen:"references,omitempty"` - ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` - Subject string `json:"subject" cborgen:"subject"` -} diff --git a/cmd/cborgen/cborgen.go b/cmd/cborgen/cborgen.go index 844585bb..b2e80c17 100644 --- a/cmd/cborgen/cborgen.go +++ b/cmd/cborgen/cborgen.go @@ -15,7 +15,7 @@ func main() { "api/tangled/cbor_gen.go", "tangled", tangled.ActorProfile{}, - tangled.Comment{}, + tangled.FeedComment{}, tangled.FeedReaction{}, tangled.FeedStar{}, tangled.GitRefUpdate{}, @@ -31,6 +31,7 @@ func main() { tangled.LabelDefinition_ValueType{}, tangled.LabelOp{}, tangled.LabelOp_Operand{}, + tangled.MarkupMarkdown{}, tangled.Pipeline{}, tangled.Pipeline_CloneOpts{}, tangled.Pipeline_ManualTriggerData{}, diff --git a/lexicon-build-config.json b/lexicon-build-config.json index 47f1fea4..2b02573a 100644 --- a/lexicon-build-config.json +++ b/lexicon-build-config.json @@ -1,4 +1,10 @@ [ + { + "package": "atproto", + "prefix": "com.atproto", + "outdir": "/tmp/atproto", + "import": "github.com/bluesky-social/indigo/api/atproto" + }, { "package": "tangled", "prefix": "sh.tangled", diff --git a/lexicons/com/atproto/repo/strongRef.json b/lexicons/com/atproto/repo/strongRef.json new file mode 100644 index 00000000..cb796251 --- /dev/null +++ b/lexicons/com/atproto/repo/strongRef.json @@ -0,0 +1,15 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.strongRef", + "description": "A URI with a content-hash fingerprint.", + "defs": { + "main": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" } + } + } + } +} diff --git a/lexicons/comment/comment.json b/lexicons/feed/comment.json similarity index 51% rename from lexicons/comment/comment.json rename to lexicons/feed/comment.json index f1e47837..8af9e31e 100644 --- a/lexicons/comment/comment.json +++ b/lexicons/feed/comment.json @@ -1,6 +1,6 @@ { "lexicon": 1, - "id": "sh.tangled.comment", + "id": "sh.tangled.feed.comment", "needsCbor": true, "needsType": true, "defs": { @@ -16,33 +16,25 @@ ], "properties": { "subject": { - "type": "string", - "format": "at-uri" + "type": "ref", + "ref": "com.atproto.repo.strongRef" }, "body": { - "type": "string" + "type": "union", + "refs": ["sh.tangled.markup.markdown"] }, "createdAt": { "type": "string", "format": "datetime" }, "replyTo": { - "type": "string", - "format": "at-uri" - }, - "mentions": { - "type": "array", - "items": { - "type": "string", - "format": "did" - } + "type": "ref", + "ref": "com.atproto.repo.strongRef" }, - "references": { - "type": "array", - "items": { - "type": "string", - "format": "at-uri" - } + "pullRoundIdx": { + "type": "integer", + "minimum": 0, + "description": "optional pull submission round index. required when subject is sh.tangled.repo.pull" } } } diff --git a/lexicons/markup/markdown.json b/lexicons/markup/markdown.json new file mode 100644 index 00000000..fcf86963 --- /dev/null +++ b/lexicons/markup/markdown.json @@ -0,0 +1,30 @@ +{ + "lexicon": 1, + "id": "sh.tangled.markup.markdown", + "defs": { + "main": { + "type": "object", + "required": ["text"], + "description": "Tangled Flavored Markdown format text", + "properties": { + "text": { + "type": "string", + "description": "Final post-processed markdown content that will be rendered" + }, + "original": { + "type": "string", + "description": "Original Markdown before post-processing. Used to restore original input on edit." + }, + "blobs": { + "type": "array", + "items": { + "type": "blob", + "accept": ["image/*"], + "maxSize": 1000000 + }, + "description": "list of blobs referenced in markdown" + } + } + } + } +} -- 2.51.2 From dc838cf1de8bc2972fc89e860e6cc758b3e24f37 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 14 Nov 2025 13:24:53 +0900 Subject: [PATCH 03/10] appview: replace `PullComment` to `Comment` Including db migration to migrate `issue_comments` and `pull_comments` to unified `comments` table. Signed-off-by: Seongmin Lee --- appview/db/comments.go | 268 +++++++++++++++++++ appview/db/db.go | 96 +++++++ appview/db/pulls.go | 147 ++-------- appview/db/reference.go | 15 +- appview/ingester.go | 114 +++++++- appview/models/comment.go | 147 ++++++++++ appview/models/pull.go | 30 +-- appview/notify/db/db.go | 20 +- appview/notify/logging/notifier.go | 2 +- appview/notify/merged_notifier.go | 2 +- appview/notify/notifier.go | 4 +- appview/notify/posthog/notifier.go | 7 +- appview/oauth/scopes.go | 1 + appview/pages/templates/repo/pulls/pull.html | 10 +- appview/pulls/pulls.go | 109 +++++--- appview/state/state.go | 30 ++- 16 files changed, 762 insertions(+), 240 deletions(-) create mode 100644 appview/db/comments.go create mode 100644 appview/models/comment.go diff --git a/appview/db/comments.go b/appview/db/comments.go new file mode 100644 index 00000000..5f8e9cc7 --- /dev/null +++ b/appview/db/comments.go @@ -0,0 +1,268 @@ +package db + +import ( + "database/sql" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/models" + "tangled.org/core/orm" +) + +func PutComment(tx *sql.Tx, c *models.Comment, references []syntax.ATURI) error { + if c.Collection == "" { + c.Collection = tangled.FeedCommentNSID + } + + var bodyBlobs, replyToUri, replyToCid *string + if len(c.Body.Blobs) > 0 { + encoded, err := json.Marshal(c.Body.Blobs) + if err != nil { + return fmt.Errorf("encoding blobs to json: %w", err) + } + encodedStr := string(encoded) + bodyBlobs = &encodedStr + } + if c.ReplyTo != nil { + replyToUri = &c.ReplyTo.Uri + replyToCid = &c.ReplyTo.Cid + } + result, err := tx.Exec( + // users can change the 'created' date. + // skip update entirely if cid is unchanged. + `insert into comments ( + did, + collection, + rkey, + cid, + subject_uri, + subject_cid, + body_text, + body_original, + body_blobs, + created, + reply_to_uri, + reply_to_cid, + pull_round_idx + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(did, collection, rkey) + do update set + cid = excluded.cid, + subject_uri = excluded.subject_uri, + subject_cid = excluded.subject_cid, + body_text = excluded.body_text, + body_original = excluded.body_original, + body_blobs = excluded.body_blobs, + created = excluded.created, + reply_to_uri = excluded.reply_to_uri, + reply_to_cid = excluded.reply_to_cid, + pull_round_idx = excluded.pull_round_idx, + edited = ? + where comments.cid != excluded.cid`, + c.Did, + c.Collection, + c.Rkey, + c.Cid, + c.Subject.Uri, + c.Subject.Cid, + c.Body.Text, + c.Body.Original, + bodyBlobs, + c.Created.Format(time.RFC3339), + replyToUri, + replyToCid, + c.PullRoundIdx, + time.Now().Format(time.RFC3339), + ) + if err != nil { + return err + } + + c.Id, err = result.LastInsertId() + if err != nil { + return err + } + + affected, err := result.RowsAffected() + if err != nil { + return err + } + + if affected > 0 { + // update references when comment is updated + if err := putReferences(tx, c.AtUri(), references); err != nil { + return fmt.Errorf("put reference_links: %w", err) + } + } + + return nil +} + +// PurgeComments actually purges a comment row from db instead of marking it as "deleted" +func PurgeComments(e Execer, filters ...orm.Filter) error { + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + + _, err := e.Exec(fmt.Sprintf(`delete from comments %s`, whereClause), args...) + return err +} + +func DeleteComments(e Execer, filters ...orm.Filter) error { + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + + query := fmt.Sprintf( + `update comments + set body_text = "", + body_original = null, + body_blobs = null, + deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') + %s`, + whereClause, + ) + + _, err := e.Exec(query, args...) + return err +} + +func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { + var comments []models.Comment + + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + + query := fmt.Sprintf(` + select + id, + did, + collection, + rkey, + cid, + subject_uri, + subject_cid, + body_text, + body_original, + body_blobs, + created, + reply_to_uri, + reply_to_cid, + pull_round_idx, + edited, + deleted + from + comments + %s + `, whereClause) + + rows, err := e.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var comment models.Comment + var created string + var cid, bodyBlobs, replyToUri, replyToCid, edited, deleted sql.Null[string] + err := rows.Scan( + &comment.Id, + &comment.Did, + &comment.Collection, + &comment.Rkey, + &cid, + &comment.Subject.Uri, + &comment.Subject.Cid, + &comment.Body.Text, + &comment.Body.Original, + &bodyBlobs, + &created, + &replyToUri, + &replyToCid, + &comment.PullRoundIdx, + &edited, + &deleted, + ) + if err != nil { + return nil, err + } + + if cid.Valid && cid.V != "" { + comment.Cid = syntax.CID(cid.V) + } + + if bodyBlobs.Valid && bodyBlobs.V != "" { + if err := json.Unmarshal([]byte(bodyBlobs.V), &comment.Body.Blobs); err != nil { + return nil, fmt.Errorf("decoding blobs: %w", err) + } + } + + if t, err := time.Parse(time.RFC3339, created); err == nil { + comment.Created = t + } + + if replyToUri.Valid && replyToCid.Valid { + comment.ReplyTo = &atproto.RepoStrongRef{ + Uri: replyToUri.V, + Cid: replyToCid.V, + } + } + + if edited.Valid { + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { + comment.Edited = &t + } + } + + if deleted.Valid { + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { + comment.Deleted = &t + } + } + + comments = append(comments, comment) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + sort.Slice(comments, func(i, j int) bool { + return comments[i].Created.Before(comments[j].Created) + }) + + return comments, nil +} diff --git a/appview/db/db.go b/appview/db/db.go index 91421c62..455ac86b 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -1409,6 +1409,102 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { return err }) + orm.RunMigration(conn, logger, "add-comments-table", func(tx *sql.Tx) error { + _, err := tx.Exec(` + drop table if exists comments; + + create table comments ( + -- identifiers + id integer primary key autoincrement, + + did text not null, + collection text not null default 'sh.tangled.feed.comment', + rkey text not null, + at_uri text generated always as ('at://' || did || '/' || collection || '/' || rkey) stored, + cid text, + + -- content + subject_uri text not null, -- at_uri of subject (issue, pr, string) + subject_cid text not null, -- cid of subject + + body_text text not null, + body_original text, + body_blobs text, -- json + + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + + reply_to_uri text, -- at_uri of parent comment + reply_to_cid text, -- cid of parent comment + + pull_round_idx integer, -- pull round index. required when subject is sh.tangled.repo.pull + + -- appview-local information + edited text, + deleted text, + + unique(did, collection, rkey) + ); + + insert into comments ( + did, + collection, + rkey, + subject_uri, + subject_cid, -- we need to know cid + body_text, + created, + reply_to_uri, + reply_to_cid, -- we need to know cid + edited, + deleted + ) + select + did, + 'sh.tangled.repo.issue.comment', + rkey, + issue_at, + '', + body, + created, + reply_to, + '', + edited, + deleted + from issue_comments + where rkey is not null; + + insert into comments ( + did, + collection, + rkey, + subject_uri, + subject_cid, -- we need to know cid + body_text, + created, + pull_round_idx + ) + select + c.owner_did, + 'sh.tangled.repo.pull.comment', + substr( + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey + instr( + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey + '/' + ) + 1 + ), -- rkey + p.at_uri, + '', + c.body, + c.created, + s.round_number + from pull_comments c + join pulls p on c.repo_at = p.repo_at and c.pull_id = p.pull_id + join pull_submissions s on s.id = c.submission_id; + `) + return err + }) + return &DB{ db, logger, diff --git a/appview/db/pulls.go b/appview/db/pulls.go index e4338390..12964b24 100644 --- a/appview/db/pulls.go +++ b/appview/db/pulls.go @@ -524,7 +524,7 @@ func GetPullSubmissions(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]*mo } defer rows.Close() - submissionMap := make(map[int]*models.PullSubmission) + pullMap := make(map[syntax.ATURI][]*models.PullSubmission) for rows.Next() { var submission models.PullSubmission @@ -572,129 +572,39 @@ func GetPullSubmissions(e Execer, filters ...orm.Filter) (map[syntax.ATURI][]*mo submission.Blob.Size = patchBlobSize.V } - submissionMap[submission.ID] = &submission + pullMap[submission.PullAt] = append(pullMap[submission.PullAt], &submission) } if err := rows.Err(); err != nil { return nil, err } - // Get comments for all submissions using GetPullComments - submissionIds := slices.Collect(maps.Keys(submissionMap)) - comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) + // Get comments for all submissions using GetComments + pullAts := slices.Collect(maps.Keys(pullMap)) + comments, err := GetComments(e, orm.FilterIn("subject_uri", pullAts)) if err != nil { return nil, fmt.Errorf("failed to get pull comments: %w", err) } for _, comment := range comments { - if submission, ok := submissionMap[comment.SubmissionId]; ok { - submission.Comments = append(submission.Comments, comment) + if comment.PullRoundIdx != nil { + roundIdx := *comment.PullRoundIdx + if submissions, ok := pullMap[syntax.ATURI(comment.Subject.Uri)]; ok { + if roundIdx < len(submissions) { + submission := submissions[roundIdx] + submission.Comments = append(submission.Comments, comment) + } + } } } - // group the submissions by pull_at - m := make(map[syntax.ATURI][]*models.PullSubmission) - for _, s := range submissionMap { - m[s.PullAt] = append(m[s.PullAt], s) - } - // sort each one by round number - for _, s := range m { + for _, s := range pullMap { slices.SortFunc(s, func(a, b *models.PullSubmission) int { return cmp.Compare(a.RoundNumber, b.RoundNumber) }) } - return m, nil -} - -func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { - var conditions []string - var args []any - for _, filter := range filters { - conditions = append(conditions, filter.Condition()) - args = append(args, filter.Arg()...) - } - - whereClause := "" - if conditions != nil { - whereClause = " where " + strings.Join(conditions, " and ") - } - - query := fmt.Sprintf(` - select - id, - pull_id, - submission_id, - repo_at, - owner_did, - comment_at, - body, - created - from - pull_comments - %s - order by - created asc - `, whereClause) - - rows, err := e.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - commentMap := make(map[string]*models.PullComment) - for rows.Next() { - var comment models.PullComment - var createdAt string - err := rows.Scan( - &comment.ID, - &comment.PullId, - &comment.SubmissionId, - &comment.RepoAt, - &comment.OwnerDid, - &comment.CommentAt, - &comment.Body, - &createdAt, - ) - if err != nil { - return nil, err - } - - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { - comment.Created = t - } - - atUri := comment.AtUri().String() - commentMap[atUri] = &comment - } - - if err := rows.Err(); err != nil { - return nil, err - } - - // collect references for each comments - commentAts := slices.Collect(maps.Keys(commentMap)) - allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) - if err != nil { - return nil, fmt.Errorf("failed to query reference_links: %w", err) - } - for commentAt, references := range allReferences { - if comment, ok := commentMap[commentAt.String()]; ok { - comment.References = references - } - } - - var comments []models.PullComment - for _, c := range commentMap { - comments = append(comments, *c) - } - - sort.Slice(comments, func(i, j int) bool { - return comments[i].Created.Before(comments[j].Created) - }) - - return comments, nil + return pullMap, nil } // timeframe here is directly passed into the sql query filter, and any @@ -773,33 +683,6 @@ func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) return pulls, nil } -func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { - query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` - res, err := tx.Exec( - query, - comment.OwnerDid, - comment.RepoAt, - comment.SubmissionId, - comment.CommentAt, - comment.PullId, - comment.Body, - ) - if err != nil { - return 0, err - } - - i, err := res.LastInsertId() - if err != nil { - return 0, err - } - - if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { - return 0, fmt.Errorf("put reference_links: %w", err) - } - - return i, nil -} - // use with transaction func SetPullsState(e Execer, pullState models.PullState, filters ...orm.Filter) error { var conditions []string diff --git a/appview/db/reference.go b/appview/db/reference.go index 6c631e86..d4ef2ee8 100644 --- a/appview/db/reference.go +++ b/appview/db/reference.go @@ -124,8 +124,7 @@ func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATU values %s ) select - p.owner_did, p.rkey, - c.comment_at + p.owner_did, p.rkey, c.at_uri from input inp join repos r on r.did = inp.owner_did @@ -133,9 +132,9 @@ func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATU join pulls p on p.repo_at = r.at_uri and p.pull_id = inp.pull_id - left join pull_comments c + left join comments c on inp.comment_id is not null - and c.repo_at = r.at_uri and c.pull_id = p.pull_id + and c.subject_uri = ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) and c.id = inp.comment_id `, strings.Join(vals, ","), @@ -293,7 +292,7 @@ func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, er return nil, fmt.Errorf("get pull backlinks: %w", err) } backlinks = append(backlinks, ls...) - ls, err = getPullCommentBacklinks(e, target, backlinksMap[tangled.RepoPullCommentNSID]) + ls, err = getPullCommentBacklinks(e, target, backlinksMap[tangled.FeedCommentNSID]) if err != nil { return nil, fmt.Errorf("get pull_comment backlinks: %w", err) } @@ -430,7 +429,7 @@ func getPullCommentBacklinks(e Execer, target syntax.ATURI, aturis []syntax.ATUR if len(aturis) == 0 { return nil, nil } - filter := orm.FilterIn("c.comment_at", aturis) + filter := orm.FilterIn("c.at_uri", aturis) exclude := orm.FilterNotEq("p.at_uri", target) rows, err := e.Query( fmt.Sprintf( @@ -438,8 +437,8 @@ func getPullCommentBacklinks(e Execer, target syntax.ATURI, aturis []syntax.ATUR from repos r join pulls p on r.at_uri = p.repo_at - join pull_comments c - on r.at_uri = c.repo_at and p.pull_id = c.pull_id + join comments c + on ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) = c.subject_uri where %s and %s`, filter.Condition(), exclude.Condition(), diff --git a/appview/ingester.go b/appview/ingester.go index 5b278050..62e2d878 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -25,7 +25,9 @@ import ( "tangled.org/core/api/tangled" "tangled.org/core/appview/config" "tangled.org/core/appview/db" + "tangled.org/core/appview/mentions" "tangled.org/core/appview/models" + "tangled.org/core/appview/notify" "tangled.org/core/appview/serververify" "tangled.org/core/appview/validator" "tangled.org/core/idresolver" @@ -34,12 +36,14 @@ import ( ) type Ingester struct { - Db db.DbWrapper - Enforcer *rbac.Enforcer - IdResolver *idresolver.Resolver - Config *config.Config - Logger *slog.Logger - Validator *validator.Validator + Db db.DbWrapper + Enforcer *rbac.Enforcer + IdResolver *idresolver.Resolver + Config *config.Config + Logger *slog.Logger + Validator *validator.Validator + MentionsResolver *mentions.Resolver + Notifier notify.Notifier } type processFunc func(ctx context.Context, e *jmodels.Event) error @@ -82,8 +86,12 @@ func (i *Ingester) Ingest() processFunc { err = i.ingestIssue(ctx, e) case tangled.RepoPullNSID: err = i.ingestPull(ctx, e) + case tangled.FeedCommentNSID: + err = i.ingestComment(e) case tangled.RepoIssueCommentNSID: err = i.ingestIssueComment(e) + case tangled.RepoPullCommentNSID: + err = i.ingestPullComment(e) case tangled.LabelDefinitionNSID: err = i.ingestLabelDefinition(e) case tangled.LabelOpNSID: @@ -1159,6 +1167,100 @@ func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { return nil } +// ingestPullComment ingests legacy sh.tangled.repo.pull.comment deletions +func (i *Ingester) ingestPullComment(e *jmodels.Event) error { + l := i.Logger.With("handler", "ingestPullComment", "nsid", e.Commit.Collection, "did", e.Did, "rkey", e.Commit.RKey) + l.Info("ingesting record") + + switch e.Commit.Operation { + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + // no-op. sh.tangled.repo.pull.comment is deprecated + + case jmodels.CommitOperationDelete: + if err := db.PurgeComments( + i.Db, + orm.FilterEq("did", e.Did), + orm.FilterEq("collection", e.Commit.Collection), + orm.FilterEq("rkey", e.Commit.RKey), + ); err != nil { + return fmt.Errorf("failed to delete comment record: %w", err) + } + } + + return nil +} + +func (i *Ingester) ingestComment(e *jmodels.Event) error { + did := e.Did + rkey := e.Commit.RKey + cid := e.Commit.CID + + var err error + + l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) + l.Info("ingesting record") + + ddb, ok := i.Db.Execer.(*db.DB) + if !ok { + return fmt.Errorf("failed to index issue comment record, invalid db cast") + } + + ctx := context.Background() + + switch e.Commit.Operation { + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + raw := json.RawMessage(e.Commit.Record) + record := tangled.FeedComment{} + err = json.Unmarshal(raw, &record) + if err != nil { + return fmt.Errorf("invalid record: %w", err) + } + + comment, err := models.CommentFromRecord(syntax.DID(did), syntax.RecordKey(rkey), syntax.CID(cid), record) + if err != nil { + return fmt.Errorf("failed to parse comment from record: %w", err) + } + + if err := comment.Validate(); err != nil { + return fmt.Errorf("failed to validate comment: %w", err) + } + + var references []syntax.ATURI + if comment.Body.Original != nil { + _, references = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) + } + + tx, err := ddb.Begin() + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer tx.Rollback() + + err = db.PutComment(tx, comment, references) + if err != nil { + return fmt.Errorf("failed to create comment: %w", err) + } + + if err := tx.Commit(); err != nil { + return err + } + + case jmodels.CommitOperationDelete: + if err := db.DeleteComments( + ddb, + orm.FilterEq("did", did), + orm.FilterEq("collection", e.Commit.Collection), + orm.FilterEq("rkey", rkey), + ); err != nil { + return fmt.Errorf("failed to delete comment record: %w", err) + } + + return nil + } + + return nil +} + func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error { did := e.Did rkey := e.Commit.RKey diff --git a/appview/models/comment.go b/appview/models/comment.go new file mode 100644 index 00000000..6c28df8c --- /dev/null +++ b/appview/models/comment.go @@ -0,0 +1,147 @@ +package models + +import ( + "fmt" + "strings" + "time" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + typegen "github.com/whyrusleeping/cbor-gen" + "tangled.org/core/api/tangled" +) + +type Comment struct { + Id int64 + + Did syntax.DID + Collection syntax.NSID + Rkey syntax.RecordKey + Cid syntax.CID + + // record content + Subject comatproto.RepoStrongRef + Body tangled.MarkupMarkdown // markup body type. only markdown is supported right now + Created time.Time + ReplyTo *comatproto.RepoStrongRef // (optional) parent comment + PullRoundIdx *int // (optional) pull round number used when subject is sh.tangled.repo.pull + + // store on db, but not on PDS + Edited *time.Time + Deleted *time.Time +} + +func (c *Comment) AtUri() syntax.ATURI { + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, c.Collection, c.Rkey)) +} + +func (c *Comment) StrongRef() comatproto.RepoStrongRef { + return comatproto.RepoStrongRef{ + Uri: c.AtUri().String(), + Cid: c.Cid.String(), + } +} + +func (c *Comment) AsRecord() typegen.CBORMarshaler { + // can't convert to record for legacy types + if c.Collection != tangled.FeedCommentNSID { + return nil + } + var pullRoundIdx int64 + if c.PullRoundIdx != nil { + pullRoundIdx = int64(*c.PullRoundIdx) + } + return &tangled.FeedComment{ + Subject: &c.Subject, + Body: &tangled.FeedComment_Body{MarkupMarkdown: &c.Body}, + CreatedAt: c.Created.Format(time.RFC3339), + ReplyTo: c.ReplyTo, + PullRoundIdx: &pullRoundIdx, + } +} + +func (c *Comment) IsTopLevel() bool { + return c.ReplyTo == nil +} + +func (c *Comment) IsReply() bool { + return c.ReplyTo != nil +} + +func (c *Comment) Validate() error { + // TODO: sanitize the body and then trim space + if sb := strings.TrimSpace(c.Body.Text); sb == "" { + return fmt.Errorf("body is empty after HTML sanitization") + } + + // if it's for PR, PullSubmissionId should not be nil + subjectAt, err := syntax.ParseATURI(c.Subject.Uri) + if err != nil { + return fmt.Errorf("subject.uri is not valid at-uri: %w", err) + } + if subjectAt.Collection().String() == tangled.RepoPullNSID { + if c.PullRoundIdx == nil { + return fmt.Errorf("pullSubmissionId should not be nil when subject is sh.tangled.repo.pull") + } + } + return nil +} + +func CommentFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.FeedComment) (*Comment, error) { + created, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + created = time.Now() + } + + if record.Subject == nil { + return nil, fmt.Errorf("subject can't be nil") + } + subjectAt, err := syntax.ParseATURI(record.Subject.Uri) + if err != nil { + return nil, fmt.Errorf("invalid subject uri: %w", err) + } + if _, err = syntax.ParseCID(record.Subject.Cid); err != nil { + return nil, fmt.Errorf("invalid subject cid: %w", err) + } + + if subjectAt.Collection() == tangled.RepoPullNSID { + if record.PullRoundIdx == nil { + return nil, fmt.Errorf("pullRoundIdx can't be nil when subject is sh.tangled.repo.pull") + } + } + + if record.Body == nil { + return nil, fmt.Errorf("body can't be nil") + } + if record.Body.MarkupMarkdown == nil { + return nil, fmt.Errorf("body should be markdown type") + } + + if record.ReplyTo != nil { + if _, err = syntax.ParseATURI(record.ReplyTo.Uri); err != nil { + return nil, fmt.Errorf("invalid replyTo uri: %w", err) + } + if _, err = syntax.ParseCID(record.ReplyTo.Cid); err != nil { + return nil, fmt.Errorf("invalid replyTo cid: %w", err) + } + } + + var pullRoundIdx *int + if record.PullRoundIdx != nil { + pullRoundIdx = new(int) + *pullRoundIdx = int(*record.PullRoundIdx) + } + + return &Comment{ + Did: did, + Collection: tangled.FeedCommentNSID, + Rkey: rkey, + Cid: cid, + + Subject: *record.Subject, + Body: *record.Body.MarkupMarkdown, + Created: created, + ReplyTo: record.ReplyTo, + PullRoundIdx: pullRoundIdx, + }, nil +} diff --git a/appview/models/pull.go b/appview/models/pull.go index c514941e..fe8be4dc 100644 --- a/appview/models/pull.go +++ b/appview/models/pull.go @@ -274,39 +274,13 @@ type PullSubmission struct { Blob lexutil.LexBlob Patch string Combined string - Comments []PullComment + Comments []Comment SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs // meta Created time.Time } -type PullComment struct { - // ids - ID int - PullId int - SubmissionId int - - // at ids - RepoAt string - OwnerDid string - CommentAt string - - // content - Body string - - // meta - Mentions []syntax.DID - References []syntax.ATURI - - // meta - Created time.Time -} - -func (p *PullComment) AtUri() syntax.ATURI { - return syntax.ATURI(p.CommentAt) -} - func (p *Pull) TotalComments() int { total := 0 for _, s := range p.Submissions { @@ -426,7 +400,7 @@ func (s *PullSubmission) Participants() []string { addParticipant(s.PullAt.Authority().String()) for _, c := range s.Comments { - addParticipant(c.OwnerDid) + addParticipant(c.Did.String()) } return participants diff --git a/appview/notify/db/db.go b/appview/notify/db/db.go index bc5f6658..4f3ae32e 100644 --- a/appview/notify/db/db.go +++ b/appview/notify/db/db.go @@ -281,19 +281,25 @@ func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { ) } -func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { +func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { l := log.FromContext(ctx) - pull, err := db.GetPull(n.db, - orm.FilterEq("repo_at", syntax.ATURI(comment.RepoAt)), - orm.FilterEq("pull_id", comment.PullId), + subjectAt := syntax.ATURI(comment.Subject.Uri) + pulls, err := db.GetPulls(n.db, + orm.FilterEq("owner_did", subjectAt.Authority()), + orm.FilterEq("rkey", subjectAt.RecordKey()), ) if err != nil { - l.Error("failed to get pulls", "err", err) + l.Error("failed to get pull", "err", err) return } + if len(pulls) == 0 { + l.Error("NewPullComment: no pull found", "aturi", comment.Subject) + return + } + pull := pulls[0] - repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) if err != nil { l.Error("failed to get repos", "err", err) return @@ -311,7 +317,7 @@ func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.P recipients.Remove(m) } - actorDid := syntax.DID(comment.OwnerDid) + actorDid := comment.Did eventType := models.NotificationTypePullCommented entityType := "pull" entityId := pull.AtUri().String() diff --git a/appview/notify/logging/notifier.go b/appview/notify/logging/notifier.go index 726531ac..ccae1ebb 100644 --- a/appview/notify/logging/notifier.go +++ b/appview/notify/logging/notifier.go @@ -86,7 +86,7 @@ func (l *loggingNotifier) NewPull(ctx context.Context, pull *models.Pull) { l.inner.NewPull(ctx, pull) } -func (l *loggingNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { +func (l *loggingNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPullComment")) l.inner.NewPullComment(ctx, comment, mentions) } diff --git a/appview/notify/merged_notifier.go b/appview/notify/merged_notifier.go index fb16ec6a..778ba404 100644 --- a/appview/notify/merged_notifier.go +++ b/appview/notify/merged_notifier.go @@ -82,7 +82,7 @@ func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { m.fanout(func(n Notifier) { n.NewPull(ctx, pull) }) } -func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { +func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { m.fanout(func(n Notifier) { n.NewPullComment(ctx, comment, mentions) }) } diff --git a/appview/notify/notifier.go b/appview/notify/notifier.go index 1f65d015..876c366f 100644 --- a/appview/notify/notifier.go +++ b/appview/notify/notifier.go @@ -23,7 +23,7 @@ type Notifier interface { DeleteFollow(ctx context.Context, follow *models.Follow) NewPull(ctx context.Context, pull *models.Pull) - NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) + NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) NewIssueLabelOp(ctx context.Context, issue *models.Issue) @@ -64,7 +64,7 @@ func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} -func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { +func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.Comment, mentions []syntax.DID) { } func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} diff --git a/appview/notify/posthog/notifier.go b/appview/notify/posthog/notifier.go index 7fe57255..02ffa274 100644 --- a/appview/notify/posthog/notifier.go +++ b/appview/notify/posthog/notifier.go @@ -86,13 +86,12 @@ func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { } } -func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { +func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { err := n.client.Enqueue(posthog.Capture{ - DistinctId: comment.OwnerDid, + DistinctId: comment.Did.String(), Event: "new_pull_comment", Properties: posthog.Properties{ - "repo_at": comment.RepoAt, - "pull_id": comment.PullId, + "pull_at": comment.Subject, "mentions": mentions, }, }) diff --git a/appview/oauth/scopes.go b/appview/oauth/scopes.go index 351e05d9..1e95baca 100644 --- a/appview/oauth/scopes.go +++ b/appview/oauth/scopes.go @@ -16,6 +16,7 @@ var TangledScopes = []string{ "repo:sh.tangled.spindle", "repo:sh.tangled.spindle.member", "repo:sh.tangled.graph.follow", + "repo:sh.tangled.feed.comment", "repo:sh.tangled.feed.star", "repo:sh.tangled.feed.reaction", "repo:sh.tangled.label.definition", diff --git a/appview/pages/templates/repo/pulls/pull.html b/appview/pages/templates/repo/pulls/pull.html index 708a9623..bf9864aa 100644 --- a/appview/pages/templates/repo/pulls/pull.html +++ b/appview/pages/templates/repo/pulls/pull.html @@ -625,25 +625,25 @@ {{ end }} {{ define "submissionComment" }} -
+
- {{ template "user/fragments/picLink" (list .OwnerDid "size-8") }} + {{ template "user/fragments/picLink" (list .Did.String "size-8") }}
- {{ $handle := resolve .OwnerDid }} + {{ $handle := resolve .Did.String }} {{ $handle }} - + {{ template "repo/fragments/shortTime" .Created }}
- {{ .Body | markdown }} + {{ .Body.Text | markdown }}
diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index 0485965b..14a89521 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -838,60 +838,103 @@ func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { case http.MethodPost: body := r.FormValue("body") if body == "" { - s.pages.Notice(w, "pull", "Comment body is required") + s.pages.Notice(w, "pull-comment", "Comment body is required") return } + // TODO(boltless): normalize markdown body + normalizedBody := body mentions, references := s.mentionsResolver.Resolve(r.Context(), body) - // Start a transaction - tx, err := s.db.BeginTx(r.Context(), nil) + markdownBody := tangled.MarkupMarkdown{ + Text: normalizedBody, + Original: &body, + Blobs: nil, + } + + // ingest CID of PR record on-demand. + // TODO(boltless): appview should ingest CID of atproto records + cid, err := func() (syntax.CID, error) { + ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) + if err != nil { + return "", err + } + + xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} + out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoPullNSID, pull.OwnerDid, pull.Rkey) + if err != nil { + return "", err + } + if out.Cid == nil { + return "", fmt.Errorf("record CID is empty") + } + + cid, err := syntax.ParseCID(*out.Cid) + if err != nil { + return "", err + } + + return cid, nil + }() if err != nil { - l.Error("failed to start transaction", "err", err) - s.pages.Notice(w, "pull-comment", "Failed to create comment.") + s.logger.Error("failed to backfill subject PR record", "err", err) + s.pages.Notice(w, "pull-comment", "failed to backfill subject record") return } - defer tx.Rollback() + pullStrongRef := comatproto.RepoStrongRef{ + Uri: pull.AtUri().String(), + Cid: cid.String(), + } + + comment := models.Comment{ + Did: syntax.DID(user.Active.Did), + Collection: tangled.FeedCommentNSID, + Rkey: syntax.RecordKey(tid.TID()), - createdAt := time.Now().Format(time.RFC3339) + Subject: pullStrongRef, + Body: markdownBody, + Created: time.Now(), + ReplyTo: nil, + PullRoundIdx: &roundNumber, + } + if err = comment.Validate(); err != nil { + s.logger.Error("failed to validate comment", "err", err) + s.pages.Notice(w, "pull-comment", "Failed to create comment.") + return + } client, err := s.oauth.AuthorizedClient(r) if err != nil { - l.Error("failed to get authorized client", "err", err) + s.logger.Error("failed to get authorized client", "err", err) s.pages.Notice(w, "pull-comment", "Failed to create comment.") return } - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoPullCommentNSID, - Repo: user.Active.Did, - Rkey: tid.TID(), - Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.RepoPullComment{ - Pull: pull.AtUri().String(), - Body: body, - CreatedAt: createdAt, - }, - }, + + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + Collection: comment.Collection.String(), + Repo: comment.Did.String(), + Rkey: comment.Rkey.String(), + Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, }) if err != nil { - l.Error("failed to create pull comment", "err", err) + s.logger.Error("failed to create pull comment", "err", err) s.pages.Notice(w, "pull-comment", "Failed to create comment.") return } - comment := &models.PullComment{ - OwnerDid: user.Active.Did, - RepoAt: f.RepoAt().String(), - PullId: pull.PullId, - Body: body, - CommentAt: atResp.Uri, - SubmissionId: pull.Submissions[roundNumber].ID, - Mentions: mentions, - References: references, + comment.Cid = syntax.CID(out.Cid) + + // Start a transaction + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + l.Error("failed to start transaction", "err", err) + s.pages.Notice(w, "pull-comment", "Failed to create comment.") + return } + defer tx.Rollback() - // Create the pull comment in the database with the commentAt field - commentId, err := db.NewPullComment(tx, comment) + // Create the pull comment in the database + err = db.PutComment(tx, &comment, references) if err != nil { l.Error("failed to create pull comment in database", "err", err) s.pages.Notice(w, "pull-comment", "Failed to create comment.") @@ -905,10 +948,10 @@ func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { return } - s.notifier.NewPullComment(r.Context(), comment, mentions) + s.notifier.NewPullComment(r.Context(), &comment, mentions) ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) return } } diff --git a/appview/state/state.go b/appview/state/state.go index de95850d..02e4eefa 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -126,8 +126,10 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { tangled.KnotNSID, tangled.StringNSID, tangled.RepoPullNSID, + tangled.RepoPullCommentNSID, tangled.RepoIssueNSID, tangled.RepoIssueCommentNSID, + tangled.FeedCommentNSID, tangled.LabelDefinitionNSID, tangled.LabelOpNSID, }, @@ -148,19 +150,6 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { return nil, fmt.Errorf("failed to backfill default label defs: %w", err) } - ingester := appview.Ingester{ - Db: wrapper, - Enforcer: enforcer, - IdResolver: res, - Config: config, - Logger: log.SubLogger(logger, "ingester"), - Validator: validator, - } - err = jc.StartJetstream(ctx, ingester.Ingest()) - if err != nil { - return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) - } - var notifiers []notify.Notifier // Always add the database notifier @@ -177,6 +166,21 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { notifier := notify.NewMergedNotifier(notifiers) notifier = lognotify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify")) + ingester := appview.Ingester{ + Db: wrapper, + Enforcer: enforcer, + IdResolver: res, + Config: config, + Logger: log.SubLogger(logger, "ingester"), + Validator: validator, + MentionsResolver: mentionsResolver, + Notifier: notifier, + } + err = jc.StartJetstream(ctx, ingester.Ingest()) + if err != nil { + return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) + } + var cfClient *cloudflare.Client if config.Cloudflare.ApiToken != "" { cfClient, err = cloudflare.New(config) -- 2.51.2 From 3c7451a6014a6ac17db2445a6ed0780c972572c2 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Tue, 9 Dec 2025 00:06:33 +0900 Subject: [PATCH 04/10] appview: replace `IssueComment` to `Comment` Signed-off-by: Seongmin Lee --- appview/db/comments.go | 14 +- appview/db/issues.go | 192 +-------------- appview/db/reference.go | 37 ++- appview/ingester.go | 55 +---- appview/issues/issues.go | 221 +++++++++++------- appview/models/comment.go | 11 + appview/models/issue.go | 103 +------- appview/notify/db/db.go | 10 +- appview/notify/logging/notifier.go | 2 +- appview/notify/merged_notifier.go | 2 +- appview/notify/notifier.go | 4 +- appview/notify/posthog/notifier.go | 6 +- appview/pages/pages.go | 8 +- .../repo/issues/fragments/commentList.html | 4 +- .../issues/fragments/editIssueComment.html | 2 +- .../issues/fragments/issueCommentBody.html | 2 +- .../issues/fragments/issueCommentHeader.html | 8 +- .../repo/issues/fragments/replyComment.html | 12 +- .../replyIssueCommentPlaceholder.html | 6 + appview/validator/issue.go | 27 --- 20 files changed, 240 insertions(+), 486 deletions(-) diff --git a/appview/db/comments.go b/appview/db/comments.go index 5f8e9cc7..b71dafd7 100644 --- a/appview/db/comments.go +++ b/appview/db/comments.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "log" "sort" "strings" "time" @@ -95,11 +96,14 @@ func PutComment(tx *sql.Tx, c *models.Comment, references []syntax.ATURI) error return err } - if affected > 0 { - // update references when comment is updated - if err := putReferences(tx, c.AtUri(), references); err != nil { - return fmt.Errorf("put reference_links: %w", err) - } + if affected < 1 { + log.Println("record is already stored. skipping operation") + return nil + } + + // update references when comment is updated + if err := putReferences(tx, c.AtUri(), references); err != nil { + return fmt.Errorf("put reference_links: %w", err) } return nil diff --git a/appview/db/issues.go b/appview/db/issues.go index 244b9f5d..d37d6a2c 100644 --- a/appview/db/issues.go +++ b/appview/db/issues.go @@ -100,7 +100,7 @@ func updateIssue(tx *sql.Tx, issue *models.Issue) error { } func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { - issueMap := make(map[string]*models.Issue) // at-uri -> issue + issueMap := make(map[syntax.ATURI]*models.Issue) // at-uri -> issue var conditions []string var args []any @@ -196,8 +196,7 @@ func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ( } } - atUri := issue.AtUri().String() - issueMap[atUri] = &issue + issueMap[issue.AtUri()] = &issue } // collect reverse repos @@ -229,12 +228,12 @@ func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ( // collect comments issueAts := slices.Collect(maps.Keys(issueMap)) - comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) + comments, err := GetComments(e, orm.FilterIn("subject_uri", issueAts)) if err != nil { return nil, fmt.Errorf("failed to query comments: %w", err) } for i := range comments { - issueAt := comments[i].IssueAt + issueAt := syntax.ATURI(comments[i].Subject.Uri) if issue, ok := issueMap[issueAt]; ok { issue.Comments = append(issue.Comments, comments[i]) } @@ -246,7 +245,7 @@ func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ( return nil, fmt.Errorf("failed to query labels: %w", err) } for issueAt, labels := range allLabels { - if issue, ok := issueMap[issueAt.String()]; ok { + if issue, ok := issueMap[issueAt]; ok { issue.Labels = labels } } @@ -257,7 +256,7 @@ func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ( return nil, fmt.Errorf("failed to query reference_links: %w", err) } for issueAt, references := range allReferences { - if issue, ok := issueMap[issueAt.String()]; ok { + if issue, ok := issueMap[issueAt]; ok { issue.References = references } } @@ -295,185 +294,6 @@ func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) { return GetIssuesPaginated(e, pagination.Page{}, filters...) } -func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { - result, err := tx.Exec( - `insert into issue_comments ( - did, - rkey, - issue_at, - body, - reply_to, - created, - edited - ) - values (?, ?, ?, ?, ?, ?, null) - on conflict(did, rkey) do update set - issue_at = excluded.issue_at, - body = excluded.body, - edited = case - when - issue_comments.issue_at != excluded.issue_at - or issue_comments.body != excluded.body - or issue_comments.reply_to != excluded.reply_to - then ? - else issue_comments.edited - end`, - c.Did, - c.Rkey, - c.IssueAt, - c.Body, - c.ReplyTo, - c.Created.Format(time.RFC3339), - time.Now().Format(time.RFC3339), - ) - if err != nil { - return 0, err - } - - id, err := result.LastInsertId() - if err != nil { - return 0, err - } - - if err := putReferences(tx, c.AtUri(), c.References); err != nil { - return 0, fmt.Errorf("put reference_links: %w", err) - } - - return id, nil -} - -func DeleteIssueComments(e Execer, filters ...orm.Filter) error { - var conditions []string - var args []any - for _, filter := range filters { - conditions = append(conditions, filter.Condition()) - args = append(args, filter.Arg()...) - } - - whereClause := "" - if conditions != nil { - whereClause = " where " + strings.Join(conditions, " and ") - } - - query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) - - _, err := e.Exec(query, args...) - return err -} - -func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { - commentMap := make(map[string]*models.IssueComment) - - var conditions []string - var args []any - for _, filter := range filters { - conditions = append(conditions, filter.Condition()) - args = append(args, filter.Arg()...) - } - - whereClause := "" - if conditions != nil { - whereClause = " where " + strings.Join(conditions, " and ") - } - - query := fmt.Sprintf(` - select - id, - did, - rkey, - issue_at, - reply_to, - body, - created, - edited, - deleted - from - issue_comments - %s - `, whereClause) - - rows, err := e.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - for rows.Next() { - var comment models.IssueComment - var created string - var rkey, edited, deleted, replyTo sql.Null[string] - err := rows.Scan( - &comment.Id, - &comment.Did, - &rkey, - &comment.IssueAt, - &replyTo, - &comment.Body, - &created, - &edited, - &deleted, - ) - if err != nil { - return nil, err - } - - // this is a remnant from old times, newer comments always have rkey - if rkey.Valid { - comment.Rkey = rkey.V - } - - if t, err := time.Parse(time.RFC3339, created); err == nil { - comment.Created = t - } - - if edited.Valid { - if t, err := time.Parse(time.RFC3339, edited.V); err == nil { - comment.Edited = &t - } - } - - if deleted.Valid { - if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { - comment.Deleted = &t - } - } - - if replyTo.Valid { - comment.ReplyTo = &replyTo.V - } - - atUri := comment.AtUri().String() - commentMap[atUri] = &comment - } - - if err = rows.Err(); err != nil { - return nil, err - } - - // collect references for each comments - commentAts := slices.Collect(maps.Keys(commentMap)) - allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) - if err != nil { - return nil, fmt.Errorf("failed to query reference_links: %w", err) - } - for commentAt, references := range allReferences { - if comment, ok := commentMap[commentAt.String()]; ok { - comment.References = references - } - } - - var comments []models.IssueComment - for _, c := range commentMap { - comments = append(comments, *c) - } - - sort.Slice(comments, func(i, j int) bool { - return comments[i].Created.After(comments[j].Created) - }) - - return comments, nil -} - func DeleteIssues(tx *sql.Tx, did, rkey string) error { _, err := tx.Exec( `delete from issues diff --git a/appview/db/reference.go b/appview/db/reference.go index d4ef2ee8..f8fc9c9a 100644 --- a/appview/db/reference.go +++ b/appview/db/reference.go @@ -11,7 +11,7 @@ import ( "tangled.org/core/orm" ) -// ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. +// ValidateReferenceLinks resolves refLinks to Issue/PR/Comment ATURIs. // It will ignore missing refLinks. func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { var ( @@ -53,8 +53,7 @@ func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.AT values %s ) select - i.did, i.rkey, - c.did, c.rkey + i.at_uri, c.at_uri from input inp join repos r on r.did = inp.owner_did @@ -62,9 +61,9 @@ func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.AT join issues i on i.repo_at = r.at_uri and i.issue_id = inp.issue_id - left join issue_comments c + left join comments c on inp.comment_id is not null - and c.issue_at = i.at_uri + and c.subject_uri = i.at_uri and c.id = inp.comment_id `, strings.Join(vals, ","), @@ -79,26 +78,16 @@ func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.AT for rows.Next() { // Scan rows - var issueOwner, issueRkey string - var commentOwner, commentRkey sql.NullString + var issueUri string + var commentUri sql.NullString var uri syntax.ATURI - if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { + if err := rows.Scan(&issueUri, &commentUri); err != nil { return nil, err } - if commentOwner.Valid && commentRkey.Valid { - uri = syntax.ATURI(fmt.Sprintf( - "at://%s/%s/%s", - commentOwner.String, - tangled.RepoIssueCommentNSID, - commentRkey.String, - )) + if commentUri.Valid { + uri = syntax.ATURI(commentUri.String) } else { - uri = syntax.ATURI(fmt.Sprintf( - "at://%s/%s/%s", - issueOwner, - tangled.RepoIssueNSID, - issueRkey, - )) + uri = syntax.ATURI(issueUri) } uris = append(uris, uri) } @@ -282,7 +271,7 @@ func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, er return nil, fmt.Errorf("get issue backlinks: %w", err) } backlinks = append(backlinks, ls...) - ls, err = getIssueCommentBacklinks(e, target, backlinksMap[tangled.RepoIssueCommentNSID]) + ls, err = getIssueCommentBacklinks(e, target, backlinksMap[tangled.FeedCommentNSID]) if err != nil { return nil, fmt.Errorf("get issue_comment backlinks: %w", err) } @@ -352,9 +341,9 @@ func getIssueCommentBacklinks(e Execer, target syntax.ATURI, aturis []syntax.ATU rows, err := e.Query( fmt.Sprintf( `select r.did, r.name, i.issue_id, c.id, i.title, i.open - from issue_comments c + from comments c join issues i - on i.at_uri = c.issue_at + on i.at_uri = c.subject_uri join repos r on r.at_uri = i.repo_at where %s and %s`, diff --git a/appview/ingester.go b/appview/ingester.go index 62e2d878..106358fc 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -1107,61 +1107,24 @@ func (i *Ingester) ingestPull(ctx context.Context, e *jmodels.Event) error { return nil } +// ingestIssueComment ingests legacy sh.tangled.repo.issue.comment deletions func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { - did := e.Did - rkey := e.Commit.RKey - - var err error - - l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", e.Did, "rkey", e.Commit.RKey) l.Info("ingesting record") - ddb, ok := i.Db.Execer.(*db.DB) - if !ok { - return fmt.Errorf("failed to index issue comment record, invalid db cast") - } - switch e.Commit.Operation { case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: - raw := json.RawMessage(e.Commit.Record) - record := tangled.RepoIssueComment{} - err = json.Unmarshal(raw, &record) - if err != nil { - return fmt.Errorf("invalid record: %w", err) - } - - comment, err := models.IssueCommentFromRecord(did, rkey, record) - if err != nil { - return fmt.Errorf("failed to parse comment from record: %w", err) - } - - if err := i.Validator.ValidateIssueComment(comment); err != nil { - return fmt.Errorf("failed to validate comment: %w", err) - } - - tx, err := ddb.Begin() - if err != nil { - return fmt.Errorf("failed to start transaction: %w", err) - } - defer tx.Rollback() - - _, err = db.AddIssueComment(tx, *comment) - if err != nil { - return fmt.Errorf("failed to create issue comment: %w", err) - } - - return tx.Commit() + // no-op. sh.tangled.repo.issue.comment is deprecated case jmodels.CommitOperationDelete: - if err := db.DeleteIssueComments( - ddb, - orm.FilterEq("did", did), - orm.FilterEq("rkey", rkey), + if err := db.PurgeComments( + i.Db, + orm.FilterEq("did", e.Did), + orm.FilterEq("collection", e.Commit.Collection), + orm.FilterEq("rkey", e.Commit.RKey), ); err != nil { - return fmt.Errorf("failed to delete issue comment record: %w", err) + return fmt.Errorf("failed to delete comment record: %w", err) } - - return nil } return nil diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 34bdcb0d..56b13b2e 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -13,6 +13,7 @@ import ( "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" + indigoxrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" "tangled.org/core/api/tangled" @@ -406,34 +407,91 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { body := r.FormValue("body") if body == "" { - rp.pages.Notice(w, "issue", "Body is required") + rp.pages.Notice(w, "issue-comment", "Body is required") return } - replyToUri := r.FormValue("reply-to") - var replyTo *string - if replyToUri != "" { - replyTo = &replyToUri + // TODO(boltless): normalize markdown body + normalizedBody := body + _, references := rp.mentionsResolver.Resolve(r.Context(), body) + + markdownBody := tangled.MarkupMarkdown{ + Text: normalizedBody, + Original: &body, + Blobs: nil, + } + + // ingest CID of issue record on-demand. + // TODO(boltless): appview should ingest CID of atproto records + cid, err := func() (syntax.CID, error) { + ident, err := rp.idResolver.ResolveIdent(r.Context(), issue.Did) + if err != nil { + return "", err + } + + xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} + out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) + if err != nil { + return "", err + } + if out.Cid == nil { + return "", fmt.Errorf("record CID is empty") + } + + cid, err := syntax.ParseCID(*out.Cid) + if err != nil { + return "", err + } + + return cid, nil + }() + if err != nil { + rp.logger.Error("failed to backfill subject PR record", "err", err) + rp.pages.Notice(w, "issue-comment", "failed to backfill subject record") + return + } + issueStrongRef := comatproto.RepoStrongRef{ + Uri: issue.AtUri().String(), + Cid: cid.String(), + } + + var replyTo *comatproto.RepoStrongRef + replyToUriRaw := r.FormValue("reply-to-uri") + replyToCidRaw := r.FormValue("reply-to-cid") + if replyToUriRaw != "" && replyToCidRaw != "" { + uri, err := syntax.ParseATURI(replyToUriRaw) + if err != nil { + rp.pages.Notice(w, "issue-comment", "reply-to-uri should be valid AT-URI") + return + } + cid, err := syntax.ParseCID(replyToCidRaw) + if err != nil { + rp.pages.Notice(w, "issue-comment", "reply-to-cid should be valid CID") + return + } + replyTo = &comatproto.RepoStrongRef{ + Uri: uri.String(), + Cid: cid.String(), + } } mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) - comment := models.IssueComment{ - Did: user.Active.Did, - Rkey: tid.TID(), - IssueAt: issue.AtUri().String(), - ReplyTo: replyTo, - Body: body, - Created: time.Now(), - Mentions: mentions, - References: references, - } - if err = rp.validator.ValidateIssueComment(&comment); err != nil { + comment := models.Comment{ + Did: syntax.DID(user.Active.Did), + Collection: tangled.FeedCommentNSID, + Rkey: syntax.RecordKey(tid.TID()), + + Subject: issueStrongRef, + Body: markdownBody, + Created: time.Now(), + ReplyTo: replyTo, + } + if err = comment.Validate(); err != nil { l.Error("failed to validate comment", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } - record := comment.AsRecord() client, err := rp.oauth.AuthorizedClient(r) if err != nil { @@ -443,25 +501,19 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { } // create a record first - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoIssueCommentNSID, - Repo: comment.Did, - Rkey: comment.Rkey, - Record: &lexutil.LexiconTypeDecoder{ - Val: &record, - }, + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + Collection: comment.Collection.String(), + Repo: comment.Did.String(), + Rkey: comment.Rkey.String(), + Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, }) if err != nil { l.Error("failed to create comment", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } - atUri := resp.Uri - defer func() { - if err := rollbackRecord(context.Background(), atUri, client); err != nil { - l.Error("rollback failed", "err", err) - } - }() + + comment.Cid = syntax.CID(out.Cid) tx, err := rp.db.Begin() if err != nil { @@ -471,12 +523,13 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback() - commentId, err := db.AddIssueComment(tx, comment) + err = db.PutComment(tx, &comment, references) if err != nil { l.Error("failed to create comment", "err", err) rp.pages.Notice(w, "issue-comment", "Failed to create comment.") return } + err = tx.Commit() if err != nil { l.Error("failed to commit transaction", "err", err) @@ -484,16 +537,10 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { return } - // reset atUri to make rollback a no-op - atUri = "" - - // notify about the new comment - comment.Id = commentId - rp.notifier.NewIssueComment(r.Context(), &comment, mentions) ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) } func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { @@ -508,7 +555,7 @@ func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -544,7 +591,7 @@ func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -560,7 +607,7 @@ func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { } comment := comments[0] - if comment.Did != user.Active.Did { + if comment.Did.String() != user.Active.Did { l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) return @@ -576,7 +623,25 @@ func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { }) case http.MethodPost: // extract form value - newBody := r.FormValue("body") + body := r.FormValue("body") + if body == "" { + rp.pages.Notice(w, "issue-comment", "Body is required") + return + } + + // TODO(boltless): normalize markdown body + normalizedBody := body + _, references := rp.mentionsResolver.Resolve(r.Context(), body) + + now := time.Now() + newComment := comment + newComment.Body = tangled.MarkupMarkdown{ + Text: normalizedBody, + Original: &body, + Blobs: nil, + } + newComment.Edited = &now + client, err := rp.oauth.AuthorizedClient(r) if err != nil { l.Error("failed to get authorized client", "err", err) @@ -584,13 +649,24 @@ func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { return } - now := time.Now() - newComment := comment - newComment.Body = newBody - newComment.Edited = &now - newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) + // update a record first + exCid := comment.Cid.String() + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + Collection: newComment.Collection.String(), + Repo: newComment.Did.String(), + Rkey: newComment.Rkey.String(), + SwapRecord: &exCid, + Record: &lexutil.LexiconTypeDecoder{ + Val: newComment.AsRecord(), + }, + }) + if err != nil { + l.Error("failed to update comment", "err", err) + rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") + return + } - record := newComment.AsRecord() + newComment.Cid = syntax.CID(resp.Cid) tx, err := rp.db.Begin() if err != nil { @@ -600,36 +676,17 @@ func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback() - _, err = db.AddIssueComment(tx, newComment) + err = db.PutComment(tx, &newComment, references) if err != nil { l.Error("failed to perform update-description query", "err", err) rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") return } - tx.Commit() - - // rkey is optional, it was introduced later - if newComment.Rkey != "" { - // update the record on pds - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Active.Did, comment.Rkey) - if err != nil { - l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") - return - } - - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoIssueCommentNSID, - Repo: user.Active.Did, - Rkey: newComment.Rkey, - SwapRecord: ex.Cid, - Record: &lexutil.LexiconTypeDecoder{ - Val: &record, - }, - }) - if err != nil { - l.Error("failed to update record on PDS", "err", err) - } + err = tx.Commit() + if err != nil { + l.Error("failed to commit transaction", "err", err) + rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") + return } // return new comment body with htmx @@ -654,7 +711,7 @@ func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Re } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -690,7 +747,7 @@ func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -726,7 +783,7 @@ func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { } commentId := chi.URLParam(r, "commentId") - comments, err := db.GetIssueComments( + comments, err := db.GetComments( rp.db, orm.FilterEq("id", commentId), ) @@ -742,7 +799,7 @@ func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { } comment := comments[0] - if comment.Did != user.Active.Did { + if comment.Did.String() != user.Active.Did { l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) return @@ -755,7 +812,7 @@ func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { // optimistic deletion deleted := time.Now() - err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) + err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) if err != nil { l.Error("failed to delete comment", "err", err) rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") @@ -771,9 +828,9 @@ func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { return } _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ - Collection: tangled.RepoIssueCommentNSID, - Repo: user.Active.Did, - Rkey: comment.Rkey, + Collection: comment.Collection.String(), + Repo: comment.Did.String(), + Rkey: comment.Rkey.String(), }) if err != nil { l.Error("failed to delete from PDS", "err", err) @@ -781,7 +838,7 @@ func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { } // optimistic update for htmx - comment.Body = "" + comment.Body = tangled.MarkupMarkdown{} comment.Deleted = &deleted // htmx fragment of comment after deletion diff --git a/appview/models/comment.go b/appview/models/comment.go index 6c28df8c..3eaeb4f6 100644 --- a/appview/models/comment.go +++ b/appview/models/comment.go @@ -60,6 +60,17 @@ func (c *Comment) AsRecord() typegen.CBORMarshaler { } } +func (c *Comment) EditableBody() string { + if c.Body.Original != nil { + return *c.Body.Original + } + return c.Body.Text +} + +func (c *Comment) IsLegacy() bool { + return c.Collection != tangled.FeedCommentNSID +} + func (c *Comment) IsTopLevel() bool { return c.ReplyTo == nil } diff --git a/appview/models/issue.go b/appview/models/issue.go index 58cf07a6..e00a3c51 100644 --- a/appview/models/issue.go +++ b/appview/models/issue.go @@ -26,7 +26,7 @@ type Issue struct { // optionally, populate this when querying for reverse mappings // like comment counts, parent repo etc. - Comments []IssueComment + Comments []Comment Labels LabelState Repo *Repo } @@ -67,8 +67,8 @@ func (i *Issue) State() string { } type CommentListItem struct { - Self *IssueComment - Replies []*IssueComment + Self *Comment + Replies []*Comment } func (it *CommentListItem) Participants() []syntax.DID { @@ -93,13 +93,13 @@ func (it *CommentListItem) Participants() []syntax.DID { func (i *Issue) CommentList() []CommentListItem { // Create a map to quickly find comments by their aturi - toplevel := make(map[string]*CommentListItem) - var replies []*IssueComment + toplevel := make(map[syntax.ATURI]*CommentListItem) + var replies []*Comment // collect top level comments into the map for _, comment := range i.Comments { if comment.IsTopLevel() { - toplevel[comment.AtUri().String()] = &CommentListItem{ + toplevel[comment.AtUri()] = &CommentListItem{ Self: &comment, } } else { @@ -108,8 +108,10 @@ func (i *Issue) CommentList() []CommentListItem { } for _, r := range replies { - parentAt := *r.ReplyTo - if parent, exists := toplevel[parentAt]; exists { + if r.ReplyTo == nil { + continue + } + if parent, exists := toplevel[syntax.ATURI(r.ReplyTo.Uri)]; exists { parent.Replies = append(parent.Replies, r) } } @@ -120,7 +122,7 @@ func (i *Issue) CommentList() []CommentListItem { } // sort everything - sortFunc := func(a, b *IssueComment) bool { + sortFunc := func(a, b *Comment) bool { return a.Created.Before(b.Created) } sort.Slice(listing, func(i, j int) bool { @@ -149,7 +151,7 @@ func (i *Issue) Participants() []string { addParticipant(i.Did) for _, c := range i.Comments { - addParticipant(c.Did) + addParticipant(c.Did.String()) } return participants @@ -181,84 +183,3 @@ func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { Open: true, // new issues are open by default } } - -type IssueComment struct { - Id int64 - Did string - Rkey string - IssueAt string - ReplyTo *string - Body string - Created time.Time - Edited *time.Time - Deleted *time.Time - Mentions []syntax.DID - References []syntax.ATURI -} - -func (i *IssueComment) AtUri() syntax.ATURI { - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) -} - -func (i *IssueComment) AsRecord() tangled.RepoIssueComment { - mentions := make([]string, len(i.Mentions)) - for i, did := range i.Mentions { - mentions[i] = string(did) - } - references := make([]string, len(i.References)) - for i, uri := range i.References { - references[i] = string(uri) - } - return tangled.RepoIssueComment{ - Body: i.Body, - Issue: i.IssueAt, - CreatedAt: i.Created.Format(time.RFC3339), - ReplyTo: i.ReplyTo, - Mentions: mentions, - References: references, - } -} - -func (i *IssueComment) IsTopLevel() bool { - return i.ReplyTo == nil -} - -func (i *IssueComment) IsReply() bool { - return i.ReplyTo != nil -} - -func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { - created, err := time.Parse(time.RFC3339, record.CreatedAt) - if err != nil { - created = time.Now() - } - - ownerDid := did - - if _, err = syntax.ParseATURI(record.Issue); err != nil { - return nil, err - } - - i := record - mentions := make([]syntax.DID, len(record.Mentions)) - for i, did := range record.Mentions { - mentions[i] = syntax.DID(did) - } - references := make([]syntax.ATURI, len(record.References)) - for i, uri := range i.References { - references[i] = syntax.ATURI(uri) - } - - comment := IssueComment{ - Did: ownerDid, - Rkey: rkey, - Body: record.Body, - IssueAt: record.Issue, - ReplyTo: record.ReplyTo, - Created: created, - Mentions: mentions, - References: references, - } - - return &comment, nil -} diff --git a/appview/notify/db/db.go b/appview/notify/db/db.go index 4f3ae32e..ee44999e 100644 --- a/appview/notify/db/db.go +++ b/appview/notify/db/db.go @@ -132,16 +132,16 @@ func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, me ) } -func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { +func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { l := log.FromContext(ctx) - issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) + issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.Subject)) if err != nil { l.Error("failed to get issues", "err", err) return } if len(issues) == 0 { - l.Error("no issue found for", "err", comment.IssueAt) + l.Error("no issue found for", "err", comment.Subject) return } issue := issues[0] @@ -155,11 +155,11 @@ func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models. if comment.IsReply() { // if this comment is a reply, then notify everybody in that thread - parentAtUri := *comment.ReplyTo + parent := *comment.ReplyTo // find the parent thread, and add all DIDs from here to the recipient list for _, t := range issue.CommentList() { - if t.Self.AtUri().String() == parentAtUri { + if t.Self.AtUri() == syntax.ATURI(parent.Uri) { for _, p := range t.Participants() { recipients.Insert(p) } diff --git a/appview/notify/logging/notifier.go b/appview/notify/logging/notifier.go index ccae1ebb..874f2c26 100644 --- a/appview/notify/logging/notifier.go +++ b/appview/notify/logging/notifier.go @@ -46,7 +46,7 @@ func (l *loggingNotifier) NewIssue(ctx context.Context, issue *models.Issue, men l.inner.NewIssue(ctx, issue, mentions) } -func (l *loggingNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { +func (l *loggingNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewIssueComment")) l.inner.NewIssueComment(ctx, comment, mentions) } diff --git a/appview/notify/merged_notifier.go b/appview/notify/merged_notifier.go index 778ba404..43cf0aca 100644 --- a/appview/notify/merged_notifier.go +++ b/appview/notify/merged_notifier.go @@ -50,7 +50,7 @@ func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, ment m.fanout(func(n Notifier) { n.NewIssue(ctx, issue, mentions) }) } -func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { +func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { m.fanout(func(n Notifier) { n.NewIssueComment(ctx, comment, mentions) }) } diff --git a/appview/notify/notifier.go b/appview/notify/notifier.go index 876c366f..572832f8 100644 --- a/appview/notify/notifier.go +++ b/appview/notify/notifier.go @@ -15,7 +15,7 @@ type Notifier interface { DeleteStar(ctx context.Context, star *models.Star) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) - NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) + NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) DeleteIssue(ctx context.Context, issue *models.Issue) @@ -52,7 +52,7 @@ func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} -func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { +func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { } func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} diff --git a/appview/notify/posthog/notifier.go b/appview/notify/posthog/notifier.go index 02ffa274..5f16e5dc 100644 --- a/appview/notify/posthog/notifier.go +++ b/appview/notify/posthog/notifier.go @@ -190,12 +190,12 @@ func (n *posthogNotifier) Clone(ctx context.Context, repo *models.Repo) { } } -func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { +func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { err := n.client.Enqueue(posthog.Capture{ - DistinctId: comment.Did, + DistinctId: comment.Did.String(), Event: "new_issue_comment", Properties: posthog.Properties{ - "issue_at": comment.IssueAt, + "issue_at": comment.Subject.Uri, "mentions": mentions, }, }) diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 541f34ef..61d8a3ef 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1178,7 +1178,7 @@ type EditIssueCommentParams struct { LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo Issue *models.Issue - Comment *models.IssueComment + Comment *models.Comment } func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { @@ -1189,7 +1189,7 @@ type ReplyIssueCommentPlaceholderParams struct { LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo Issue *models.Issue - Comment *models.IssueComment + Comment *models.Comment } func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { @@ -1200,7 +1200,7 @@ type ReplyIssueCommentParams struct { LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo Issue *models.Issue - Comment *models.IssueComment + Comment *models.Comment } func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { @@ -1211,7 +1211,7 @@ type IssueCommentBodyParams struct { LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo Issue *models.Issue - Comment *models.IssueComment + Comment *models.Comment } func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { diff --git a/appview/pages/templates/repo/issues/fragments/commentList.html b/appview/pages/templates/repo/issues/fragments/commentList.html index 765c397c..4fd6cd91 100644 --- a/appview/pages/templates/repo/issues/fragments/commentList.html +++ b/appview/pages/templates/repo/issues/fragments/commentList.html @@ -41,7 +41,7 @@ {{ define "topLevelComment" }}
- {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1") }} + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }}
{{ template "repo/issues/fragments/issueCommentHeader" . }} @@ -53,7 +53,7 @@ {{ define "replyComment" }}
- {{ template "user/fragments/picLink" (list .Comment.Did "size-8 mr-1") }} + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }}
{{ template "repo/issues/fragments/issueCommentHeader" . }} diff --git a/appview/pages/templates/repo/issues/fragments/editIssueComment.html b/appview/pages/templates/repo/issues/fragments/editIssueComment.html index 32be9b06..ca7354b5 100644 --- a/appview/pages/templates/repo/issues/fragments/editIssueComment.html +++ b/appview/pages/templates/repo/issues/fragments/editIssueComment.html @@ -5,7 +5,7 @@ name="body" class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" rows="5" - autofocus>{{ .Comment.Body }} + autofocus>{{ .Comment.EditableBody }} {{ template "editActions" $ }}
diff --git a/appview/pages/templates/repo/issues/fragments/issueCommentBody.html b/appview/pages/templates/repo/issues/fragments/issueCommentBody.html index 0dbababe..093eb903 100644 --- a/appview/pages/templates/repo/issues/fragments/issueCommentBody.html +++ b/appview/pages/templates/repo/issues/fragments/issueCommentBody.html @@ -1,7 +1,7 @@ {{ define "repo/issues/fragments/issueCommentBody" }}
{{ if not .Comment.Deleted }} -
{{ .Comment.Body | markdown }}
+
{{ .Comment.Body.Text | markdown }}
{{ else }}
[deleted by author]
{{ end }} diff --git a/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html b/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html index 538b0765..be137904 100644 --- a/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html +++ b/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html @@ -1,13 +1,15 @@ {{ define "repo/issues/fragments/issueCommentHeader" }}
- {{ $handle := resolve .Comment.Did }} + {{ $handle := resolve .Comment.Did.String }} {{ $handle }} {{ template "hats" $ }} {{ template "timestamp" . }} - {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} {{ if and $isCommentOwner (not .Comment.Deleted) }} - {{ template "editIssueComment" . }} + {{ if not .Comment.IsLegacy }} + {{ template "editIssueComment" . }} + {{ end }} {{ template "deleteIssueComment" . }} {{ end }}
diff --git a/appview/pages/templates/repo/issues/fragments/replyComment.html b/appview/pages/templates/repo/issues/fragments/replyComment.html index fc87ccc9..e80b0cfe 100644 --- a/appview/pages/templates/repo/issues/fragments/replyComment.html +++ b/appview/pages/templates/repo/issues/fragments/replyComment.html @@ -18,12 +18,20 @@ + {{ template "replyActions" . }} {{ end }} diff --git a/appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html b/appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html index 2f812d6c..549748ee 100644 --- a/appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html +++ b/appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html @@ -1,5 +1,10 @@ {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }}
+ {{ if .Comment.IsLegacy }} + {{ if .LoggedInUser }} + Can't reply to legacy comment. + {{ end }} + {{ else }} {{ if .LoggedInUser }} {{ template "user/fragments/pic" (list .LoggedInUser.Did "size-8 mr-1") }} {{ end }} @@ -12,5 +17,6 @@ hx-swap="outerHTML" > + {{ end }}
{{ end }} diff --git a/appview/validator/issue.go b/appview/validator/issue.go index b199f513..9d0edcbd 100644 --- a/appview/validator/issue.go +++ b/appview/validator/issue.go @@ -4,36 +4,9 @@ import ( "fmt" "strings" - "tangled.org/core/appview/db" "tangled.org/core/appview/models" - "tangled.org/core/orm" ) -func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { - // if comments have parents, only ingest ones that are 1 level deep - if comment.ReplyTo != nil { - parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) - if err != nil { - return fmt.Errorf("failed to fetch parent comment: %w", err) - } - if len(parents) != 1 { - return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) - } - - // depth check - parent := parents[0] - if parent.ReplyTo != nil { - return fmt.Errorf("incorrect depth, this comment is replying at depth >1") - } - } - - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { - return fmt.Errorf("body is empty after HTML sanitization") - } - - return nil -} - func (v *Validator) ValidateIssue(issue *models.Issue) error { if issue.Title == "" { return fmt.Errorf("issue title is empty") -- 2.51.2 From 367138e5f3328873a840b71da260cfecd772f2d8 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Mon, 8 Dec 2025 23:34:14 +0900 Subject: [PATCH 05/10] appview/notify: merge new comment events into one Signed-off-by: Seongmin Lee --- appview/issues/issues.go | 2 +- appview/notify/db/db.go | 273 +++++++++++++++-------------- appview/notify/logging/notifier.go | 19 +- appview/notify/merged_notifier.go | 16 +- appview/notify/notifier.go | 15 +- appview/notify/posthog/notifier.go | 22 +-- appview/pulls/pulls.go | 2 +- 7 files changed, 168 insertions(+), 181 deletions(-) diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 56b13b2e..d3689cb9 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -537,7 +537,7 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { return } - rp.notifier.NewIssueComment(r.Context(), &comment, mentions) + rp.notifier.NewComment(r.Context(), &comment, mentions) ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) diff --git a/appview/notify/db/db.go b/appview/notify/db/db.go index ee44999e..84909884 100644 --- a/appview/notify/db/db.go +++ b/appview/notify/db/db.go @@ -80,48 +80,135 @@ func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { // no-op } -func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { +func (n *databaseNotifier) NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { l := log.FromContext(ctx) - collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) - if err != nil { - l.Error("failed to fetch collaborators", "err", err) - return - } + var ( + // built the recipients list: + // - the owner of the repo + // - | if the comment is a reply -> everybody on that thread + // | if the comment is a top level -> just the issue owner + // - remove mentioned users from the recipients list + recipients = sets.New[syntax.DID]() + entityType string + entityId string + repoId *int64 + issueId *int64 + pullId *int64 + ) - // build the recipients list - // - owner of the repo - // - collaborators in the repo - // - remove users already mentioned - recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) - for _, c := range collaborators { - recipients.Insert(c.SubjectDid) - } - for _, m := range mentions { - recipients.Remove(m) - } + subjectAt := syntax.ATURI(comment.Subject.Uri) - actorDid := syntax.DID(issue.Did) - entityType := "issue" - entityId := issue.AtUri().String() - repoId := &issue.Repo.Id - issueId := &issue.Id - var pullId *int64 + switch subjectAt.Collection() { + case tangled.RepoIssueNSID: + issues, err := db.GetIssues( + n.db, + orm.FilterEq("at_uri", subjectAt), + ) + if err != nil { + l.Error("failed to get issues", "err", err) + return + } + if len(issues) == 0 { + l.Error("no issue found", "subject", comment.Subject) + return + } + issue := issues[0] + + recipients.Insert(syntax.DID(issue.Repo.Did)) + if comment.IsReply() { + // if this comment is a reply, then notify everybody in that thread + parent := *comment.ReplyTo + + // find the parent thread, and add all DIDs from here to the recipient list + for _, t := range issue.CommentList() { + if t.Self.AtUri() == syntax.ATURI(parent.Uri) { + for _, p := range t.Participants() { + recipients.Insert(p) + } + } + } + } else { + // not a reply, notify just the issue author + recipients.Insert(syntax.DID(issue.Did)) + } + + entityType = "issue" + entityId = issue.AtUri().String() + repoId = &issue.Repo.Id + issueId = &issue.Id + + for _, m := range mentions { + recipients.Remove(m) + } + + n.notifyEvent( + ctx, + comment.Did, + recipients, + models.NotificationTypeIssueCommented, + entityType, + entityId, + repoId, + issueId, + pullId, + ) + + case tangled.RepoPullNSID: + pulls, err := db.GetPulls( + n.db, + orm.FilterEq("owner_did", subjectAt.Authority()), + orm.FilterEq("rkey", subjectAt.RecordKey()), + ) + if err != nil { + l.Error("NewComment: failed to get pulls", "err", err) + return + } + if len(pulls) == 0 { + l.Error("NewComment: no pull found", "aturi", comment.Subject) + return + } + pull := pulls[0] + + pull.Repo, err = db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) + if err != nil { + l.Error("NewComment: failed to get repo", "err", err) + return + } + + recipients.Insert(syntax.DID(pull.Repo.Did)) + for _, p := range pull.Participants() { + recipients.Insert(syntax.DID(p)) + } + + entityType = "pull" + entityId = pull.AtUri().String() + repoId = &pull.Repo.Id + p := int64(pull.ID) + pullId = &p + + for _, m := range mentions { + recipients.Remove(m) + } + + n.notifyEvent( + ctx, + comment.Did, + recipients, + models.NotificationTypePullCommented, + entityType, + entityId, + repoId, + issueId, + pullId, + ) + default: + return // no-op + } n.notifyEvent( ctx, - actorDid, - recipients, - models.NotificationTypeIssueCreated, - entityType, - entityId, - repoId, - issueId, - pullId, - ) - n.notifyEvent( - ctx, - actorDid, + comment.Did, sets.Collect(slices.Values(mentions)), models.NotificationTypeUserMentioned, entityType, @@ -132,49 +219,32 @@ func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, me ) } -func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { +func (n *databaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { + // no-op +} + +func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { l := log.FromContext(ctx) - issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.Subject)) + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) if err != nil { - l.Error("failed to get issues", "err", err) - return - } - if len(issues) == 0 { - l.Error("no issue found for", "err", comment.Subject) + l.Error("failed to fetch collaborators", "err", err) return } - issue := issues[0] - // built the recipients list: - // - the owner of the repo - // - | if the comment is a reply -> everybody on that thread - // | if the comment is a top level -> just the issue owner - // - remove mentioned users from the recipients list + // build the recipients list + // - owner of the repo + // - collaborators in the repo + // - remove users already mentioned recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) - - if comment.IsReply() { - // if this comment is a reply, then notify everybody in that thread - parent := *comment.ReplyTo - - // find the parent thread, and add all DIDs from here to the recipient list - for _, t := range issue.CommentList() { - if t.Self.AtUri() == syntax.ATURI(parent.Uri) { - for _, p := range t.Participants() { - recipients.Insert(p) - } - } - } - } else { - // not a reply, notify just the issue author - recipients.Insert(syntax.DID(issue.Did)) + for _, c := range collaborators { + recipients.Insert(c.SubjectDid) } - for _, m := range mentions { recipients.Remove(m) } - actorDid := syntax.DID(comment.Did) + actorDid := syntax.DID(issue.Did) entityType := "issue" entityId := issue.AtUri().String() repoId := &issue.Repo.Id @@ -185,7 +255,7 @@ func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models. ctx, actorDid, recipients, - models.NotificationTypeIssueCommented, + models.NotificationTypeIssueCreated, entityType, entityId, repoId, @@ -281,75 +351,6 @@ func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { ) } -func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { - l := log.FromContext(ctx) - - subjectAt := syntax.ATURI(comment.Subject.Uri) - pulls, err := db.GetPulls(n.db, - orm.FilterEq("owner_did", subjectAt.Authority()), - orm.FilterEq("rkey", subjectAt.RecordKey()), - ) - if err != nil { - l.Error("failed to get pull", "err", err) - return - } - if len(pulls) == 0 { - l.Error("NewPullComment: no pull found", "aturi", comment.Subject) - return - } - pull := pulls[0] - - repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) - if err != nil { - l.Error("failed to get repos", "err", err) - return - } - - // build up the recipients list: - // - repo owner - // - all pull participants - // - remove those already mentioned - recipients := sets.Singleton(syntax.DID(repo.Did)) - for _, p := range pull.Participants() { - recipients.Insert(syntax.DID(p)) - } - for _, m := range mentions { - recipients.Remove(m) - } - - actorDid := comment.Did - eventType := models.NotificationTypePullCommented - entityType := "pull" - entityId := pull.AtUri().String() - repoId := &repo.Id - var issueId *int64 - p := int64(pull.ID) - pullId := &p - - n.notifyEvent( - ctx, - actorDid, - recipients, - eventType, - entityType, - entityId, - repoId, - issueId, - pullId, - ) - n.notifyEvent( - ctx, - actorDid, - sets.Collect(slices.Values(mentions)), - models.NotificationTypeUserMentioned, - entityType, - entityId, - repoId, - issueId, - pullId, - ) -} - func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { // no-op } diff --git a/appview/notify/logging/notifier.go b/appview/notify/logging/notifier.go index 874f2c26..6e771411 100644 --- a/appview/notify/logging/notifier.go +++ b/appview/notify/logging/notifier.go @@ -41,16 +41,20 @@ func (l *loggingNotifier) DeleteStar(ctx context.Context, star *models.Star) { l.inner.DeleteStar(ctx, star) } +func (l *loggingNotifier) NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewComment")) + l.inner.NewComment(ctx, comment, mentions) +} +func (l *loggingNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { + ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "DeleteComment")) + l.inner.DeleteComment(ctx, comment) +} + func (l *loggingNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewIssue")) l.inner.NewIssue(ctx, issue, mentions) } -func (l *loggingNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { - ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewIssueComment")) - l.inner.NewIssueComment(ctx, comment, mentions) -} - func (l *loggingNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewIssueState")) l.inner.NewIssueState(ctx, actor, issue) @@ -86,11 +90,6 @@ func (l *loggingNotifier) NewPull(ctx context.Context, pull *models.Pull) { l.inner.NewPull(ctx, pull) } -func (l *loggingNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { - ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPullComment")) - l.inner.NewPullComment(ctx, comment, mentions) -} - func (l *loggingNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPullState")) l.inner.NewPullState(ctx, actor, pull) diff --git a/appview/notify/merged_notifier.go b/appview/notify/merged_notifier.go index 43cf0aca..a0f0be2b 100644 --- a/appview/notify/merged_notifier.go +++ b/appview/notify/merged_notifier.go @@ -46,12 +46,16 @@ func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { m.fanout(func(n Notifier) { n.DeleteStar(ctx, star) }) } -func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { - m.fanout(func(n Notifier) { n.NewIssue(ctx, issue, mentions) }) +func (m *mergedNotifier) NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { + m.fanout(func(n Notifier) { n.NewComment(ctx, comment, mentions) }) +} + +func (m *mergedNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { + m.fanout(func(n Notifier) { n.DeleteComment(ctx, comment) }) } -func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { - m.fanout(func(n Notifier) { n.NewIssueComment(ctx, comment, mentions) }) +func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { + m.fanout(func(n Notifier) { n.NewIssue(ctx, issue, mentions) }) } func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { @@ -82,10 +86,6 @@ func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { m.fanout(func(n Notifier) { n.NewPull(ctx, pull) }) } -func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { - m.fanout(func(n Notifier) { n.NewPullComment(ctx, comment, mentions) }) -} - func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { m.fanout(func(n Notifier) { n.NewPullState(ctx, actor, pull) }) } diff --git a/appview/notify/notifier.go b/appview/notify/notifier.go index 572832f8..15efa51e 100644 --- a/appview/notify/notifier.go +++ b/appview/notify/notifier.go @@ -14,8 +14,10 @@ type Notifier interface { NewStar(ctx context.Context, star *models.Star) DeleteStar(ctx context.Context, star *models.Star) + NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) + DeleteComment(ctx context.Context, comment *models.Comment) + NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) - NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) DeleteIssue(ctx context.Context, issue *models.Issue) @@ -23,7 +25,6 @@ type Notifier interface { DeleteFollow(ctx context.Context, follow *models.Follow) NewPull(ctx context.Context, pull *models.Pull) - NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) NewIssueLabelOp(ctx context.Context, issue *models.Issue) @@ -51,9 +52,11 @@ func (m *BaseNotifier) DeleteRepo(ctx context.Context, repo *models.Repo) {} func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} -func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} -func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { +func (m *BaseNotifier) NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { } +func (m *BaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {} + +func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} @@ -63,9 +66,7 @@ func (m *BaseNotifier) NewPullLabelOp(ctx context.Context, pull *models.Pull) func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} -func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} -func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.Comment, mentions []syntax.DID) { -} +func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} diff --git a/appview/notify/posthog/notifier.go b/appview/notify/posthog/notifier.go index 5f16e5dc..5ad61a18 100644 --- a/appview/notify/posthog/notifier.go +++ b/appview/notify/posthog/notifier.go @@ -86,20 +86,6 @@ func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { } } -func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { - err := n.client.Enqueue(posthog.Capture{ - DistinctId: comment.Did.String(), - Event: "new_pull_comment", - Properties: posthog.Properties{ - "pull_at": comment.Subject, - "mentions": mentions, - }, - }) - if err != nil { - log.Println("failed to enqueue posthog event:", err) - } -} - func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { err := n.client.Enqueue(posthog.Capture{ DistinctId: pull.OwnerDid, @@ -190,13 +176,13 @@ func (n *posthogNotifier) Clone(ctx context.Context, repo *models.Repo) { } } -func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { +func (n *posthogNotifier) NewComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { err := n.client.Enqueue(posthog.Capture{ DistinctId: comment.Did.String(), - Event: "new_issue_comment", + Event: "new_comment", Properties: posthog.Properties{ - "issue_at": comment.Subject.Uri, - "mentions": mentions, + "subject_at": comment.Subject.Uri, + "mentions": mentions, }, }) if err != nil { diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index 14a89521..64057b29 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -948,7 +948,7 @@ func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { return } - s.notifier.NewPullComment(r.Context(), &comment, mentions) + s.notifier.NewComment(r.Context(), &comment, mentions) ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) -- 2.51.2 From eae53cf82166f3ce503d2873727125becfe5eb5c Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Wed, 1 Apr 2026 19:45:14 +0900 Subject: [PATCH 06/10] appview: unified comment fragments/handlers share as much handlers/fragments as possible. PR has still `/.../comment` endpoint to serve comment form htmx fragment. Due to how it is designed. Signed-off-by: Seongmin Lee --- appview/db/comments.go | 11 + appview/ingester.go | 7 +- appview/issues/issues.go | 468 +----------------- appview/issues/router.go | 9 - appview/models/comment.go | 10 +- appview/pages/pages.go | 33 +- .../comment/commentBody.html} | 4 +- .../comment/commentHeader.html} | 26 +- .../comment/edit.html} | 31 +- .../templates/fragments/comment/reply.html | 50 ++ .../fragments/comment/replyPlaceholder.html | 15 + .../repo/issues/fragments/commentList.html | 38 +- .../repo/issues/fragments/newComment.html | 53 +- .../repo/issues/fragments/replyComment.html | 66 --- .../replyIssueCommentPlaceholder.html | 22 - .../repo/pulls/fragments/pullActions.html | 26 +- .../repo/pulls/fragments/pullNewComment.html | 26 +- appview/pages/templates/repo/pulls/pull.html | 21 +- appview/pulls/pulls.go | 129 +---- appview/pulls/router.go | 5 +- appview/state/comment.go | 396 +++++++++++++++ appview/state/router.go | 10 + appview/strings/strings.go | 6 - 23 files changed, 645 insertions(+), 817 deletions(-) rename appview/pages/templates/{repo/issues/fragments/issueCommentBody.html => fragments/comment/commentBody.html} (73%) rename appview/pages/templates/{repo/issues/fragments/issueCommentHeader.html => fragments/comment/commentHeader.html} (73%) rename appview/pages/templates/{repo/issues/fragments/editIssueComment.html => fragments/comment/edit.html} (56%) create mode 100644 appview/pages/templates/fragments/comment/reply.html create mode 100644 appview/pages/templates/fragments/comment/replyPlaceholder.html delete mode 100644 appview/pages/templates/repo/issues/fragments/replyComment.html delete mode 100644 appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html create mode 100644 appview/state/comment.go diff --git a/appview/db/comments.go b/appview/db/comments.go index b71dafd7..27faf1cc 100644 --- a/appview/db/comments.go +++ b/appview/db/comments.go @@ -154,6 +154,17 @@ func DeleteComments(e Execer, filters ...orm.Filter) error { return err } +func GetComment(e Execer, filters ...orm.Filter) (models.Comment, error) { + comments, err := GetComments(e, filters...) + if err != nil { + return models.Comment{}, err + } + if len(comments) != 1 { + return models.Comment{}, fmt.Errorf("expected 1 comment, got %d", len(comments)) + } + return comments[0], nil +} + func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { var comments []models.Comment diff --git a/appview/ingester.go b/appview/ingester.go index 106358fc..aa5aad04 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -1188,9 +1188,10 @@ func (i *Ingester) ingestComment(e *jmodels.Event) error { return fmt.Errorf("failed to validate comment: %w", err) } + var mentions []syntax.DID var references []syntax.ATURI if comment.Body.Original != nil { - _, references = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) + mentions, references = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) } tx, err := ddb.Begin() @@ -1208,6 +1209,10 @@ func (i *Ingester) ingestComment(e *jmodels.Event) error { return err } + if e.Commit.Operation == jmodels.CommitOperationCreate { + i.Notifier.NewComment(ctx, comment, mentions) + } + case jmodels.CommitOperationDelete: if err := db.DeleteComments( ddb, diff --git a/appview/issues/issues.go b/appview/issues/issues.go index d3689cb9..a54f128c 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -13,8 +13,6 @@ import ( "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" - indigoxrpc "github.com/bluesky-social/indigo/xrpc" - "github.com/go-chi/chi/v5" "tangled.org/core/api/tangled" "tangled.org/core/appview/config" @@ -133,7 +131,7 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { defs[l.AtUri().String()] = &l } - rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ + err = rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ LoggedInUser: user, RepoInfo: rp.repoResolver.GetRepoInfo(r, user), Issue: issue, @@ -143,6 +141,9 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { UserReacted: userReactions, LabelDefs: defs, }) + if err != nil { + l.Error("failed to render", "err", err) + } } func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { @@ -389,467 +390,6 @@ func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { } } -func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "NewIssueComment") - user := rp.oauth.GetMultiAccountUser(r) - f, err := rp.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - return - } - - issue, ok := r.Context().Value("issue").(*models.Issue) - if !ok { - l.Error("failed to get issue") - rp.pages.Error404(w) - return - } - - body := r.FormValue("body") - if body == "" { - rp.pages.Notice(w, "issue-comment", "Body is required") - return - } - - // TODO(boltless): normalize markdown body - normalizedBody := body - _, references := rp.mentionsResolver.Resolve(r.Context(), body) - - markdownBody := tangled.MarkupMarkdown{ - Text: normalizedBody, - Original: &body, - Blobs: nil, - } - - // ingest CID of issue record on-demand. - // TODO(boltless): appview should ingest CID of atproto records - cid, err := func() (syntax.CID, error) { - ident, err := rp.idResolver.ResolveIdent(r.Context(), issue.Did) - if err != nil { - return "", err - } - - xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} - out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) - if err != nil { - return "", err - } - if out.Cid == nil { - return "", fmt.Errorf("record CID is empty") - } - - cid, err := syntax.ParseCID(*out.Cid) - if err != nil { - return "", err - } - - return cid, nil - }() - if err != nil { - rp.logger.Error("failed to backfill subject PR record", "err", err) - rp.pages.Notice(w, "issue-comment", "failed to backfill subject record") - return - } - issueStrongRef := comatproto.RepoStrongRef{ - Uri: issue.AtUri().String(), - Cid: cid.String(), - } - - var replyTo *comatproto.RepoStrongRef - replyToUriRaw := r.FormValue("reply-to-uri") - replyToCidRaw := r.FormValue("reply-to-cid") - if replyToUriRaw != "" && replyToCidRaw != "" { - uri, err := syntax.ParseATURI(replyToUriRaw) - if err != nil { - rp.pages.Notice(w, "issue-comment", "reply-to-uri should be valid AT-URI") - return - } - cid, err := syntax.ParseCID(replyToCidRaw) - if err != nil { - rp.pages.Notice(w, "issue-comment", "reply-to-cid should be valid CID") - return - } - replyTo = &comatproto.RepoStrongRef{ - Uri: uri.String(), - Cid: cid.String(), - } - } - - mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) - - comment := models.Comment{ - Did: syntax.DID(user.Active.Did), - Collection: tangled.FeedCommentNSID, - Rkey: syntax.RecordKey(tid.TID()), - - Subject: issueStrongRef, - Body: markdownBody, - Created: time.Now(), - ReplyTo: replyTo, - } - if err = comment.Validate(); err != nil { - l.Error("failed to validate comment", "err", err) - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") - return - } - - client, err := rp.oauth.AuthorizedClient(r) - if err != nil { - l.Error("failed to get authorized client", "err", err) - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") - return - } - - // create a record first - out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: comment.Collection.String(), - Repo: comment.Did.String(), - Rkey: comment.Rkey.String(), - Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, - }) - if err != nil { - l.Error("failed to create comment", "err", err) - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") - return - } - - comment.Cid = syntax.CID(out.Cid) - - tx, err := rp.db.Begin() - if err != nil { - l.Error("failed to start transaction", "err", err) - rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") - return - } - defer tx.Rollback() - - err = db.PutComment(tx, &comment, references) - if err != nil { - l.Error("failed to create comment", "err", err) - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") - return - } - - err = tx.Commit() - if err != nil { - l.Error("failed to commit transaction", "err", err) - rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") - return - } - - rp.notifier.NewComment(r.Context(), &comment, mentions) - - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) -} - -func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "IssueComment") - user := rp.oauth.GetMultiAccountUser(r) - - issue, ok := r.Context().Value("issue").(*models.Issue) - if !ok { - l.Error("failed to get issue") - rp.pages.Error404(w) - return - } - - commentId := chi.URLParam(r, "commentId") - comments, err := db.GetComments( - rp.db, - orm.FilterEq("id", commentId), - ) - if err != nil { - l.Error("failed to fetch comment", "id", commentId) - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) - return - } - if len(comments) != 1 { - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) - http.Error(w, "invalid comment id", http.StatusBadRequest) - return - } - comment := comments[0] - - rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ - LoggedInUser: user, - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), - Issue: issue, - Comment: &comment, - }) -} - -func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "EditIssueComment") - user := rp.oauth.GetMultiAccountUser(r) - - issue, ok := r.Context().Value("issue").(*models.Issue) - if !ok { - l.Error("failed to get issue") - rp.pages.Error404(w) - return - } - - commentId := chi.URLParam(r, "commentId") - comments, err := db.GetComments( - rp.db, - orm.FilterEq("id", commentId), - ) - if err != nil { - l.Error("failed to fetch comment", "id", commentId) - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) - return - } - if len(comments) != 1 { - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) - http.Error(w, "invalid comment id", http.StatusBadRequest) - return - } - comment := comments[0] - - if comment.Did.String() != user.Active.Did { - l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) - return - } - - switch r.Method { - case http.MethodGet: - rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ - LoggedInUser: user, - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), - Issue: issue, - Comment: &comment, - }) - case http.MethodPost: - // extract form value - body := r.FormValue("body") - if body == "" { - rp.pages.Notice(w, "issue-comment", "Body is required") - return - } - - // TODO(boltless): normalize markdown body - normalizedBody := body - _, references := rp.mentionsResolver.Resolve(r.Context(), body) - - now := time.Now() - newComment := comment - newComment.Body = tangled.MarkupMarkdown{ - Text: normalizedBody, - Original: &body, - Blobs: nil, - } - newComment.Edited = &now - - client, err := rp.oauth.AuthorizedClient(r) - if err != nil { - l.Error("failed to get authorized client", "err", err) - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") - return - } - - // update a record first - exCid := comment.Cid.String() - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: newComment.Collection.String(), - Repo: newComment.Did.String(), - Rkey: newComment.Rkey.String(), - SwapRecord: &exCid, - Record: &lexutil.LexiconTypeDecoder{ - Val: newComment.AsRecord(), - }, - }) - if err != nil { - l.Error("failed to update comment", "err", err) - rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") - return - } - - newComment.Cid = syntax.CID(resp.Cid) - - tx, err := rp.db.Begin() - if err != nil { - l.Error("failed to start transaction", "err", err) - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") - return - } - defer tx.Rollback() - - err = db.PutComment(tx, &newComment, references) - if err != nil { - l.Error("failed to perform update-description query", "err", err) - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") - return - } - err = tx.Commit() - if err != nil { - l.Error("failed to commit transaction", "err", err) - rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") - return - } - - // return new comment body with htmx - rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ - LoggedInUser: user, - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), - Issue: issue, - Comment: &newComment, - }) - } -} - -func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") - user := rp.oauth.GetMultiAccountUser(r) - - issue, ok := r.Context().Value("issue").(*models.Issue) - if !ok { - l.Error("failed to get issue") - rp.pages.Error404(w) - return - } - - commentId := chi.URLParam(r, "commentId") - comments, err := db.GetComments( - rp.db, - orm.FilterEq("id", commentId), - ) - if err != nil { - l.Error("failed to fetch comment", "id", commentId) - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) - return - } - if len(comments) != 1 { - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) - http.Error(w, "invalid comment id", http.StatusBadRequest) - return - } - comment := comments[0] - - rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ - LoggedInUser: user, - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), - Issue: issue, - Comment: &comment, - }) -} - -func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "ReplyIssueComment") - user := rp.oauth.GetMultiAccountUser(r) - - issue, ok := r.Context().Value("issue").(*models.Issue) - if !ok { - l.Error("failed to get issue") - rp.pages.Error404(w) - return - } - - commentId := chi.URLParam(r, "commentId") - comments, err := db.GetComments( - rp.db, - orm.FilterEq("id", commentId), - ) - if err != nil { - l.Error("failed to fetch comment", "id", commentId) - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) - return - } - if len(comments) != 1 { - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) - http.Error(w, "invalid comment id", http.StatusBadRequest) - return - } - comment := comments[0] - - rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ - LoggedInUser: user, - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), - Issue: issue, - Comment: &comment, - }) -} - -func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { - l := rp.logger.With("handler", "DeleteIssueComment") - user := rp.oauth.GetMultiAccountUser(r) - - issue, ok := r.Context().Value("issue").(*models.Issue) - if !ok { - l.Error("failed to get issue") - rp.pages.Error404(w) - return - } - - commentId := chi.URLParam(r, "commentId") - comments, err := db.GetComments( - rp.db, - orm.FilterEq("id", commentId), - ) - if err != nil { - l.Error("failed to fetch comment", "id", commentId) - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) - return - } - if len(comments) != 1 { - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) - http.Error(w, "invalid comment id", http.StatusBadRequest) - return - } - comment := comments[0] - - if comment.Did.String() != user.Active.Did { - l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) - return - } - - if comment.Deleted != nil { - http.Error(w, "comment already deleted", http.StatusBadRequest) - return - } - - // optimistic deletion - deleted := time.Now() - err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) - if err != nil { - l.Error("failed to delete comment", "err", err) - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") - return - } - - // delete from pds - if comment.Rkey != "" { - client, err := rp.oauth.AuthorizedClient(r) - if err != nil { - l.Error("failed to get authorized client", "err", err) - rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") - return - } - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ - Collection: comment.Collection.String(), - Repo: comment.Did.String(), - Rkey: comment.Rkey.String(), - }) - if err != nil { - l.Error("failed to delete from PDS", "err", err) - } - } - - // optimistic update for htmx - comment.Body = tangled.MarkupMarkdown{} - comment.Deleted = &deleted - - // htmx fragment of comment after deletion - rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ - LoggedInUser: user, - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), - Issue: issue, - Comment: &comment, - }) -} - func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "RepoIssues") diff --git a/appview/issues/router.go b/appview/issues/router.go index e0e5dba4..6ab92330 100644 --- a/appview/issues/router.go +++ b/appview/issues/router.go @@ -21,15 +21,6 @@ func (i *Issues) Router(mw *middleware.Middleware) http.Handler { // authenticated routes r.Group(func(r chi.Router) { r.Use(middleware.AuthMiddleware(i.oauth)) - r.Post("/comment", i.NewIssueComment) - r.Route("/comment/{commentId}/", func(r chi.Router) { - r.Get("/", i.IssueComment) - r.Delete("/", i.DeleteIssueComment) - r.Get("/edit", i.EditIssueComment) - r.Post("/edit", i.EditIssueComment) - r.Get("/reply", i.ReplyIssueComment) - r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) - }) r.Get("/edit", i.EditIssue) r.Post("/edit", i.EditIssue) r.Delete("/", i.DeleteIssue) diff --git a/appview/models/comment.go b/appview/models/comment.go index 3eaeb4f6..75841b4c 100644 --- a/appview/models/comment.go +++ b/appview/models/comment.go @@ -31,18 +31,18 @@ type Comment struct { Deleted *time.Time } -func (c *Comment) AtUri() syntax.ATURI { +func (c Comment) AtUri() syntax.ATURI { return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, c.Collection, c.Rkey)) } -func (c *Comment) StrongRef() comatproto.RepoStrongRef { +func (c Comment) StrongRef() comatproto.RepoStrongRef { return comatproto.RepoStrongRef{ Uri: c.AtUri().String(), Cid: c.Cid.String(), } } -func (c *Comment) AsRecord() typegen.CBORMarshaler { +func (c Comment) AsRecord() typegen.CBORMarshaler { // can't convert to record for legacy types if c.Collection != tangled.FeedCommentNSID { return nil @@ -60,14 +60,14 @@ func (c *Comment) AsRecord() typegen.CBORMarshaler { } } -func (c *Comment) EditableBody() string { +func (c Comment) EditableBody() string { if c.Body.Original != nil { return *c.Body.Original } return c.Body.Text } -func (c *Comment) IsLegacy() bool { +func (c Comment) IsLegacy() bool { return c.Collection != tangled.FeedCommentNSID } diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 61d8a3ef..27673b97 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1210,7 +1210,6 @@ func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentP type IssueCommentBodyParams struct { LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo - Issue *models.Issue Comment *models.Comment } @@ -1621,6 +1620,38 @@ func (p *Pages) Home(w io.Writer, params TimelineParams) error { return p.execute("timeline/home", w, params) } +type CommentBodyFragmentParams struct { + Comment models.Comment +} + +func (p *Pages) CommentBodyFragment(w io.Writer, params CommentBodyFragmentParams) error { + return p.executePlain("fragments/comment/commentBody", w, params) +} + +type EditCommentFragmentParams struct { + Comment models.Comment +} + +func (p *Pages) EditCommentFragment(w io.Writer, params EditCommentFragmentParams) error { + return p.executePlain("fragments/comment/edit", w, params) +} + +type ReplyCommentFragmentParams struct { + LoggedInUser *oauth.MultiAccountUser +} + +func (p *Pages) ReplyCommentFragment(w io.Writer, params ReplyCommentFragmentParams) error { + return p.executePlain("fragments/comment/reply", w, params) +} + +type ReplyPlaceholderFragmentParams struct { + LoggedInUser *oauth.MultiAccountUser +} + +func (p *Pages) ReplyPlaceholderFragment(w io.Writer, params ReplyPlaceholderFragmentParams) error { + return p.executePlain("fragments/comment/replyPlaceholder", w, params) +} + func (p *Pages) Static() http.Handler { if p.dev { return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) diff --git a/appview/pages/templates/repo/issues/fragments/issueCommentBody.html b/appview/pages/templates/fragments/comment/commentBody.html similarity index 73% rename from appview/pages/templates/repo/issues/fragments/issueCommentBody.html rename to appview/pages/templates/fragments/comment/commentBody.html index 093eb903..1fcfe931 100644 --- a/appview/pages/templates/repo/issues/fragments/issueCommentBody.html +++ b/appview/pages/templates/fragments/comment/commentBody.html @@ -1,5 +1,5 @@ -{{ define "repo/issues/fragments/issueCommentBody" }} -
+{{ define "fragments/comment/commentBody" }} +
{{ if not .Comment.Deleted }}
{{ .Comment.Body.Text | markdown }}
{{ else }} diff --git a/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html b/appview/pages/templates/fragments/comment/commentHeader.html similarity index 73% rename from appview/pages/templates/repo/issues/fragments/issueCommentHeader.html rename to appview/pages/templates/fragments/comment/commentHeader.html index be137904..4729a626 100644 --- a/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html +++ b/appview/pages/templates/fragments/comment/commentHeader.html @@ -1,5 +1,8 @@ -{{ define "repo/issues/fragments/issueCommentHeader" }} -
+{{ define "fragments/comment/commentHeader" }} +
{{ $handle := resolve .Comment.Did.String }} {{ $handle }} {{ template "hats" $ }} @@ -8,9 +11,9 @@ {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} {{ if and $isCommentOwner (not .Comment.Deleted) }} {{ if not .Comment.IsLegacy }} - {{ template "editIssueComment" . }} + {{ template "editCommentBtn" . }} {{ end }} - {{ template "deleteIssueComment" . }} + {{ template "deleteCommentBtn" . }} {{ end }}
{{ end }} @@ -36,25 +39,22 @@ {{ end }} -{{ define "editIssueComment" }} +{{ define "editCommentBtn" }} + hx-get="/comment/edit?aturi={{ .Comment.AtUri }}" + > {{ i "pencil" "size-3 inline group-[.htmx-request]:hidden" }} {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} {{ end }} -{{ define "deleteIssueComment" }} +{{ define "deleteCommentBtn" }} + > {{ i "trash-2" "size-3 inline group-[.htmx-request]:hidden" }} {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} diff --git a/appview/pages/templates/repo/issues/fragments/editIssueComment.html b/appview/pages/templates/fragments/comment/edit.html similarity index 56% rename from appview/pages/templates/repo/issues/fragments/editIssueComment.html rename to appview/pages/templates/fragments/comment/edit.html index ca7354b5..1ab0d0c0 100644 --- a/appview/pages/templates/repo/issues/fragments/editIssueComment.html +++ b/appview/pages/templates/fragments/comment/edit.html @@ -1,14 +1,21 @@ -{{ define "repo/issues/fragments/editIssueComment" }} -
+{{ define "fragments/comment/edit" }} +
+ - +
{{ template "editActions" $ }} -
+ {{ end }} {{ define "editActions" }} @@ -20,12 +27,9 @@ {{ define "save" }} diff --git a/appview/pages/templates/fragments/comment/reply.html b/appview/pages/templates/fragments/comment/reply.html new file mode 100644 index 00000000..8b564b0d --- /dev/null +++ b/appview/pages/templates/fragments/comment/reply.html @@ -0,0 +1,50 @@ +{{ define "fragments/comment/reply" }} +
+ {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} + +
+ {{ template "replyActions" . }} +
+{{ end }} + +{{ define "replyActions" }} +
+ {{ template "cancel" . }} + {{ template "reply" . }} +
+{{ end }} + +{{ define "cancel" }} + +{{ end }} + +{{ define "reply" }} + +{{ end }} diff --git a/appview/pages/templates/fragments/comment/replyPlaceholder.html b/appview/pages/templates/fragments/comment/replyPlaceholder.html new file mode 100644 index 00000000..4d7f48b8 --- /dev/null +++ b/appview/pages/templates/fragments/comment/replyPlaceholder.html @@ -0,0 +1,15 @@ +{{ define "fragments/comment/replyPlaceholder" }} +
+ {{ if .LoggedInUser }} + {{ template "user/fragments/pic" (list .LoggedInUser.Did "size-8 mr-1") }} + {{ end }} + +
+{{ end }} diff --git a/appview/pages/templates/repo/issues/fragments/commentList.html b/appview/pages/templates/repo/issues/fragments/commentList.html index 4fd6cd91..9b91a13a 100644 --- a/appview/pages/templates/repo/issues/fragments/commentList.html +++ b/appview/pages/templates/repo/issues/fragments/commentList.html @@ -1,40 +1,48 @@ {{ define "repo/issues/fragments/commentList" }}
{{ range $item := .CommentList }} - {{ template "commentListing" (list $ .) }} + {{ template "commentListItem" (list $ .) }} {{ end }}
{{ end }} -{{ define "commentListing" }} +{{ define "commentListItem" }} {{ $root := index . 0 }} - {{ $comment := index . 1 }} + {{ $item := index . 1 }} {{ $params := (dict - "RepoInfo" $root.RepoInfo "LoggedInUser" $root.LoggedInUser - "Issue" $root.Issue - "Comment" $comment.Self) }} + "Comment" $item.Self) }}
{{ template "topLevelComment" $params }}
- {{ range $index, $reply := $comment.Replies }} + {{ range $index, $reply := $item.Replies }}
{{ template "replyComment" (dict - "RepoInfo" $root.RepoInfo "LoggedInUser" $root.LoggedInUser - "Issue" $root.Issue - "Comment" $reply) + "Comment" $reply) }}
{{ end }}
- {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} +
+ + + + + {{ if $item.Self.IsLegacy }} +
+ Can't reply to legacy comment. +
+ {{ else }} + {{ template "fragments/comment/replyPlaceholder" (dict "LoggedInUser" $root.LoggedInUser) }} + {{ end }} +
{{ end }} @@ -44,8 +52,8 @@ {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }}
- {{ template "repo/issues/fragments/issueCommentHeader" . }} - {{ template "repo/issues/fragments/issueCommentBody" . }} + {{ template "fragments/comment/commentHeader" . }} + {{ template "fragments/comment/commentBody" . }}
{{ end }} @@ -56,8 +64,8 @@ {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }}
- {{ template "repo/issues/fragments/issueCommentHeader" . }} - {{ template "repo/issues/fragments/issueCommentBody" . }} + {{ template "fragments/comment/commentHeader" . }} + {{ template "fragments/comment/commentBody" . }}
{{ end }} diff --git a/appview/pages/templates/repo/issues/fragments/newComment.html b/appview/pages/templates/repo/issues/fragments/newComment.html index e6038136..0641c134 100644 --- a/appview/pages/templates/repo/issues/fragments/newComment.html +++ b/appview/pages/templates/repo/issues/fragments/newComment.html @@ -1,26 +1,27 @@ {{ define "repo/issues/fragments/newComment" }} {{ if .LoggedInUser }}
+
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
- -
+ +
@@ -50,17 +51,6 @@ {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]/close:inline" }} close -
-
- {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }}
-{{ end }} - -{{ define "reply" }} - -{{ end }} diff --git a/appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html b/appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html deleted file mode 100644 index 549748ee..00000000 --- a/appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html +++ /dev/null @@ -1,22 +0,0 @@ -{{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} -
- {{ if .Comment.IsLegacy }} - {{ if .LoggedInUser }} - Can't reply to legacy comment. - {{ end }} - {{ else }} - {{ if .LoggedInUser }} - {{ template "user/fragments/pic" (list .LoggedInUser.Did "size-8 mr-1") }} - {{ end }} - - - {{ end }} -
-{{ end }} diff --git a/appview/pages/templates/repo/pulls/fragments/pullActions.html b/appview/pages/templates/repo/pulls/fragments/pullActions.html index 248694d2..0ff2b5bc 100644 --- a/appview/pages/templates/repo/pulls/fragments/pullActions.html +++ b/appview/pages/templates/repo/pulls/fragments/pullActions.html @@ -22,11 +22,9 @@ {{ $isLastRound := eq $roundNumber $lastIdx }} {{ $isSameRepoBranch := .Pull.IsBranchBased }} {{ $isUpToDate := .ResubmitCheck.No }} -
+
{{ end }} {{ if and $isPushAllowed $isOpen $isLastRound }} - {{ $disabled := "" }} - {{ if $isConflicted }} - {{ $disabled = "disabled" }} - {{ end }} {{ end }} - diff --git a/appview/pages/templates/repo/pulls/pull.html b/appview/pages/templates/repo/pulls/pull.html index bf9864aa..bbb2bfe0 100644 --- a/appview/pages/templates/repo/pulls/pull.html +++ b/appview/pages/templates/repo/pulls/pull.html @@ -592,7 +592,8 @@
{{ range $item.Comments }} - {{ template "submissionComment" . }} + {{/* template "submissionComment" . */}} + {{ template "comment" (dict "LoggedInUser" $root.LoggedInUser "Comment" .) }} {{ end }}
{{ if gt $c 0}} @@ -607,8 +608,10 @@ {{ block "resubmitStatus" $root }} {{ end }} {{ end }}
-
+
{{ if $root.LoggedInUser }} + + {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $root.LoggedInUser @@ -624,6 +627,20 @@ {{ end }} +{{ define "comment" }} +
+ +
+ {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8") }} +
+ +
+ {{ template "fragments/comment/commentHeader" . }} + {{ template "fragments/comment/commentBody" . }} +
+
+{{ end }} + {{ define "submissionComment" }}
diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index 64057b29..9ec474b5 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -288,7 +288,7 @@ func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff diff = patchutil.Interdiff(previousPatch, currentPatch) } - s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ + err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ LoggedInUser: user, RepoInfo: s.repoResolver.GetRepoInfo(r, user), Pull: pull, @@ -308,6 +308,9 @@ func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff LabelDefs: defs, }) + if err != nil { + s.logger.Error("failed to render", "err", err) + } } func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { @@ -804,12 +807,6 @@ func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { l = l.With("user", user.Active.Did) } - f, err := s.repoResolver.Resolve(r) - if err != nil { - l.Error("failed to get repo and knot", "err", err) - return - } - pull, ok := r.Context().Value("pull").(*models.Pull) if !ok { l.Error("failed to get pull") @@ -835,124 +832,6 @@ func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { RoundNumber: roundNumber, }) return - case http.MethodPost: - body := r.FormValue("body") - if body == "" { - s.pages.Notice(w, "pull-comment", "Comment body is required") - return - } - - // TODO(boltless): normalize markdown body - normalizedBody := body - mentions, references := s.mentionsResolver.Resolve(r.Context(), body) - - markdownBody := tangled.MarkupMarkdown{ - Text: normalizedBody, - Original: &body, - Blobs: nil, - } - - // ingest CID of PR record on-demand. - // TODO(boltless): appview should ingest CID of atproto records - cid, err := func() (syntax.CID, error) { - ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) - if err != nil { - return "", err - } - - xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} - out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoPullNSID, pull.OwnerDid, pull.Rkey) - if err != nil { - return "", err - } - if out.Cid == nil { - return "", fmt.Errorf("record CID is empty") - } - - cid, err := syntax.ParseCID(*out.Cid) - if err != nil { - return "", err - } - - return cid, nil - }() - if err != nil { - s.logger.Error("failed to backfill subject PR record", "err", err) - s.pages.Notice(w, "pull-comment", "failed to backfill subject record") - return - } - pullStrongRef := comatproto.RepoStrongRef{ - Uri: pull.AtUri().String(), - Cid: cid.String(), - } - - comment := models.Comment{ - Did: syntax.DID(user.Active.Did), - Collection: tangled.FeedCommentNSID, - Rkey: syntax.RecordKey(tid.TID()), - - Subject: pullStrongRef, - Body: markdownBody, - Created: time.Now(), - ReplyTo: nil, - PullRoundIdx: &roundNumber, - } - if err = comment.Validate(); err != nil { - s.logger.Error("failed to validate comment", "err", err) - s.pages.Notice(w, "pull-comment", "Failed to create comment.") - return - } - - client, err := s.oauth.AuthorizedClient(r) - if err != nil { - s.logger.Error("failed to get authorized client", "err", err) - s.pages.Notice(w, "pull-comment", "Failed to create comment.") - return - } - - out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ - Collection: comment.Collection.String(), - Repo: comment.Did.String(), - Rkey: comment.Rkey.String(), - Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, - }) - if err != nil { - s.logger.Error("failed to create pull comment", "err", err) - s.pages.Notice(w, "pull-comment", "Failed to create comment.") - return - } - - comment.Cid = syntax.CID(out.Cid) - - // Start a transaction - tx, err := s.db.BeginTx(r.Context(), nil) - if err != nil { - l.Error("failed to start transaction", "err", err) - s.pages.Notice(w, "pull-comment", "Failed to create comment.") - return - } - defer tx.Rollback() - - // Create the pull comment in the database - err = db.PutComment(tx, &comment, references) - if err != nil { - l.Error("failed to create pull comment in database", "err", err) - s.pages.Notice(w, "pull-comment", "Failed to create comment.") - return - } - - // Commit the transaction - if err = tx.Commit(); err != nil { - l.Error("failed to commit transaction", "err", err) - s.pages.Notice(w, "pull-comment", "Failed to create comment.") - return - } - - s.notifier.NewComment(r.Context(), &comment, mentions) - - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) - return } } diff --git a/appview/pulls/router.go b/appview/pulls/router.go index c1a0fce4..2425b1d4 100644 --- a/appview/pulls/router.go +++ b/appview/pulls/router.go @@ -29,10 +29,7 @@ func (s *Pulls) Router(mw *middleware.Middleware) http.Handler { r.Get("/", s.RepoPullPatch) r.Get("/interdiff", s.RepoPullInterdiff) r.Get("/actions", s.PullActions) - r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) { - r.Get("/", s.PullComment) - r.Post("/", s.PullComment) - }) + r.Get("/comment", s.PullComment) }) r.Route("/round/{round}.patch", func(r chi.Router) { diff --git a/appview/state/comment.go b/appview/state/comment.go new file mode 100644 index 00000000..4903f7eb --- /dev/null +++ b/appview/state/comment.go @@ -0,0 +1,396 @@ +package state + +import ( + "fmt" + "net/http" + "strconv" + "time" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pages" + "tangled.org/core/orm" + "tangled.org/core/tid" +) + +func (s *State) CommentBodyFragment(w http.ResponseWriter, r *http.Request) { + l := s.logger.With("handler", "CommentBodyFragment") + + commentAt := r.URL.Query().Get("aturi") + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) + if err != nil { + l.Error("failed to fetch comment", "aturi", commentAt) + http.Error(w, "Failed to fetch comment", http.StatusInternalServerError) + return + } + + err = s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ + Comment: comment, + }) + if err != nil { + l.Error("failed to render") + } +} + +func (s *State) EditCommentFragment(w http.ResponseWriter, r *http.Request) { + l := s.logger.With("handler", "EditCommentFragment") + + commentAt := r.URL.Query().Get("aturi") + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) + if err != nil { + l.Error("failed to fetch comment", "aturi", commentAt) + http.Error(w, "Failed to fetch comment", http.StatusInternalServerError) + return + } + + err = s.pages.EditCommentFragment(w, pages.EditCommentFragmentParams{ + Comment: comment, + }) + if err != nil { + l.Error("failed to render") + } +} + +func (s *State) NewReplyCommentFragment(w http.ResponseWriter, r *http.Request) { + s.pages.ReplyCommentFragment(w, pages.ReplyCommentFragmentParams{ + LoggedInUser: s.oauth.GetMultiAccountUser(r), + }) +} + +func (s *State) ReplyPlaceholderFragment(w http.ResponseWriter, r *http.Request) { + s.pages.ReplyPlaceholderFragment(w, pages.ReplyPlaceholderFragmentParams{ + LoggedInUser: s.oauth.GetMultiAccountUser(r), + }) +} + +func (s *State) NewComment(w http.ResponseWriter, r *http.Request) { + l := s.logger.With("handler", "NewComment") + user := s.oauth.GetMultiAccountUser(r) + + noticeId := "comment-error" + ctx := r.Context() + + body := r.FormValue("body") + if body == "" { + s.pages.Notice(w, noticeId, "Body is required") + return + } + + // TODO(boltless): normalize markdown body + normalizedBody := body + _, references := s.mentionsResolver.Resolve(ctx, body) + + markdownBody := tangled.MarkupMarkdown{ + Text: normalizedBody, + Original: &body, + Blobs: nil, + } + + subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri")) + if err != nil { + l.Warn("invalid subject uri", "err", err) + s.pages.Notice(w, noticeId, "Subject URI should be valid AT-URI") + return + } + l = l.With("subject.uri", subjectUri) + + // ingest CID of subject record on-demand. + // TODO(boltless): appview should ingest CID of all atproto records + var subjectCid syntax.CID + if subjectCidRaw := r.FormValue("subject-cid"); subjectCidRaw != "" { + subjectCid, err = syntax.ParseCID(subjectCidRaw) + if err != nil { + l.Warn("invalid subject cid", "err", err) + s.pages.Notice(w, noticeId, "Subject URI should be valid AT-URI") + return + } + } else { + l.Debug("ingesting subject record CID") + subjectCid, err = func(uri syntax.ATURI) (syntax.CID, error) { + ident, err := s.idResolver.ResolveIdent(ctx, uri.Authority().String()) + if err != nil { + return "", err + } + + xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} + out, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) + if err != nil { + return "", err + } + if out.Cid == nil { + return "", fmt.Errorf("record CID is empty") + } + + cid, err := syntax.ParseCID(*out.Cid) + if err != nil { + return "", err + } + + return cid, nil + }(subjectUri) + if err != nil { + l.Error("failed to backfill subject record", "err", err) + s.pages.Notice(w, noticeId, "failed to backfill subject record") + return + } + } + l = l.With("subject.cid", subjectCid) + + subject := comatproto.RepoStrongRef{ + Uri: subjectUri.String(), + Cid: subjectCid.String(), + } + + var pullRoundIdx *int + if pullRoundIdxRaw := r.FormValue("pull-round-idx"); pullRoundIdxRaw != "" { + roundIdx, err := strconv.Atoi(pullRoundIdxRaw) + if err != nil { + l.Warn("invalid round idx", "err", err) + s.pages.Notice(w, noticeId, "pull round index should be valid integer") + return + } + pullRoundIdx = &roundIdx + } + + var replyTo *comatproto.RepoStrongRef + replyToUriRaw := r.FormValue("reply-to-uri") + replyToCidRaw := r.FormValue("reply-to-cid") + if replyToUriRaw != "" && replyToCidRaw != "" { + uri, err := syntax.ParseATURI(replyToUriRaw) + if err != nil { + s.pages.Notice(w, noticeId, "reply-to-uri should be valid AT-URI") + return + } + cid, err := syntax.ParseCID(replyToCidRaw) + if err != nil { + s.pages.Notice(w, noticeId, "reply-to-cid should be valid CID") + return + } + replyTo = &comatproto.RepoStrongRef{ + Uri: uri.String(), + Cid: cid.String(), + } + } + + comment := models.Comment{ + Did: syntax.DID(user.Active.Did), + Collection: tangled.FeedCommentNSID, + Rkey: syntax.RecordKey(tid.TID()), + + Subject: subject, + Body: markdownBody, + Created: time.Now(), + ReplyTo: replyTo, + PullRoundIdx: pullRoundIdx, + } + if err = comment.Validate(); err != nil { + l.Error("failed to validate comment", "err", err) + s.pages.Notice(w, noticeId, "Failed to create comment.") + return + } + + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + l.Error("failed to get authorized client", "err", err) + s.pages.Notice(w, noticeId, "Failed to create comment.") + return + } + + // create a record first + out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ + Collection: comment.Collection.String(), + Repo: comment.Did.String(), + Rkey: comment.Rkey.String(), + Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, + }) + if err != nil { + l.Error("failed to create comment", "err", err) + s.pages.Notice(w, noticeId, "Failed to create comment.") + return + } + + comment.Cid = syntax.CID(out.Cid) + + tx, err := s.db.Begin() + if err != nil { + l.Error("failed to start transaction", "err", err) + s.pages.Notice(w, noticeId, "Failed to create comment, try again later.") + return + } + defer tx.Rollback() + + err = db.PutComment(tx, &comment, references) + if err != nil { + l.Error("failed to create comment", "err", err) + s.pages.Notice(w, noticeId, "Failed to create comment.") + return + } + + err = tx.Commit() + if err != nil { + l.Error("failed to commit transaction", "err", err) + s.pages.Notice(w, noticeId, "Failed to create comment, try again later.") + return + } + + // TODO: return comment or reply-comment fragment + // onattach, htmx-callback to focus on comment. + s.pages.HxRefresh(w) +} + +func (s *State) EditComment(w http.ResponseWriter, r *http.Request) { + l := s.logger.With("handler", "EditComment") + user := s.oauth.GetMultiAccountUser(r) + + noticeId := "comment-error" + ctx := r.Context() + + commentAt := r.FormValue("aturi") + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) + if err != nil { + l.Error("failed to fetch comment", "aturi", commentAt, "err", err) + s.pages.Notice(w, noticeId, "Failed to fetch comment") + return + } + + if comment.Did.String() != user.Active.Did { + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) + s.pages.Notice(w, noticeId, "You are not the author of this comment") + return + } + + body := r.FormValue("body") + if body == "" { + s.pages.Notice(w, noticeId, "Body is required") + return + } + + // TODO(boltless): normalize markdown body + normalizedBody := body + _, references := s.mentionsResolver.Resolve(ctx, body) + + now := time.Now() + newComment := comment + newComment.Body = tangled.MarkupMarkdown{ + Text: normalizedBody, + Original: &body, + Blobs: nil, + } + newComment.Edited = &now + if err := newComment.Validate(); err != nil { + l.Error("failed to validate comment", "err", err) + s.pages.Notice(w, noticeId, "Failed to update comment.") + return + } + + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + l.Error("failed to get authorized client", "err", err) + s.pages.Notice(w, noticeId, "Failed to create comment. try again later.") + return + } + + // update the record first + exCid := comment.Cid.String() + out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ + Collection: newComment.Collection.String(), + Repo: newComment.Did.String(), + Rkey: newComment.Rkey.String(), + SwapRecord: &exCid, + Record: &lexutil.LexiconTypeDecoder{ + Val: newComment.AsRecord(), + }, + }) + if err != nil { + l.Error("failed to update comment", "err", err) + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") + return + } + + newComment.Cid = syntax.CID(out.Cid) + + tx, err := s.db.Begin() + if err != nil { + l.Error("failed to start transaction", "err", err) + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") + return + } + defer tx.Rollback() + + err = db.PutComment(tx, &newComment, references) + if err != nil { + l.Error("failed to perform update-description query", "err", err) + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") + return + } + err = tx.Commit() + if err != nil { + l.Error("failed to commit transaction", "err", err) + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") + return + } + + // TODO: return full comment fragment so we can update comment header too + s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ + Comment: newComment, + }) +} + +func (s *State) DeleteComment(w http.ResponseWriter, r *http.Request) { + l := s.logger.With("handler", "DeleteComment") + user := s.oauth.GetMultiAccountUser(r) + + noticeId := "comment" + ctx := r.Context() + + commentAt := r.URL.Query().Get("aturi") + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) + if err != nil { + l.Error("failed to fetch comment", "aturi", commentAt) + s.pages.Notice(w, noticeId, "Failed to fetch comment.") + return + } + + if comment.Did.String() != user.Active.Did { + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) + s.pages.Notice(w, noticeId, "you are not the author of this comment") + return + } + + if comment.Deleted != nil { + s.pages.Notice(w, noticeId, "Comment already deleted") + return + } + + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + l.Error("failed to get authorized client", "err", err) + s.pages.Notice(w, "comment", "Failed to delete comment.") + return + } + _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ + Collection: comment.Collection.String(), + Repo: comment.Did.String(), + Rkey: comment.Rkey.String(), + }) + if err != nil { + l.Error("failed to delete from PDS", "err", err) + s.pages.Notice(w, noticeId, "Failed to delete comment, try again later.") + return + } + + // optimistic update for htmx response + now := time.Now() + comment.Body = tangled.MarkupMarkdown{} + comment.Deleted = &now + + s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ + Comment: comment, + }) +} diff --git a/appview/state/router.go b/appview/state/router.go index e897f87a..b9ecee57 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -188,6 +188,16 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.Delete("/", s.React) }) + r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) { + r.Get("/", s.CommentBodyFragment) + r.Get("/edit", s.EditCommentFragment) + r.Get("/reply", s.NewReplyCommentFragment) + r.Get("/reply/placeholder", s.ReplyPlaceholderFragment) + r.Post("/", s.NewComment) + r.Patch("/", s.EditComment) + r.Delete("/", s.DeleteComment) + }) + r.Route("/profile", func(r chi.Router) { r.Use(middleware.AuthMiddleware(s.oauth)) r.Get("/edit-bio", s.EditBioFragment) diff --git a/appview/strings/strings.go b/appview/strings/strings.go index cdbb93a8..1c6627ac 100644 --- a/appview/strings/strings.go +++ b/appview/strings/strings.go @@ -55,9 +55,6 @@ func (s *Strings) Router(mw *middleware.Middleware) http.Handler { r.Get("/raw", s.contents) r.Get("/edit", s.edit) r.Post("/edit", s.edit) - r. - With(middleware.AuthMiddleware(s.OAuth)). - Post("/comment", s.comment) }) }) @@ -436,6 +433,3 @@ func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { s.Pages.HxRedirect(w, "/strings/"+user.Active.Did) } - -func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { -} -- 2.51.2 From a3de6dc0ae8c466c101be01512ef95928d5ff434 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Mon, 12 Jan 2026 16:25:44 +0900 Subject: [PATCH 07/10] appview: move `CommentList` out of `Issue` So that we can render reply comments from non-issue threads. Signed-off-by: Seongmin Lee --- appview/issues/issues.go | 2 +- appview/models/comment.go | 72 +++++++++++++++++++ appview/models/issue.go | 72 ------------------- appview/notify/db/db.go | 2 +- .../comment}/commentList.html | 11 ++- .../pages/templates/repo/issues/issue.html | 6 +- 6 files changed, 81 insertions(+), 84 deletions(-) rename appview/pages/templates/{repo/issues/fragments => fragments/comment}/commentList.html (91%) diff --git a/appview/issues/issues.go b/appview/issues/issues.go index a54f128c..507e0482 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -135,7 +135,7 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { LoggedInUser: user, RepoInfo: rp.repoResolver.GetRepoInfo(r, user), Issue: issue, - CommentList: issue.CommentList(), + CommentList: models.NewCommentList(issue.Comments), Backlinks: backlinks, Reactions: reactionMap, UserReacted: userReactions, diff --git a/appview/models/comment.go b/appview/models/comment.go index 75841b4c..49363258 100644 --- a/appview/models/comment.go +++ b/appview/models/comment.go @@ -2,6 +2,7 @@ package models import ( "fmt" + "sort" "strings" "time" @@ -156,3 +157,74 @@ func CommentFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, re PullRoundIdx: pullRoundIdx, }, nil } + +type CommentListItem struct { + Self *Comment + Replies []*Comment +} + +func (it *CommentListItem) Participants() []syntax.DID { + participantSet := make(map[syntax.DID]struct{}) + participants := []syntax.DID{} + + addParticipant := func(did syntax.DID) { + if _, exists := participantSet[did]; !exists { + participantSet[did] = struct{}{} + participants = append(participants, did) + } + } + + addParticipant(syntax.DID(it.Self.Did)) + + for _, c := range it.Replies { + addParticipant(syntax.DID(c.Did)) + } + + return participants +} + +func NewCommentList(comments []Comment) []CommentListItem { + // Create a map to quickly find comments by their aturi + toplevel := make(map[syntax.ATURI]*CommentListItem) + var replies []*Comment + + // collect top level comments into the map + for _, comment := range comments { + if comment.IsTopLevel() { + toplevel[comment.AtUri()] = &CommentListItem{ + Self: &comment, + } + } else { + replies = append(replies, &comment) + } + } + + for _, r := range replies { + if r.ReplyTo == nil { + continue + } + if parent, exists := toplevel[syntax.ATURI(r.ReplyTo.Uri)]; exists { + parent.Replies = append(parent.Replies, r) + } + } + + var listing []CommentListItem + for _, v := range toplevel { + listing = append(listing, *v) + } + + // sort everything + sortFunc := func(a, b *Comment) bool { + return a.Created.Before(b.Created) + } + sort.Slice(listing, func(i, j int) bool { + return sortFunc(listing[i].Self, listing[j].Self) + }) + for _, r := range listing { + sort.Slice(r.Replies, func(i, j int) bool { + return sortFunc(r.Replies[i], r.Replies[j]) + }) + } + + return listing +} diff --git a/appview/models/issue.go b/appview/models/issue.go index e00a3c51..75950023 100644 --- a/appview/models/issue.go +++ b/appview/models/issue.go @@ -2,7 +2,6 @@ package models import ( "fmt" - "sort" "time" "github.com/bluesky-social/indigo/atproto/syntax" @@ -66,77 +65,6 @@ func (i *Issue) State() string { return "closed" } -type CommentListItem struct { - Self *Comment - Replies []*Comment -} - -func (it *CommentListItem) Participants() []syntax.DID { - participantSet := make(map[syntax.DID]struct{}) - participants := []syntax.DID{} - - addParticipant := func(did syntax.DID) { - if _, exists := participantSet[did]; !exists { - participantSet[did] = struct{}{} - participants = append(participants, did) - } - } - - addParticipant(syntax.DID(it.Self.Did)) - - for _, c := range it.Replies { - addParticipant(syntax.DID(c.Did)) - } - - return participants -} - -func (i *Issue) CommentList() []CommentListItem { - // Create a map to quickly find comments by their aturi - toplevel := make(map[syntax.ATURI]*CommentListItem) - var replies []*Comment - - // collect top level comments into the map - for _, comment := range i.Comments { - if comment.IsTopLevel() { - toplevel[comment.AtUri()] = &CommentListItem{ - Self: &comment, - } - } else { - replies = append(replies, &comment) - } - } - - for _, r := range replies { - if r.ReplyTo == nil { - continue - } - if parent, exists := toplevel[syntax.ATURI(r.ReplyTo.Uri)]; exists { - parent.Replies = append(parent.Replies, r) - } - } - - var listing []CommentListItem - for _, v := range toplevel { - listing = append(listing, *v) - } - - // sort everything - sortFunc := func(a, b *Comment) bool { - return a.Created.Before(b.Created) - } - sort.Slice(listing, func(i, j int) bool { - return sortFunc(listing[i].Self, listing[j].Self) - }) - for _, r := range listing { - sort.Slice(r.Replies, func(i, j int) bool { - return sortFunc(r.Replies[i], r.Replies[j]) - }) - } - - return listing -} - func (i *Issue) Participants() []string { participantSet := make(map[string]struct{}) participants := []string{} diff --git a/appview/notify/db/db.go b/appview/notify/db/db.go index 84909884..f002ea10 100644 --- a/appview/notify/db/db.go +++ b/appview/notify/db/db.go @@ -121,7 +121,7 @@ func (n *databaseNotifier) NewComment(ctx context.Context, comment *models.Comme parent := *comment.ReplyTo // find the parent thread, and add all DIDs from here to the recipient list - for _, t := range issue.CommentList() { + for _, t := range models.NewCommentList(issue.Comments) { if t.Self.AtUri() == syntax.ATURI(parent.Uri) { for _, p := range t.Participants() { recipients.Insert(p) diff --git a/appview/pages/templates/repo/issues/fragments/commentList.html b/appview/pages/templates/fragments/comment/commentList.html similarity index 91% rename from appview/pages/templates/repo/issues/fragments/commentList.html rename to appview/pages/templates/fragments/comment/commentList.html index 9b91a13a..c92e0562 100644 --- a/appview/pages/templates/repo/issues/fragments/commentList.html +++ b/appview/pages/templates/fragments/comment/commentList.html @@ -1,4 +1,4 @@ -{{ define "repo/issues/fragments/commentList" }} +{{ define "fragments/comment/commentList" }}
{{ range $item := .CommentList }} {{ template "commentListItem" (list $ .) }} @@ -9,13 +9,12 @@ {{ define "commentListItem" }} {{ $root := index . 0 }} {{ $item := index . 1 }} - {{ $params := - (dict - "LoggedInUser" $root.LoggedInUser - "Comment" $item.Self) }}
- {{ template "topLevelComment" $params }} + {{ template "topLevelComment" + (dict + "LoggedInUser" $root.LoggedInUser + "Comment" $item.Self) }}
{{ range $index, $reply := $item.Replies }} diff --git a/appview/pages/templates/repo/issues/issue.html b/appview/pages/templates/repo/issues/issue.html index e17656f8..1204b52d 100644 --- a/appview/pages/templates/repo/issues/issue.html +++ b/appview/pages/templates/repo/issues/issue.html @@ -113,12 +113,10 @@ {{ define "repoAfter" }}
{{ - template "repo/issues/fragments/commentList" + template "fragments/comment/commentList" (dict - "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser - "Issue" $.Issue - "CommentList" $.Issue.CommentList) + "CommentList" $.CommentList) }} {{ template "repo/issues/fragments/newComment" . }} -- 2.51.2 From a0357b599e646fb8018d746a537fa1073170277d Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 10 Apr 2026 13:06:49 +0900 Subject: [PATCH 08/10] appview: add comment to strings Signed-off-by: Seongmin Lee --- appview/pages/pages.go | 1 + appview/pages/templates/strings/string.html | 57 +++++++++++++++++++++ appview/strings/strings.go | 6 +++ 3 files changed, 64 insertions(+) diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 27673b97..9e21f97c 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1595,6 +1595,7 @@ type SingleStringParams struct { IsStarred bool StarCount int Owner identity.Identity + CommentList []models.CommentListItem } func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { diff --git a/appview/pages/templates/strings/string.html b/appview/pages/templates/strings/string.html index f0fd65f1..f46d8d59 100644 --- a/appview/pages/templates/strings/string.html +++ b/appview/pages/templates/strings/string.html @@ -94,4 +94,61 @@
{{ template "fragments/multiline-select" }} +
+ {{ + template "fragments/comment/commentList" + (dict + "LoggedInUser" .LoggedInUser + "CommentList" .CommentList) + }} + {{ template "newComment" . }} +
+{{ end }} + +{{ define "newComment" }} + {{ if .LoggedInUser }} + + +
+
+ {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} +
+ +
+
+
+ +
+ + {{ else }} +
+ + sign up + + or + login + to add to the discussion +
+ {{ end }} {{ end }} diff --git a/appview/strings/strings.go b/appview/strings/strings.go index 1c6627ac..f2465e89 100644 --- a/appview/strings/strings.go +++ b/appview/strings/strings.go @@ -156,6 +156,11 @@ func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { isStarred = db.GetStarStatus(s.Db, user.Active.Did, string.AtUri()) } + comments, err := db.GetComments(s.Db, orm.FilterEq("subject_uri", string.AtUri())) + if err != nil { + l.Error("failed to get comments", "err", err) + } + s.Pages.SingleString(w, pages.SingleStringParams{ LoggedInUser: user, RenderToggle: renderToggle, @@ -165,6 +170,7 @@ func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { IsStarred: isStarred, StarCount: starCount, Owner: id, + CommentList: models.NewCommentList(comments), }) } -- 2.51.2 From e8b66ce4e8f1859c5bdf4b9865e45a347b5d4038 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Mon, 12 Jan 2026 16:25:44 +0900 Subject: [PATCH 09/10] appview: fetch `aturi->reactionMap` from DB There can be multiple reactable entities in same page. Fetch every reactions in `aturi->reactionMap` format where reactionMap is `kind->T` map Signed-off-by: Seongmin Lee --- appview/db/reaction.go | 134 +++++++++++++----- appview/issues/issues.go | 14 +- appview/pages/pages.go | 8 +- .../pages/templates/repo/issues/issue.html | 7 +- .../repo/pulls/fragments/pullHeader.html | 7 +- appview/pulls/pulls.go | 12 +- 6 files changed, 128 insertions(+), 54 deletions(-) diff --git a/appview/db/reaction.go b/appview/db/reaction.go index 287a4324..50645584 100644 --- a/appview/db/reaction.go +++ b/appview/db/reaction.go @@ -1,11 +1,13 @@ package db import ( + "fmt" "log" "time" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/appview/models" + "tangled.org/core/orm" ) func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error { @@ -71,58 +73,120 @@ func GetReactionCountByKind(e Execer, threadAt syntax.ATURI, kind models.Reactio return count, nil } +// GetReactionDisplayDataMap returns map of [models.ReactionKind]->[models.ReactionDisplayData] func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { - query := ` - select kind, reacted_by_did, - row_number() over (partition by kind order by created asc) as rn, - count(*) over (partition by kind) as total - from reactions - where thread_at = ? - order by kind, created asc` + reactionMaps, err := ListReactionDisplayDataMap(e, []syntax.ATURI{threadAt}, userLimit) + return reactionMaps[threadAt], err +} - rows, err := e.Query(query, threadAt) +// ListReactionDisplayDataMap returns map of [syntax.ATURI]->[models.ReactionKind]->[models.ReactionDisplayData] +func ListReactionDisplayDataMap(e Execer, threads []syntax.ATURI, userLimit int) (map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData, error) { + if len(threads) == 0 { + return nil, nil + } + + filter := orm.FilterIn("thread_at", threads) + args := filter.Arg() + args = append(args, userLimit) + rows, err := e.Query( + fmt.Sprintf( + `with ranked_reactions as ( + select + thread_at, + kind, + reacted_by_did, + row_number() over (partition by thread_at, kind order by created asc) as rn, + count(*) over (partition by thread_at, kind) as total + from reactions + where %s + ) + select thread_at, kind, reacted_by_did, total + from ranked_reactions + where rn <= ? + order by thread_at, kind, rn asc`, + filter.Condition(), + ), + args..., + ) if err != nil { - return nil, err + return nil, fmt.Errorf("querying: %w", err) } defer rows.Close() - reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} - for _, kind := range models.OrderedReactionKinds { - reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} - } + // aturi -> kind -> {count,users} + result := make(map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData) for rows.Next() { + var aturi syntax.ATURI var kind models.ReactionKind - var did string - var rn, total int - if err := rows.Scan(&kind, &did, &rn, &total); err != nil { - return nil, err + var did syntax.DID + var count int + + if err := rows.Scan(&aturi, &kind, &did, &count); err != nil { + return nil, fmt.Errorf("scanning row: %w", err) } - data := reactionMap[kind] - data.Count = total - if userLimit > 0 && rn <= userLimit { - data.Users = append(data.Users, did) + if _, ok := result[aturi]; !ok { + result[aturi] = make(map[models.ReactionKind]models.ReactionDisplayData) } - reactionMap[kind] = data + data := result[aturi][kind] + data.Count = count + data.Users = append(data.Users, did.String()) + result[aturi][kind] = data + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) } - return reactionMap, rows.Err() + return result, nil } -func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool { - if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { - return false - } else { - return true - } +// GetReactionStatusMap returns map of [models.ReactionKind]->[bool] +func GetReactionStatusMap(e Execer, userDid syntax.DID, threadAt syntax.ATURI) (map[models.ReactionKind]bool, error) { + reactionMaps, err := ListReactionStatusMap(e, []syntax.ATURI{threadAt}, userDid) + return reactionMaps[threadAt], err } -func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool { - statusMap := map[models.ReactionKind]bool{} - for _, kind := range models.OrderedReactionKinds { - count := GetReactionStatus(e, userDid, threadAt, kind) - statusMap[kind] = count +// ListReactionStatusMap returns map of [syntax.ATURI]->[models.ReactionKind]->[bool] +func ListReactionStatusMap(e Execer, threads []syntax.ATURI, userDid syntax.DID) (map[syntax.ATURI]map[models.ReactionKind]bool, error) { + if len(threads) == 0 { + return nil, nil + } + + filter := orm.FilterIn("thread_at", threads) + args := []any{userDid} + args = append(args, filter.Arg()...) + rows, err := e.Query( + fmt.Sprintf( + `select thread_at, kind from reactions + where reacted_by_did = ? and %s`, + filter.Condition(), + ), + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + // aturi -> kind -> bool + result := make(map[syntax.ATURI]map[models.ReactionKind]bool) + + for rows.Next() { + var aturi syntax.ATURI + var kind models.ReactionKind + + if err := rows.Scan(&aturi, &kind); err != nil { + return nil, fmt.Errorf("scanning row: %w", err) + } + + if _, ok := result[aturi]; !ok { + result[aturi] = make(map[models.ReactionKind]bool) + } + + result[aturi][kind] = true } - return statusMap + + return result, nil } diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 507e0482..5b0baf37 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -98,14 +98,18 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { return } - reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) + entities := []syntax.ATURI{issue.AtUri()} + reactions, err := db.ListReactionDisplayDataMap(rp.db, entities, 20) if err != nil { - l.Error("failed to get issue reactions", "err", err) + l.Error("failed to get reactions", "err", err) } - userReactions := map[models.ReactionKind]bool{} + var userReactions map[syntax.ATURI]map[models.ReactionKind]bool if user != nil { - userReactions = db.GetReactionStatusMap(rp.db, user.Active.Did, issue.AtUri()) + userReactions, err = db.ListReactionStatusMap(rp.db, entities, syntax.DID(user.Active.Did)) + if err != nil { + l.Error("failed to get user reactions", "err", err) + } } backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) @@ -137,7 +141,7 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { Issue: issue, CommentList: models.NewCommentList(issue.Comments), Backlinks: backlinks, - Reactions: reactionMap, + Reactions: reactions, UserReacted: userReactions, LabelDefs: defs, }) diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 9e21f97c..9114c391 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1127,8 +1127,8 @@ type RepoSingleIssueParams struct { Backlinks []models.RichReferenceLink LabelDefs map[string]*models.LabelDefinition - Reactions map[models.ReactionKind]models.ReactionDisplayData - UserReacted map[models.ReactionKind]bool + Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData + UserReacted map[syntax.ATURI]map[models.ReactionKind]bool } func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { @@ -1287,8 +1287,8 @@ type RepoSinglePullParams struct { ActiveRound int IsInterdiff bool - Reactions map[models.ReactionKind]models.ReactionDisplayData - UserReacted map[models.ReactionKind]bool + Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData + UserReacted map[syntax.ATURI]map[models.ReactionKind]bool LabelDefs map[string]*models.LabelDefinition } diff --git a/appview/pages/templates/repo/issues/issue.html b/appview/pages/templates/repo/issues/issue.html index 1204b52d..1b3fd25e 100644 --- a/appview/pages/templates/repo/issues/issue.html +++ b/appview/pages/templates/repo/issues/issue.html @@ -36,10 +36,11 @@
{{ .Issue.Body | markdown }}
{{ end }}
+ {{ $aturi := .Issue.AtUri }} {{ template "repo/fragments/reactions" - (dict "Reactions" .Reactions - "UserReacted" .UserReacted - "ThreadAt" .Issue.AtUri) }} + (dict "Reactions" (index .Reactions $aturi) + "UserReacted" (index .UserReacted $aturi) + "ThreadAt" $aturi) }}
{{ end }} diff --git a/appview/pages/templates/repo/pulls/fragments/pullHeader.html b/appview/pages/templates/repo/pulls/fragments/pullHeader.html index bd670842..bc18aeec 100644 --- a/appview/pages/templates/repo/pulls/fragments/pullHeader.html +++ b/appview/pages/templates/repo/pulls/fragments/pullHeader.html @@ -48,10 +48,11 @@ {{ end }}
+ {{ $aturi := .Pull.AtUri }} {{ template "repo/fragments/reactions" - (dict "Reactions" .Reactions - "UserReacted" .UserReacted - "ThreadAt" .Pull.AtUri) }} + (dict "Reactions" (index .Reactions $aturi) + "UserReacted" (index .UserReacted $aturi) + "ThreadAt" $aturi) }}
diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index 9ec474b5..279a9cbe 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -240,14 +240,18 @@ func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff m[p.Sha] = p } - reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) + entities := []syntax.ATURI{pull.AtUri()} + reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20) if err != nil { l.Error("failed to get pull reactions", "err", err) } - userReactions := map[models.ReactionKind]bool{} + var userReactions map[syntax.ATURI]map[models.ReactionKind]bool if user != nil { - userReactions = db.GetReactionStatusMap(s.db, user.Active.Did, pull.AtUri()) + userReactions, err = db.ListReactionStatusMap(s.db, entities, syntax.DID(user.Active.Did)) + if err != nil { + s.logger.Error("failed to get user reactions", "err", err) + } } labelDefs, err := db.GetLabelDefinitions( @@ -303,7 +307,7 @@ func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff ActiveRound: roundIdInt, IsInterdiff: interdiff, - Reactions: reactionMap, + Reactions: reactions, UserReacted: userReactions, LabelDefs: defs, -- 2.51.2 From a8c29ef2021da5472f5e7d83720b88575dab5e2d Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Mon, 12 Jan 2026 16:25:44 +0900 Subject: [PATCH 10/10] appview: add reactions to comments Signed-off-by: Seongmin Lee --- appview/issues/issues.go | 3 +++ appview/pages/funcmap.go | 17 +++++++++++++++++ appview/pages/pages.go | 3 ++- .../fragments/comment/commentBody.html | 4 ++++ .../fragments/comment/commentList.html | 12 +++++++++--- .../templates/repo/fragments/reaction.html | 7 +++---- .../templates/repo/fragments/reactions.html | 13 ++++++------- .../pages/templates/repo/issues/issue.html | 4 +++- appview/pages/templates/repo/pulls/pull.html | 6 +++++- appview/pages/templates/strings/string.html | 6 ++++-- appview/pulls/pulls.go | 5 +++++ appview/state/reaction.go | 4 +--- appview/strings/strings.go | 19 +++++++++++++++++++ 13 files changed, 81 insertions(+), 22 deletions(-) diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 5b0baf37..52658af2 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -99,6 +99,9 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { } entities := []syntax.ATURI{issue.AtUri()} + for _, c := range issue.Comments { + entities = append(entities, c.AtUri()) + } reactions, err := db.ListReactionDisplayDataMap(rp.db, entities, 20) if err != nil { l.Error("failed to get reactions", "err", err) diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go index fe4b7e6a..061f2d2b 100644 --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -23,6 +23,7 @@ import ( chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" + "github.com/bluesky-social/indigo/atproto/syntax" "github.com/dustin/go-humanize" "github.com/go-enry/go-enry/v2" "github.com/yuin/goldmark" @@ -480,6 +481,22 @@ func (p *Pages) funcMap() template.FuncMap { "isGenerated": func(path string) bool { return enry.IsGenerated(path, nil) }, + // NOTE(boltless): I know... I hate doing this too + "asReactionMapMap": func(dict any) map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData { + if dict == nil { + return make(map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData) + } + m, _ := dict.(map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData) + return m + }, + "asReactionStatusMapMap": func(dict any) map[syntax.ATURI]map[models.ReactionKind]bool { + if dict == nil { + log.Println("returning empty map") + return make(map[syntax.ATURI]map[models.ReactionKind]bool) + } + m, _ := dict.(map[syntax.ATURI]map[models.ReactionKind]bool) + return m + }, // constant values used to define a template "const": func() map[string]any { return map[string]any{ diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 9114c391..7111113d 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1149,7 +1149,6 @@ func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { } type ThreadReactionFragmentParams struct { - ThreadAt syntax.ATURI Kind models.ReactionKind Count int Users []string @@ -1596,6 +1595,8 @@ type SingleStringParams struct { StarCount int Owner identity.Identity CommentList []models.CommentListItem + Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData + UserReacted map[syntax.ATURI]map[models.ReactionKind]bool } func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { diff --git a/appview/pages/templates/fragments/comment/commentBody.html b/appview/pages/templates/fragments/comment/commentBody.html index 1fcfe931..59ac3e2a 100644 --- a/appview/pages/templates/fragments/comment/commentBody.html +++ b/appview/pages/templates/fragments/comment/commentBody.html @@ -2,6 +2,10 @@
{{ if not .Comment.Deleted }}
{{ .Comment.Body.Text | markdown }}
+ {{ template "repo/fragments/reactions" + (dict "Reactions" .Reactions + "UserReacted" .UserReacted + "ThreadAt" .Comment.AtUri) }} {{ else }}
[deleted by author]
{{ end }} diff --git a/appview/pages/templates/fragments/comment/commentList.html b/appview/pages/templates/fragments/comment/commentList.html index c92e0562..4a671333 100644 --- a/appview/pages/templates/fragments/comment/commentList.html +++ b/appview/pages/templates/fragments/comment/commentList.html @@ -11,10 +11,14 @@ {{ $item := index . 1 }}
- {{ template "topLevelComment" + {{ + template "topLevelComment" (dict "LoggedInUser" $root.LoggedInUser - "Comment" $item.Self) }} + "Reactions" (index (asReactionMapMap $root.Reactions) $item.Self.AtUri) + "UserReacted" (index (asReactionStatusMapMap $root.UserReacted) $item.Self.AtUri) + "Comment" $item.Self) + }}
{{ range $index, $reply := $item.Replies }} @@ -23,6 +27,8 @@ template "replyComment" (dict "LoggedInUser" $root.LoggedInUser + "Reactions" (index (asReactionMapMap $root.Reactions) $reply.AtUri) + "UserReacted" (index (asReactionStatusMapMap $root.UserReacted) $reply.AtUri) "Comment" $reply) }}
@@ -58,7 +64,7 @@ {{ end }} {{ define "replyComment" }} -
+
{{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }}
diff --git a/appview/pages/templates/repo/fragments/reaction.html b/appview/pages/templates/repo/fragments/reaction.html index 5268b8de..ac02aaec 100644 --- a/appview/pages/templates/repo/fragments/reaction.html +++ b/appview/pages/templates/repo/fragments/reaction.html @@ -1,6 +1,5 @@ {{ define "repo/fragments/reaction" }}
diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index 279a9cbe..78b515bc 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -241,6 +241,11 @@ func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff } entities := []syntax.ATURI{pull.AtUri()} + for _, s := range pull.Submissions { + for _, c := range s.Comments { + entities = append(entities, c.AtUri()) + } + } reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20) if err != nil { l.Error("failed to get pull reactions", "err", err) diff --git a/appview/state/reaction.go b/appview/state/reaction.go index 208f0a5c..d127e05a 100644 --- a/appview/state/reaction.go +++ b/appview/state/reaction.go @@ -19,7 +19,7 @@ func (s *State) React(w http.ResponseWriter, r *http.Request) { l := s.logger.With("handler", "React") currentUser := s.oauth.GetMultiAccountUser(r) - subject := r.URL.Query().Get("subject") + subject := r.FormValue("subject-uri") if subject == "" { l.Warn("invalid form") return @@ -78,7 +78,6 @@ func (s *State) React(w http.ResponseWriter, r *http.Request) { l.Info("created atproto record", "uri", resp.Uri) s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ - ThreadAt: subjectUri, Kind: reactionKind, Count: reactionMap[reactionKind].Count, Users: reactionMap[reactionKind].Users, @@ -117,7 +116,6 @@ func (s *State) React(w http.ResponseWriter, r *http.Request) { } s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ - ThreadAt: subjectUri, Kind: reactionKind, Count: reactionMap[reactionKind].Count, Users: reactionMap[reactionKind].Users, diff --git a/appview/strings/strings.go b/appview/strings/strings.go index f2465e89..c51ec2db 100644 --- a/appview/strings/strings.go +++ b/appview/strings/strings.go @@ -161,6 +161,23 @@ func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { l.Error("failed to get comments", "err", err) } + entities := []syntax.ATURI{string.AtUri()} + for _, c := range comments { + entities = append(entities, c.AtUri()) + } + reactions, err := db.ListReactionDisplayDataMap(s.Db, entities, 20) + if err != nil { + l.Error("failed to get reactions", "err", err) + } + + var userReactions map[syntax.ATURI]map[models.ReactionKind]bool + if user != nil { + userReactions, err = db.ListReactionStatusMap(s.Db, entities, syntax.DID(user.Active.Did)) + if err != nil { + l.Error("failed to get user reactions", "err", err) + } + } + s.Pages.SingleString(w, pages.SingleStringParams{ LoggedInUser: user, RenderToggle: renderToggle, @@ -171,6 +188,8 @@ func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { StarCount: starCount, Owner: id, CommentList: models.NewCommentList(comments), + Reactions: reactions, + UserReacted: userReactions, }) }