From 8970e77ce96c36cfb8efab7e95c0537c0e4c5b06 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Wed, 01 Apr 2026 10:45:14 +0000 Subject: [PATCH] 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/ingester.go | 7 ++++++- appview/db/comments.go | 11 +++++++++++ appview/issues/issues.go | 463 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- appview/issues/router.go | 9 --------- appview/models/comment.go | 10 +++++----- appview/pages/pages.go | 78 ++++++++++++++++++++++++++++++++++-------------------------------------------- appview/pulls/comment.go | 134 -------------------------------------------------------------------------------------------------------------------------------------- appview/pulls/router.go | 5 +---- appview/state/comment.go | 422 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/state/router.go | 10 ++++++++++ appview/strings/strings.go | 6 ------ appview/pages/templates/fragments/line-quote-button.html | 3 +-- appview/pages/templates/fragments/comment/commentBody.html | 9 +++++++++ appview/pages/templates/fragments/comment/commentHeader.html | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/pages/templates/fragments/comment/edit.html | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ appview/pages/templates/fragments/comment/reply.html | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ appview/pages/templates/fragments/comment/replyPlaceholder.html | 15 +++++++++++++++ appview/pages/templates/repo/pulls/pull.html | 24 ++++++++++++++++++++++-- appview/pages/templates/repo/issues/fragments/commentList.html | 46 ++++++++++++++++++++++++++-------------------- appview/pages/templates/repo/issues/fragments/editIssueComment.html | 44 -------------------------------------------- appview/pages/templates/repo/issues/fragments/issueCommentBody.html | 9 --------- appview/pages/templates/repo/issues/fragments/issueCommentHeader.html | 61 ------------------------------------------------------------- appview/pages/templates/repo/issues/fragments/newComment.html | 53 ++++++++++++++++------------------------------------- appview/pages/templates/repo/issues/fragments/replyComment.html | 66 ------------------------------------------------------------------ appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html | 22 ---------------------- appview/pages/templates/repo/pulls/fragments/pullActions.html | 26 ++++++++------------------ appview/pages/templates/repo/pulls/fragments/pullNewComment.html | 28 ++++++++++++++-------------- 27 file(s) changed, 760 insertion(s)(+), 961 deletion(s)(-) diff --git a/appview/ingester.go b/appview/ingester.go --- a/appview/ingester.go +++ b/appview/ingester.go @@ -1621,9 +1621,10 @@ 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() @@ -1639,6 +1640,10 @@ if err := tx.Commit(); err != nil { return err + } + + if e.Commit.Operation == jmodels.CommitOperationCreate { + i.Notifier.NewComment(ctx, comment, mentions) } case jmodels.CommitOperationDelete: diff --git a/appview/db/comments.go b/appview/db/comments.go --- a/appview/db/comments.go +++ b/appview/db/comments.go @@ -154,6 +154,17 @@ 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/issues/issues.go b/appview/issues/issues.go --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -14,8 +14,6 @@ "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" @@ -401,467 +399,6 @@ http.Error(w, "forbidden", http.StatusUnauthorized) return } -} - -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) { diff --git a/appview/issues/router.go b/appview/issues/router.go --- a/appview/issues/router.go +++ b/appview/issues/router.go @@ -21,15 +21,6 @@ // 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 --- a/appview/models/comment.go +++ b/appview/models/comment.go @@ -31,18 +31,18 @@ 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) 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 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1238,50 +1238,6 @@ 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 @@ -1714,6 +1670,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 { diff --git a/appview/pulls/comment.go b/appview/pulls/comment.go --- a/appview/pulls/comment.go +++ b/appview/pulls/comment.go @@ -1,22 +1,12 @@ package pulls import ( - "fmt" "net/http" "strconv" - "time" - "tangled.org/core/api/tangled" - "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/pages" - "tangled.org/core/appview/reporesolver" - "tangled.org/core/tid" - 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" "github.com/go-chi/chi/v5" ) @@ -26,12 +16,6 @@ user := s.oauth.GetMultiAccountUser(r) if user != nil { l = l.With("user", user.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) @@ -58,124 +42,6 @@ Pull: pull, 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.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 --- a/appview/pulls/router.go +++ b/appview/pulls/router.go @@ -27,10 +27,7 @@ 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 --- /dev/null +++ b/appview/state/comment.go @@ -0,0 +1,422 @@ +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") + user := s.oauth.GetMultiAccountUser(r) + + 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 + } + + reactions, err := db.GetReactionMap(s.db, 20, comment.AtUri()) + if err != nil { + l.Error("failed to get reactions", "err", err) + } + var userReactions map[models.ReactionKind]bool + if user != nil { + userReactions, err = db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.AtUri()) + if err != nil { + l.Error("failed to get user reactions", "err", err) + } + } + + err = s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ + Comment: comment, + Reactions: reactions, + UserReacted: userReactions, + }) + 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 CID should be valid CID") + return + } + } else { + l.Debug("fetching 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.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.Did { + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.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 + } + + reactions, err := db.GetReactionMap(s.db, 20, comment.AtUri()) + if err != nil { + l.Error("failed to get reactions", "err", err) + } + userReactions, err := db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.AtUri()) + if err != nil { + l.Error("failed to get user reactions", "err", err) + } + + // TODO: return full comment fragment so we can update comment header too + s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ + Comment: newComment, + Reactions: reactions, + UserReacted: userReactions, + }) +} + +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.Did { + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.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 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -212,6 +212,16 @@ 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.Get("/profile/popover", s.ProfilePopover) r.Route("/profile", func(r chi.Router) { diff --git a/appview/strings/strings.go b/appview/strings/strings.go --- a/appview/strings/strings.go +++ b/appview/strings/strings.go @@ -55,9 +55,6 @@ 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,7 +433,4 @@ s.Notifier.DeleteString(r.Context(), user.Did, rkey) s.Pages.HxRedirect(w, "/strings/"+user.Did) -} - -func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { } diff --git a/appview/pages/templates/fragments/line-quote-button.html b/appview/pages/templates/fragments/line-quote-button.html --- a/appview/pages/templates/fragments/line-quote-button.html +++ b/appview/pages/templates/fragments/line-quote-button.html @@ -18,8 +18,7 @@ const btnEnd = document.getElementById('line-quote-btn-end'); const textarea = () => - document.getElementById('pull-comment-textarea') - || document.getElementById('comment-textarea'); + document.getElementById('comment-textarea'); const lineOf = (el) => el?.closest?.('span[id*="-O"]') diff --git a/appview/pages/templates/fragments/comment/commentBody.html b/appview/pages/templates/fragments/comment/commentBody.html new file mode 100644 --- /dev/null +++ b/appview/pages/templates/fragments/comment/commentBody.html @@ -0,0 +1,9 @@ +{{ define "fragments/comment/commentBody" }} +
+ {{ if not .Comment.Deleted }} +
{{ .Comment.Body.Text | markdown }}
+ {{ else }} +
[deleted by author]
+ {{ end }} +
+{{ end }} diff --git a/appview/pages/templates/fragments/comment/commentHeader.html b/appview/pages/templates/fragments/comment/commentHeader.html new file mode 100644 --- /dev/null +++ b/appview/pages/templates/fragments/comment/commentHeader.html @@ -0,0 +1,61 @@ +{{ define "fragments/comment/commentHeader" }} +
+ {{ $handle := resolve .Comment.Did.String }} + {{ $handle }} + {{ template "hats" $ }} + + {{ template "timestamp" . }} + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} + {{ if and $isCommentOwner (not .Comment.Deleted) }} + {{ if not .Comment.IsLegacy }} + {{ template "editCommentBtn" . }} + {{ end }} + {{ template "deleteCommentBtn" . }} + {{ end }} +
+{{ end }} + +{{ define "hats" }} + {{ $isIssueAuthor := eq .Comment.Did .Issue.Did }} + {{ if $isIssueAuthor }} + (author) + {{ end }} +{{ end }} + +{{ define "timestamp" }} + + {{ if .Comment.Deleted }} + {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} + {{ else if .Comment.Edited }} + edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }} + {{ else }} + {{ template "repo/fragments/shortTimeAgo" .Comment.Created }} + {{ end }} + +{{ end }} + +{{ define "editCommentBtn" }} + + {{ i "pencil" "size-3 inline group-[.htmx-request]:hidden" }} + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} + +{{ end }} + +{{ define "deleteCommentBtn" }} + + {{ i "trash-2" "size-3 inline group-[.htmx-request]:hidden" }} + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} + +{{ end }} diff --git a/appview/pages/templates/fragments/comment/edit.html b/appview/pages/templates/fragments/comment/edit.html new file mode 100644 --- /dev/null +++ b/appview/pages/templates/fragments/comment/edit.html @@ -0,0 +1,49 @@ +{{ define "fragments/comment/edit" }} +
+ + +
+ {{ template "editActions" $ }} +
+{{ end }} + +{{ define "editActions" }} +
+ {{ template "cancel" . }} + {{ template "save" . }} +
+{{ end }} + +{{ define "save" }} + +{{ end }} + +{{ define "cancel" }} + +{{ end }} diff --git a/appview/pages/templates/fragments/comment/reply.html b/appview/pages/templates/fragments/comment/reply.html new file mode 100644 --- /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 --- /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/pulls/pull.html b/appview/pages/templates/repo/pulls/pull.html --- a/appview/pages/templates/repo/pulls/pull.html +++ b/appview/pages/templates/repo/pulls/pull.html @@ -595,7 +595,11 @@
{{ range $item.Comments }} - {{ template "submissionComment" (list . $root) }} + {{/* template "submissionComment" . */}} + {{ template "comment" + (dict "LoggedInUser" $root.LoggedInUser + "VouchRelationship" (index $root.VouchRelationships .Did) + "Comment" .) }} {{ end }}
{{ if gt $c 0}} @@ -610,8 +614,10 @@ {{ block "resubmitStatus" $root }} {{ end }} {{ end }} -
+
{{ if $root.LoggedInUser }} + + {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $root.LoggedInUser @@ -625,6 +631,20 @@ {{ end }}
+{{ 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/pages/templates/repo/issues/fragments/commentList.html b/appview/pages/templates/repo/issues/fragments/commentList.html --- a/appview/pages/templates/repo/issues/fragments/commentList.html +++ b/appview/pages/templates/repo/issues/fragments/commentList.html @@ -1,43 +1,49 @@ {{ 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 - "VouchRelationship" (index $root.VouchRelationships $comment.Self.Did) - ) }} + "LoggedInUser" $root.LoggedInUser + "VouchRelationship" (index $root.VouchRelationships $item.Self.Did) + "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 + "LoggedInUser" $root.LoggedInUser "VouchRelationship" (index $root.VouchRelationships $reply.Did) - ) }} + "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 }} @@ -47,8 +53,8 @@ {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1" .VouchRelationship) }}
- {{ template "repo/issues/fragments/issueCommentHeader" . }} - {{ template "repo/issues/fragments/issueCommentBody" . }} + {{ template "fragments/comment/commentHeader" . }} + {{ template "fragments/comment/commentBody" . }}
{{ end }} @@ -59,8 +65,8 @@ {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1" .VouchRelationship) }}
- {{ 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/editIssueComment.html b/appview/pages/templates/repo/issues/fragments/editIssueComment.html deleted file mode 100644 --- a/appview/pages/templates/repo/issues/fragments/editIssueComment.html +++ /dev/null @@ -1,44 +0,0 @@ -{{ define "repo/issues/fragments/editIssueComment" }} -
- - - {{ template "editActions" $ }} -
-{{ end }} - -{{ define "editActions" }} -
- {{ template "cancel" . }} - {{ template "save" . }} -
-{{ end }} - -{{ define "save" }} - -{{ end }} - -{{ define "cancel" }} - -{{ end }} diff --git a/appview/pages/templates/repo/issues/fragments/issueCommentBody.html b/appview/pages/templates/repo/issues/fragments/issueCommentBody.html deleted file mode 100644 --- a/appview/pages/templates/repo/issues/fragments/issueCommentBody.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ define "repo/issues/fragments/issueCommentBody" }} -
- {{ if not .Comment.Deleted }} -
{{ .Comment.Body.Text | markdown }}
- {{ else }} -
[deleted by author]
- {{ end }} -
-{{ end }} diff --git a/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html b/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html deleted file mode 100644 --- a/appview/pages/templates/repo/issues/fragments/issueCommentHeader.html +++ /dev/null @@ -1,61 +0,0 @@ -{{ define "repo/issues/fragments/issueCommentHeader" }} -
- {{ $handle := resolve .Comment.Did.String }} - {{ $handle }} - {{ template "hats" $ }} - - {{ template "timestamp" . }} - {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} - {{ if and $isCommentOwner (not .Comment.Deleted) }} - {{ if not .Comment.IsLegacy }} - {{ template "editIssueComment" . }} - {{ end }} - {{ template "deleteIssueComment" . }} - {{ end }} -
-{{ end }} - -{{ define "hats" }} - {{ $isIssueAuthor := eq .Comment.Did .Issue.Did }} - {{ if $isIssueAuthor }} - (author) - {{ end }} -{{ end }} - -{{ define "timestamp" }} - - {{ if .Comment.Deleted }} - {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} - {{ else if .Comment.Edited }} - edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }} - {{ else }} - {{ template "repo/fragments/shortTimeAgo" .Comment.Created }} - {{ end }} - -{{ end }} - -{{ define "editIssueComment" }} - - {{ i "pencil" "size-3 inline group-[.htmx-request]:hidden" }} - {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} - -{{ end }} - -{{ define "deleteIssueComment" }} - - {{ i "trash-2" "size-3 inline group-[.htmx-request]:hidden" }} - {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} - -{{ end }} diff --git a/appview/pages/templates/repo/issues/fragments/newComment.html b/appview/pages/templates/repo/issues/fragments/newComment.html --- 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 }}
- -
+ +
@@ -51,17 +52,6 @@ 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 --- 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 --- 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 }}