diff --git a/appview/db/comments.go b/appview/db/comments.go index 07053748..eae20eff 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 991a26d5..4ffec7aa 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -1621,9 +1621,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 := i.Db.Begin() @@ -1641,6 +1642,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( i.Db, diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 3cc3810c..9d431645 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -14,8 +14,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" @@ -403,467 +401,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.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.Did { - l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.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.Did { - l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.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 60db6315..e2445568 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 @@ -61,14 +61,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 a886ee25..c31b5307 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1238,50 +1238,6 @@ func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { return p.executeRepo("repo/issues/new", w, params) } -type EditIssueCommentParams struct { - LoggedInUser *oauth.MultiAccountUser - RepoInfo repoinfo.RepoInfo - Issue *models.Issue - Comment *models.Comment -} - -func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { - return p.executePlain("repo/issues/fragments/editIssueComment", w, params) -} - -type ReplyIssueCommentPlaceholderParams struct { - LoggedInUser *oauth.MultiAccountUser - RepoInfo repoinfo.RepoInfo - Issue *models.Issue - Comment *models.Comment -} - -func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { - return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) -} - -type ReplyIssueCommentParams struct { - LoggedInUser *oauth.MultiAccountUser - RepoInfo repoinfo.RepoInfo - Issue *models.Issue - Comment *models.Comment -} - -func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { - return p.executePlain("repo/issues/fragments/replyComment", w, params) -} - -type IssueCommentBodyParams struct { - LoggedInUser *oauth.MultiAccountUser - RepoInfo repoinfo.RepoInfo - Issue *models.Issue - Comment *models.Comment -} - -func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { - return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) -} - type StackedDiff struct { Diff *types.NiceDiff Opts types.DiffOpts @@ -1716,6 +1672,40 @@ func (p *Pages) Home(w io.Writer, params TimelineParams) error { return p.execute("timeline/home", w, params) } +type CommentBodyFragmentParams struct { + Comment models.Comment + Reactions map[models.ReactionKind]models.ReactionDisplayData + UserReacted map[models.ReactionKind]bool +} + +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" }} -