Monorepo for Tangled tangled.org
766
fork

Configure Feed

Select the types of activity you want to include in your feed.

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 <git@boltless.me>

+676 -848
+11
appview/db/comments.go
··· 154 154 return err 155 155 } 156 156 157 + func GetComment(e Execer, filters ...orm.Filter) (models.Comment, error) { 158 + comments, err := GetComments(e, filters...) 159 + if err != nil { 160 + return models.Comment{}, err 161 + } 162 + if len(comments) != 1 { 163 + return models.Comment{}, fmt.Errorf("expected 1 comment, got %d", len(comments)) 164 + } 165 + return comments[0], nil 166 + } 167 + 157 168 func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { 158 169 var comments []models.Comment 159 170
+6 -1
appview/ingester.go
··· 1188 1188 return fmt.Errorf("failed to validate comment: %w", err) 1189 1189 } 1190 1190 1191 + var mentions []syntax.DID 1191 1192 var references []syntax.ATURI 1192 1193 if comment.Body.Original != nil { 1193 - _, references = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) 1194 + mentions, references = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) 1194 1195 } 1195 1196 1196 1197 tx, err := ddb.Begin() ··· 1206 1207 1207 1208 if err := tx.Commit(); err != nil { 1208 1209 return err 1210 + } 1211 + 1212 + if e.Commit.Operation == jmodels.CommitOperationCreate { 1213 + i.Notifier.NewComment(ctx, comment, mentions) 1209 1214 } 1210 1215 1211 1216 case jmodels.CommitOperationDelete:
+4 -464
appview/issues/issues.go
··· 13 13 "github.com/bluesky-social/indigo/atproto/atclient" 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 17 - "github.com/go-chi/chi/v5" 18 16 19 17 "tangled.org/core/api/tangled" 20 18 "tangled.org/core/appview/config" ··· 133 131 defs[l.AtUri().String()] = &l 134 132 } 135 133 136 - rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 134 + err = rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 137 135 LoggedInUser: user, 138 136 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 139 137 Issue: issue, ··· 143 141 UserReacted: userReactions, 144 142 LabelDefs: defs, 145 143 }) 144 + if err != nil { 145 + l.Error("failed to render", "err", err) 146 + } 146 147 } 147 148 148 149 func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { ··· 387 388 http.Error(w, "forbidden", http.StatusUnauthorized) 388 389 return 389 390 } 390 - } 391 - 392 - func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 393 - l := rp.logger.With("handler", "NewIssueComment") 394 - user := rp.oauth.GetMultiAccountUser(r) 395 - f, err := rp.repoResolver.Resolve(r) 396 - if err != nil { 397 - l.Error("failed to get repo and knot", "err", err) 398 - return 399 - } 400 - 401 - issue, ok := r.Context().Value("issue").(*models.Issue) 402 - if !ok { 403 - l.Error("failed to get issue") 404 - rp.pages.Error404(w) 405 - return 406 - } 407 - 408 - body := r.FormValue("body") 409 - if body == "" { 410 - rp.pages.Notice(w, "issue-comment", "Body is required") 411 - return 412 - } 413 - 414 - // TODO(boltless): normalize markdown body 415 - normalizedBody := body 416 - _, references := rp.mentionsResolver.Resolve(r.Context(), body) 417 - 418 - markdownBody := tangled.MarkupMarkdown{ 419 - Text: normalizedBody, 420 - Original: &body, 421 - Blobs: nil, 422 - } 423 - 424 - // ingest CID of issue record on-demand. 425 - // TODO(boltless): appview should ingest CID of atproto records 426 - cid, err := func() (syntax.CID, error) { 427 - ident, err := rp.idResolver.ResolveIdent(r.Context(), issue.Did) 428 - if err != nil { 429 - return "", err 430 - } 431 - 432 - xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 433 - out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) 434 - if err != nil { 435 - return "", err 436 - } 437 - if out.Cid == nil { 438 - return "", fmt.Errorf("record CID is empty") 439 - } 440 - 441 - cid, err := syntax.ParseCID(*out.Cid) 442 - if err != nil { 443 - return "", err 444 - } 445 - 446 - return cid, nil 447 - }() 448 - if err != nil { 449 - rp.logger.Error("failed to backfill subject PR record", "err", err) 450 - rp.pages.Notice(w, "issue-comment", "failed to backfill subject record") 451 - return 452 - } 453 - issueStrongRef := comatproto.RepoStrongRef{ 454 - Uri: issue.AtUri().String(), 455 - Cid: cid.String(), 456 - } 457 - 458 - var replyTo *comatproto.RepoStrongRef 459 - replyToUriRaw := r.FormValue("reply-to-uri") 460 - replyToCidRaw := r.FormValue("reply-to-cid") 461 - if replyToUriRaw != "" && replyToCidRaw != "" { 462 - uri, err := syntax.ParseATURI(replyToUriRaw) 463 - if err != nil { 464 - rp.pages.Notice(w, "issue-comment", "reply-to-uri should be valid AT-URI") 465 - return 466 - } 467 - cid, err := syntax.ParseCID(replyToCidRaw) 468 - if err != nil { 469 - rp.pages.Notice(w, "issue-comment", "reply-to-cid should be valid CID") 470 - return 471 - } 472 - replyTo = &comatproto.RepoStrongRef{ 473 - Uri: uri.String(), 474 - Cid: cid.String(), 475 - } 476 - } 477 - 478 - mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 479 - 480 - comment := models.Comment{ 481 - Did: syntax.DID(user.Active.Did), 482 - Collection: tangled.FeedCommentNSID, 483 - Rkey: syntax.RecordKey(tid.TID()), 484 - 485 - Subject: issueStrongRef, 486 - Body: markdownBody, 487 - Created: time.Now(), 488 - ReplyTo: replyTo, 489 - } 490 - if err = comment.Validate(); err != nil { 491 - l.Error("failed to validate comment", "err", err) 492 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 493 - return 494 - } 495 - 496 - client, err := rp.oauth.AuthorizedClient(r) 497 - if err != nil { 498 - l.Error("failed to get authorized client", "err", err) 499 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 500 - return 501 - } 502 - 503 - // create a record first 504 - out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 505 - Collection: comment.Collection.String(), 506 - Repo: comment.Did.String(), 507 - Rkey: comment.Rkey.String(), 508 - Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 509 - }) 510 - if err != nil { 511 - l.Error("failed to create comment", "err", err) 512 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 513 - return 514 - } 515 - 516 - comment.Cid = syntax.CID(out.Cid) 517 - 518 - tx, err := rp.db.Begin() 519 - if err != nil { 520 - l.Error("failed to start transaction", "err", err) 521 - rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 522 - return 523 - } 524 - defer tx.Rollback() 525 - 526 - err = db.PutComment(tx, &comment, references) 527 - if err != nil { 528 - l.Error("failed to create comment", "err", err) 529 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 530 - return 531 - } 532 - 533 - err = tx.Commit() 534 - if err != nil { 535 - l.Error("failed to commit transaction", "err", err) 536 - rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 537 - return 538 - } 539 - 540 - rp.notifier.NewComment(r.Context(), &comment, mentions) 541 - 542 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 543 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) 544 - } 545 - 546 - func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 547 - l := rp.logger.With("handler", "IssueComment") 548 - user := rp.oauth.GetMultiAccountUser(r) 549 - 550 - issue, ok := r.Context().Value("issue").(*models.Issue) 551 - if !ok { 552 - l.Error("failed to get issue") 553 - rp.pages.Error404(w) 554 - return 555 - } 556 - 557 - commentId := chi.URLParam(r, "commentId") 558 - comments, err := db.GetComments( 559 - rp.db, 560 - orm.FilterEq("id", commentId), 561 - ) 562 - if err != nil { 563 - l.Error("failed to fetch comment", "id", commentId) 564 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 565 - return 566 - } 567 - if len(comments) != 1 { 568 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 569 - http.Error(w, "invalid comment id", http.StatusBadRequest) 570 - return 571 - } 572 - comment := comments[0] 573 - 574 - rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 575 - LoggedInUser: user, 576 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 577 - Issue: issue, 578 - Comment: &comment, 579 - }) 580 - } 581 - 582 - func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 583 - l := rp.logger.With("handler", "EditIssueComment") 584 - user := rp.oauth.GetMultiAccountUser(r) 585 - 586 - issue, ok := r.Context().Value("issue").(*models.Issue) 587 - if !ok { 588 - l.Error("failed to get issue") 589 - rp.pages.Error404(w) 590 - return 591 - } 592 - 593 - commentId := chi.URLParam(r, "commentId") 594 - comments, err := db.GetComments( 595 - rp.db, 596 - orm.FilterEq("id", commentId), 597 - ) 598 - if err != nil { 599 - l.Error("failed to fetch comment", "id", commentId) 600 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 601 - return 602 - } 603 - if len(comments) != 1 { 604 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 605 - http.Error(w, "invalid comment id", http.StatusBadRequest) 606 - return 607 - } 608 - comment := comments[0] 609 - 610 - if comment.Did.String() != user.Active.Did { 611 - l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) 612 - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 613 - return 614 - } 615 - 616 - switch r.Method { 617 - case http.MethodGet: 618 - rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 619 - LoggedInUser: user, 620 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 621 - Issue: issue, 622 - Comment: &comment, 623 - }) 624 - case http.MethodPost: 625 - // extract form value 626 - body := r.FormValue("body") 627 - if body == "" { 628 - rp.pages.Notice(w, "issue-comment", "Body is required") 629 - return 630 - } 631 - 632 - // TODO(boltless): normalize markdown body 633 - normalizedBody := body 634 - _, references := rp.mentionsResolver.Resolve(r.Context(), body) 635 - 636 - now := time.Now() 637 - newComment := comment 638 - newComment.Body = tangled.MarkupMarkdown{ 639 - Text: normalizedBody, 640 - Original: &body, 641 - Blobs: nil, 642 - } 643 - newComment.Edited = &now 644 - 645 - client, err := rp.oauth.AuthorizedClient(r) 646 - if err != nil { 647 - l.Error("failed to get authorized client", "err", err) 648 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 649 - return 650 - } 651 - 652 - // update a record first 653 - exCid := comment.Cid.String() 654 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 655 - Collection: newComment.Collection.String(), 656 - Repo: newComment.Did.String(), 657 - Rkey: newComment.Rkey.String(), 658 - SwapRecord: &exCid, 659 - Record: &lexutil.LexiconTypeDecoder{ 660 - Val: newComment.AsRecord(), 661 - }, 662 - }) 663 - if err != nil { 664 - l.Error("failed to update comment", "err", err) 665 - rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") 666 - return 667 - } 668 - 669 - newComment.Cid = syntax.CID(resp.Cid) 670 - 671 - tx, err := rp.db.Begin() 672 - if err != nil { 673 - l.Error("failed to start transaction", "err", err) 674 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 675 - return 676 - } 677 - defer tx.Rollback() 678 - 679 - err = db.PutComment(tx, &newComment, references) 680 - if err != nil { 681 - l.Error("failed to perform update-description query", "err", err) 682 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 683 - return 684 - } 685 - err = tx.Commit() 686 - if err != nil { 687 - l.Error("failed to commit transaction", "err", err) 688 - rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") 689 - return 690 - } 691 - 692 - // return new comment body with htmx 693 - rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 694 - LoggedInUser: user, 695 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 696 - Issue: issue, 697 - Comment: &newComment, 698 - }) 699 - } 700 - } 701 - 702 - func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 703 - l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 704 - user := rp.oauth.GetMultiAccountUser(r) 705 - 706 - issue, ok := r.Context().Value("issue").(*models.Issue) 707 - if !ok { 708 - l.Error("failed to get issue") 709 - rp.pages.Error404(w) 710 - return 711 - } 712 - 713 - commentId := chi.URLParam(r, "commentId") 714 - comments, err := db.GetComments( 715 - rp.db, 716 - orm.FilterEq("id", commentId), 717 - ) 718 - if err != nil { 719 - l.Error("failed to fetch comment", "id", commentId) 720 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 721 - return 722 - } 723 - if len(comments) != 1 { 724 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 725 - http.Error(w, "invalid comment id", http.StatusBadRequest) 726 - return 727 - } 728 - comment := comments[0] 729 - 730 - rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 731 - LoggedInUser: user, 732 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 733 - Issue: issue, 734 - Comment: &comment, 735 - }) 736 - } 737 - 738 - func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 739 - l := rp.logger.With("handler", "ReplyIssueComment") 740 - user := rp.oauth.GetMultiAccountUser(r) 741 - 742 - issue, ok := r.Context().Value("issue").(*models.Issue) 743 - if !ok { 744 - l.Error("failed to get issue") 745 - rp.pages.Error404(w) 746 - return 747 - } 748 - 749 - commentId := chi.URLParam(r, "commentId") 750 - comments, err := db.GetComments( 751 - rp.db, 752 - orm.FilterEq("id", commentId), 753 - ) 754 - if err != nil { 755 - l.Error("failed to fetch comment", "id", commentId) 756 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 757 - return 758 - } 759 - if len(comments) != 1 { 760 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 761 - http.Error(w, "invalid comment id", http.StatusBadRequest) 762 - return 763 - } 764 - comment := comments[0] 765 - 766 - rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 767 - LoggedInUser: user, 768 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 769 - Issue: issue, 770 - Comment: &comment, 771 - }) 772 - } 773 - 774 - func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 775 - l := rp.logger.With("handler", "DeleteIssueComment") 776 - user := rp.oauth.GetMultiAccountUser(r) 777 - 778 - issue, ok := r.Context().Value("issue").(*models.Issue) 779 - if !ok { 780 - l.Error("failed to get issue") 781 - rp.pages.Error404(w) 782 - return 783 - } 784 - 785 - commentId := chi.URLParam(r, "commentId") 786 - comments, err := db.GetComments( 787 - rp.db, 788 - orm.FilterEq("id", commentId), 789 - ) 790 - if err != nil { 791 - l.Error("failed to fetch comment", "id", commentId) 792 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 793 - return 794 - } 795 - if len(comments) != 1 { 796 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 797 - http.Error(w, "invalid comment id", http.StatusBadRequest) 798 - return 799 - } 800 - comment := comments[0] 801 - 802 - if comment.Did.String() != user.Active.Did { 803 - l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) 804 - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 805 - return 806 - } 807 - 808 - if comment.Deleted != nil { 809 - http.Error(w, "comment already deleted", http.StatusBadRequest) 810 - return 811 - } 812 - 813 - // optimistic deletion 814 - deleted := time.Now() 815 - err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) 816 - if err != nil { 817 - l.Error("failed to delete comment", "err", err) 818 - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 819 - return 820 - } 821 - 822 - // delete from pds 823 - if comment.Rkey != "" { 824 - client, err := rp.oauth.AuthorizedClient(r) 825 - if err != nil { 826 - l.Error("failed to get authorized client", "err", err) 827 - rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 828 - return 829 - } 830 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 831 - Collection: comment.Collection.String(), 832 - Repo: comment.Did.String(), 833 - Rkey: comment.Rkey.String(), 834 - }) 835 - if err != nil { 836 - l.Error("failed to delete from PDS", "err", err) 837 - } 838 - } 839 - 840 - // optimistic update for htmx 841 - comment.Body = tangled.MarkupMarkdown{} 842 - comment.Deleted = &deleted 843 - 844 - // htmx fragment of comment after deletion 845 - rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 846 - LoggedInUser: user, 847 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 848 - Issue: issue, 849 - Comment: &comment, 850 - }) 851 391 } 852 392 853 393 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
-9
appview/issues/router.go
··· 21 21 // authenticated routes 22 22 r.Group(func(r chi.Router) { 23 23 r.Use(middleware.AuthMiddleware(i.oauth)) 24 - r.Post("/comment", i.NewIssueComment) 25 - r.Route("/comment/{commentId}/", func(r chi.Router) { 26 - r.Get("/", i.IssueComment) 27 - r.Delete("/", i.DeleteIssueComment) 28 - r.Get("/edit", i.EditIssueComment) 29 - r.Post("/edit", i.EditIssueComment) 30 - r.Get("/reply", i.ReplyIssueComment) 31 - r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 32 - }) 33 24 r.Get("/edit", i.EditIssue) 34 25 r.Post("/edit", i.EditIssue) 35 26 r.Delete("/", i.DeleteIssue)
+5 -5
appview/models/comment.go
··· 31 31 Deleted *time.Time 32 32 } 33 33 34 - func (c *Comment) AtUri() syntax.ATURI { 34 + func (c Comment) AtUri() syntax.ATURI { 35 35 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, c.Collection, c.Rkey)) 36 36 } 37 37 38 - func (c *Comment) StrongRef() comatproto.RepoStrongRef { 38 + func (c Comment) StrongRef() comatproto.RepoStrongRef { 39 39 return comatproto.RepoStrongRef{ 40 40 Uri: c.AtUri().String(), 41 41 Cid: c.Cid.String(), 42 42 } 43 43 } 44 44 45 - func (c *Comment) AsRecord() typegen.CBORMarshaler { 45 + func (c Comment) AsRecord() typegen.CBORMarshaler { 46 46 // can't convert to record for legacy types 47 47 if c.Collection != tangled.FeedCommentNSID { 48 48 return nil ··· 60 60 } 61 61 } 62 62 63 - func (c *Comment) EditableBody() string { 63 + func (c Comment) EditableBody() string { 64 64 if c.Body.Original != nil { 65 65 return *c.Body.Original 66 66 } 67 67 return c.Body.Text 68 68 } 69 69 70 - func (c *Comment) IsLegacy() bool { 70 + func (c Comment) IsLegacy() bool { 71 71 return c.Collection != tangled.FeedCommentNSID 72 72 } 73 73
+32 -1
appview/pages/pages.go
··· 1210 1210 type IssueCommentBodyParams struct { 1211 1211 LoggedInUser *oauth.MultiAccountUser 1212 1212 RepoInfo repoinfo.RepoInfo 1213 - Issue *models.Issue 1214 1213 Comment *models.Comment 1215 1214 } 1216 1215 ··· 1619 1618 1620 1619 func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1621 1620 return p.execute("timeline/home", w, params) 1621 + } 1622 + 1623 + type CommentBodyFragmentParams struct { 1624 + Comment models.Comment 1625 + } 1626 + 1627 + func (p *Pages) CommentBodyFragment(w io.Writer, params CommentBodyFragmentParams) error { 1628 + return p.executePlain("fragments/comment/commentBody", w, params) 1629 + } 1630 + 1631 + type EditCommentFragmentParams struct { 1632 + Comment models.Comment 1633 + } 1634 + 1635 + func (p *Pages) EditCommentFragment(w io.Writer, params EditCommentFragmentParams) error { 1636 + return p.executePlain("fragments/comment/edit", w, params) 1637 + } 1638 + 1639 + type ReplyCommentFragmentParams struct { 1640 + LoggedInUser *oauth.MultiAccountUser 1641 + } 1642 + 1643 + func (p *Pages) ReplyCommentFragment(w io.Writer, params ReplyCommentFragmentParams) error { 1644 + return p.executePlain("fragments/comment/reply", w, params) 1645 + } 1646 + 1647 + type ReplyPlaceholderFragmentParams struct { 1648 + LoggedInUser *oauth.MultiAccountUser 1649 + } 1650 + 1651 + func (p *Pages) ReplyPlaceholderFragment(w io.Writer, params ReplyPlaceholderFragmentParams) error { 1652 + return p.executePlain("fragments/comment/replyPlaceholder", w, params) 1622 1653 } 1623 1654 1624 1655 func (p *Pages) Static() http.Handler {
+49
appview/pages/templates/fragments/comment/edit.html
··· 1 + {{ define "fragments/comment/edit" }} 2 + <form 3 + class="pt-2" 4 + hx-patch="/comment" 5 + hx-swap="outerHTML" 6 + hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:find textarea" 7 + hx-indicator="find button[type='submit']" 8 + hx-disabled-elt="find button[type='submit']" 9 + > 10 + <input name="aturi" type="hidden" value="{{ .Comment.AtUri }}"> 11 + <textarea 12 + name="body" 13 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 14 + rows="5" 15 + autofocus>{{ .Comment.EditableBody }}</textarea> 16 + <div id="comment-error" class="error"></div> 17 + {{ template "editActions" $ }} 18 + </form> 19 + {{ end }} 20 + 21 + {{ define "editActions" }} 22 + <div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2"> 23 + {{ template "cancel" . }} 24 + {{ template "save" . }} 25 + </div> 26 + {{ end }} 27 + 28 + {{ define "save" }} 29 + <button 30 + type="submit" 31 + class="btn-create py-0 flex gap-1 items-center group text-sm" 32 + > 33 + {{ i "check" "size-4" }} 34 + save 35 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 + </button> 37 + {{ end }} 38 + 39 + {{ define "cancel" }} 40 + <button 41 + class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group" 42 + hx-get="/comment?aturi={{ .Comment.AtUri }}" 43 + hx-target="closest form" 44 + hx-swap="outerHTML" 45 + > 46 + {{ i "x" "size-4" }} 47 + cancel 48 + </button> 49 + {{ end }}
+50
appview/pages/templates/fragments/comment/reply.html
··· 1 + {{ define "fragments/comment/reply" }} 2 + <form 3 + class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2" 4 + hx-post="/comment" 5 + hx-swap="none" 6 + hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:find textarea" 7 + hx-on::after-request="if(event.detail.successful) this.reset()" 8 + hx-disabled-elt="find button[type='submit']" 9 + > 10 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 11 + <textarea 12 + name="body" 13 + class="w-full p-2" 14 + placeholder="Leave a reply..." 15 + autofocus 16 + rows="3"></textarea> 17 + <div id="comment-error" class="error"></div> 18 + {{ template "replyActions" . }} 19 + </form> 20 + {{ end }} 21 + 22 + {{ define "replyActions" }} 23 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm"> 24 + {{ template "cancel" . }} 25 + {{ template "reply" . }} 26 + </div> 27 + {{ end }} 28 + 29 + {{ define "cancel" }} 30 + <button 31 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 32 + hx-get="/comment/reply/placeholder" 33 + hx-target="closest form" 34 + hx-swap="outerHTML" 35 + > 36 + {{ i "x" "size-4" }} 37 + cancel 38 + </button> 39 + {{ end }} 40 + 41 + {{ define "reply" }} 42 + <button 43 + type="submit" 44 + class="btn-create flex items-center gap-2 no-underline hover:no-underline" 45 + > 46 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 47 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 48 + reply 49 + </button> 50 + {{ end }}
+15
appview/pages/templates/fragments/comment/replyPlaceholder.html
··· 1 + {{ define "fragments/comment/replyPlaceholder" }} 2 + <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 + {{ if .LoggedInUser }} 4 + {{ template "user/fragments/pic" (list .LoggedInUser.Did "size-8 mr-1") }} 5 + {{ end }} 6 + <input 7 + class="w-full p-0 border-none focus:outline-none bg-transparent" 8 + placeholder="Leave a reply..." 9 + hx-get="/comment/reply" 10 + hx-trigger="focus" 11 + hx-target="closest div" 12 + hx-swap="outerHTML" 13 + > 14 + </div> 15 + {{ end }}
+23 -15
appview/pages/templates/repo/issues/fragments/commentList.html
··· 1 1 {{ define "repo/issues/fragments/commentList" }} 2 2 <div class="flex flex-col gap-4"> 3 3 {{ range $item := .CommentList }} 4 - {{ template "commentListing" (list $ .) }} 4 + {{ template "commentListItem" (list $ .) }} 5 5 {{ end }} 6 6 </div> 7 7 {{ end }} 8 8 9 - {{ define "commentListing" }} 9 + {{ define "commentListItem" }} 10 10 {{ $root := index . 0 }} 11 - {{ $comment := index . 1 }} 11 + {{ $item := index . 1 }} 12 12 {{ $params := 13 13 (dict 14 - "RepoInfo" $root.RepoInfo 15 14 "LoggedInUser" $root.LoggedInUser 16 - "Issue" $root.Issue 17 - "Comment" $comment.Self) }} 15 + "Comment" $item.Self) }} 18 16 19 17 <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> 20 18 {{ template "topLevelComment" $params }} 21 19 22 20 <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 23 - {{ range $index, $reply := $comment.Replies }} 21 + {{ range $index, $reply := $item.Replies }} 24 22 <div class="-ml-4"> 25 23 {{ 26 24 template "replyComment" 27 25 (dict 28 - "RepoInfo" $root.RepoInfo 29 26 "LoggedInUser" $root.LoggedInUser 30 - "Issue" $root.Issue 31 - "Comment" $reply) 27 + "Comment" $reply) 32 28 }} 33 29 </div> 34 30 {{ end }} 35 31 </div> 36 32 37 - {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} 33 + <div hx-include="this"> 34 + <input name="subject-uri" type="hidden" value="{{ $item.Self.Subject.Uri }}"> 35 + <input name="subject-cid" type="hidden" value="{{ $item.Self.Subject.Cid }}"> 36 + <input name="reply-to-uri" type="hidden" value="{{ $item.Self.AtUri }}"> 37 + <input name="reply-to-cid" type="hidden" value="{{ $item.Self.Cid }}"> 38 + {{ if $item.Self.IsLegacy }} 39 + <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 40 + <span class="text-orange-500">Can't reply to legacy comment.</span> 41 + </div> 42 + {{ else }} 43 + {{ template "fragments/comment/replyPlaceholder" (dict "LoggedInUser" $root.LoggedInUser) }} 44 + {{ end }} 45 + </div> 38 46 </div> 39 47 {{ end }} 40 48 ··· 44 52 {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }} 45 53 </div> 46 54 <div class="flex-1 min-w-0"> 47 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 48 - {{ template "repo/issues/fragments/issueCommentBody" . }} 55 + {{ template "fragments/comment/commentHeader" . }} 56 + {{ template "fragments/comment/commentBody" . }} 49 57 </div> 50 58 </div> 51 59 {{ end }} ··· 56 64 {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1") }} 57 65 </div> 58 66 <div class="flex-1 min-w-0"> 59 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 60 - {{ template "repo/issues/fragments/issueCommentBody" . }} 67 + {{ template "fragments/comment/commentHeader" . }} 68 + {{ template "fragments/comment/commentBody" . }} 61 69 </div> 62 70 </div> 63 71 {{ end }}
-44
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 - {{ define "repo/issues/fragments/editIssueComment" }} 2 - <div id="comment-body-{{.Comment.Id}}" class="pt-2"> 3 - <textarea 4 - id="edit-textarea-{{ .Comment.Id }}" 5 - name="body" 6 - class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 7 - rows="5" 8 - autofocus>{{ .Comment.EditableBody }}</textarea> 9 - 10 - {{ template "editActions" $ }} 11 - </div> 12 - {{ end }} 13 - 14 - {{ define "editActions" }} 15 - <div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2"> 16 - {{ template "cancel" . }} 17 - {{ template "save" . }} 18 - </div> 19 - {{ end }} 20 - 21 - {{ define "save" }} 22 - <button 23 - class="btn-create py-0 flex gap-1 items-center group text-sm" 24 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 25 - hx-trigger="click, keydown[(ctrlKey || metaKey) && key=='Enter'] from:#edit-textarea-{{ .Comment.Id }}" 26 - hx-include="#edit-textarea-{{ .Comment.Id }}" 27 - hx-target="#comment-body-{{ .Comment.Id }}" 28 - hx-swap="outerHTML"> 29 - {{ i "check" "size-4" }} 30 - save 31 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 - </button> 33 - {{ end }} 34 - 35 - {{ define "cancel" }} 36 - <button 37 - class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group" 38 - hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 39 - hx-target="#comment-body-{{ .Comment.Id }}" 40 - hx-swap="outerHTML"> 41 - {{ i "x" "size-4" }} 42 - cancel 43 - </button> 44 - {{ end }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentBody.html appview/pages/templates/fragments/comment/commentBody.html
··· 1 - {{ define "repo/issues/fragments/issueCommentBody" }} 2 - <div id="comment-body-{{.Comment.Id}}"> 1 + {{ define "fragments/comment/commentBody" }} 2 + <div class="comment-body"> 3 3 {{ if not .Comment.Deleted }} 4 4 <div class="prose dark:prose-invert">{{ .Comment.Body.Text | markdown }}</div> 5 5 {{ else }}
+13 -13
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html appview/pages/templates/fragments/comment/commentHeader.html
··· 1 - {{ define "repo/issues/fragments/issueCommentHeader" }} 2 - <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 1 + {{ define "fragments/comment/commentHeader" }} 2 + <div 3 + class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 " 4 + hx-target="next .comment-body" 5 + > 3 6 {{ $handle := resolve .Comment.Did.String }} 4 7 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="/{{ $handle }}">{{ $handle }}</a> 5 8 {{ template "hats" $ }} ··· 8 11 {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} 9 12 {{ if and $isCommentOwner (not .Comment.Deleted) }} 10 13 {{ if not .Comment.IsLegacy }} 11 - {{ template "editIssueComment" . }} 14 + {{ template "editCommentBtn" . }} 12 15 {{ end }} 13 - {{ template "deleteIssueComment" . }} 16 + {{ template "deleteCommentBtn" . }} 14 17 {{ end }} 15 18 </div> 16 19 {{ end }} ··· 36 39 </a> 37 40 {{ end }} 38 41 39 - {{ define "editIssueComment" }} 42 + {{ define "editCommentBtn" }} 40 43 <a 41 44 class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 42 - hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 43 - hx-swap="outerHTML" 44 - hx-target="#comment-body-{{.Comment.Id}}"> 45 + hx-get="/comment/edit?aturi={{ .Comment.AtUri }}" 46 + > 45 47 {{ i "pencil" "size-3 inline group-[.htmx-request]:hidden" }} 46 48 {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 47 49 </a> 48 50 {{ end }} 49 51 50 - {{ define "deleteIssueComment" }} 52 + {{ define "deleteCommentBtn" }} 51 53 <a 52 54 class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group cursor-pointer" 53 - hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 55 + hx-delete="/comment?aturi={{ .Comment.AtUri }}" 54 56 hx-confirm="Are you sure you want to delete your comment?" 55 - hx-swap="outerHTML" 56 - hx-target="#comment-body-{{.Comment.Id}}" 57 - > 57 + > 58 58 {{ i "trash-2" "size-3 inline group-[.htmx-request]:hidden" }} 59 59 {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 60 60 </a>
+16 -37
appview/pages/templates/repo/issues/fragments/newComment.html
··· 1 1 {{ define "repo/issues/fragments/newComment" }} 2 2 {{ if .LoggedInUser }} 3 3 <form 4 - id="comment-form" 5 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 - hx-trigger="submit, keydown[commentButtonEnabled() && (ctrlKey || metaKey) && key=='Enter'] from:#comment-textarea" 7 - hx-disabled-elt="#comment-form button" 8 - hx-on::after-request="if(event.detail.successful) this.reset()" 9 - class="group/form" 4 + hx-post="/comment" 5 + hx-trigger="submit, click from:#close-button, keydown[commentButtonEnabled() && (ctrlKey || metaKey) && key=='Enter'] from:#comment-textarea" 6 + hx-disabled-elt="find button[type='submit']" 7 + hx-on::after-request="if(event.detail.successful) this.reset()" 8 + class="group/form" 10 9 > 10 + <input name="subject-uri" type="hidden" value="{{ .Issue.AtUri }}"> 11 11 <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> 12 12 <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 13 13 {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 14 14 </div> 15 - <textarea 16 - id="comment-textarea" 17 - name="body" 18 - class="w-full p-2 rounded" 19 - placeholder="Add to the discussion. Markdown is supported." 20 - onkeyup="updateCommentForm()" 21 - rows="5" 22 - ></textarea> 23 - <div id="issue-comment"></div> 15 + <textarea 16 + id="comment-textarea" 17 + name="body" 18 + class="w-full p-2 rounded" 19 + placeholder="Add to the discussion. Markdown is supported." 20 + onkeyup="updateCommentForm()" 21 + rows="5" 22 + required 23 + ></textarea> 24 + <div id="comment-error" class="error"></div> 24 25 <div id="issue-action" class="error"></div> 25 26 </div> 26 27 ··· 51 52 <span id="close-button-text">close</span> 52 53 </button> 53 54 <div 54 - id="close-with-comment" 55 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 56 - hx-trigger="click from:#close-button" 57 - hx-disabled-elt="#close-with-comment" 58 - hx-target="#issue-comment" 59 - hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 60 - hx-swap="none" 61 - hx-indicator="#close-button" 62 - > 63 - </div> 64 - <div 65 55 id="close-issue" 66 56 hx-disabled-elt="#close-issue" 67 57 hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" ··· 71 61 hx-indicator="#close-button" 72 62 > 73 63 </div> 74 - <script> 75 - document.addEventListener('htmx:configRequest', function(evt) { 76 - if (evt.target.id === 'close-with-comment') { 77 - const commentText = document.getElementById('comment-textarea').value.trim(); 78 - if (commentText === '') { 79 - evt.detail.parameters = {}; 80 - evt.preventDefault(); 81 - } 82 - } 83 - }); 84 - </script> 85 64 {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }} 86 65 <button 87 66 type="button"
-66
appview/pages/templates/repo/issues/fragments/replyComment.html
··· 1 - {{ define "repo/issues/fragments/replyComment" }} 2 - <form 3 - class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2" 4 - id="reply-form-{{ .Comment.Id }}" 5 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 - hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:#reply-{{.Comment.Id}}-textarea" 7 - hx-on::after-request="if(event.detail.successful) this.reset()" 8 - hx-disabled-elt="#reply-{{ .Comment.Id }}" 9 - > 10 - {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 11 - <textarea 12 - id="reply-{{.Comment.Id}}-textarea" 13 - name="body" 14 - class="w-full p-2" 15 - placeholder="Leave a reply..." 16 - autofocus 17 - rows="3"></textarea> 18 - 19 - <input 20 - type="text" 21 - id="reply-to-uri" 22 - name="reply-to-uri" 23 - required 24 - value="{{ .Comment.AtUri }}" 25 - class="hidden" 26 - /> 27 - <input 28 - type="text" 29 - id="reply-to-cid" 30 - name="reply-to-cid" 31 - required 32 - value="{{ .Comment.Cid }}" 33 - class="hidden" 34 - /> 35 - {{ template "replyActions" . }} 36 - </form> 37 - {{ end }} 38 - 39 - {{ define "replyActions" }} 40 - <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm"> 41 - {{ template "cancel" . }} 42 - {{ template "reply" . }} 43 - </div> 44 - {{ end }} 45 - 46 - {{ define "cancel" }} 47 - <button 48 - class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 49 - hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder" 50 - hx-target="#reply-form-{{ .Comment.Id }}" 51 - hx-swap="outerHTML"> 52 - {{ i "x" "size-4" }} 53 - cancel 54 - </button> 55 - {{ end }} 56 - 57 - {{ define "reply" }} 58 - <button 59 - id="reply-{{ .Comment.Id }}" 60 - type="submit" 61 - class="btn-create flex items-center gap-2 no-underline hover:no-underline"> 62 - {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 63 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 64 - reply 65 - </button> 66 - {{ end }}
-22
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 - {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 - <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 - {{ if .Comment.IsLegacy }} 4 - {{ if .LoggedInUser }} 5 - <span class="text-orange-500">Can't reply to legacy comment.</span> 6 - {{ end }} 7 - {{ else }} 8 - {{ if .LoggedInUser }} 9 - {{ template "user/fragments/pic" (list .LoggedInUser.Did "size-8 mr-1") }} 10 - {{ end }} 11 - <input 12 - class="w-full p-0 border-none focus:outline-none bg-transparent" 13 - placeholder="Leave a reply..." 14 - hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 15 - hx-trigger="focus" 16 - hx-target="closest div" 17 - hx-swap="outerHTML" 18 - > 19 - </input> 20 - {{ end }} 21 - </div> 22 - {{ end }}
+8 -18
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 22 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 - <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative p-2"> 25 + <div id="actions-{{$roundNumber}}" hx-target="this" class="flex flex-wrap gap-2 relative p-2"> 26 26 <button 27 27 hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 28 - hx-target="#actions-{{$roundNumber}}" 29 - hx-swap="outerHtml" 30 28 class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 29 {{ i "message-square-plus" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 32 30 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} ··· 44 42 </button> 45 43 {{ end }} 46 44 {{ if and $isPushAllowed $isOpen $isLastRound }} 47 - {{ $disabled := "" }} 48 - {{ if $isConflicted }} 49 - {{ $disabled = "disabled" }} 50 - {{ end }} 51 45 <button 52 46 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 53 47 hx-swap="none" 54 48 hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 55 - class="btn-flat p-2 flex items-center gap-2 group" {{ $disabled }}> 49 + class="btn-flat p-2 flex items-center gap-2 group" 50 + {{ if $isConflicted }}disabled{{ end }} 51 + > 56 52 {{ i "git-merge" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 57 53 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 54 merge{{if $stackCount}} {{$stackCount}}{{end}} ··· 60 56 {{ end }} 61 57 62 58 {{ if and $isPullAuthor $isOpen $isLastRound }} 63 - {{ $disabled := "" }} 64 - {{ if $isUpToDate }} 65 - {{ $disabled = "disabled" }} 66 - {{ end }} 67 59 <button id="resubmitBtn" 68 60 {{ if not .Pull.IsPatchBased }} 69 61 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 62 + hx-swap="none" 70 63 {{ else }} 71 64 hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 72 - hx-target="#actions-{{$roundNumber}}" 73 - hx-swap="outerHtml" 74 65 {{ end }} 75 66 76 67 hx-disabled-elt="#resubmitBtn" 77 - class="btn-flat p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 68 + class="btn-flat p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" 78 69 79 - {{ if $disabled }} 70 + {{ if not $isUpToDate }} 80 71 title="Update this branch to resubmit this pull request" 72 + disabled 81 73 {{ else }} 82 74 title="Resubmit this pull request" 83 75 {{ end }} ··· 111 103 {{ end }} 112 104 </div> 113 105 {{ end }} 114 - 115 -
+12 -14
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 1 1 {{ define "repo/pulls/fragments/pullNewComment" }} 2 - <div 3 - id="pull-comment-card-{{ .RoundNumber }}" 4 - class="w-full flex flex-col gap-2"> 2 + <div class="w-full flex flex-col gap-2"> 5 3 {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 6 4 <form 7 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 8 - hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:#pull-comment-textarea" 5 + class="w-full flex flex-wrap gap-2 group" 6 + hx-post="/comment" 7 + hx-trigger="submit, keydown[(ctrlKey || metaKey) && key=='Enter'] from:find textarea" 9 8 hx-swap="none" 9 + hx-indicator="find button[type='submit']" 10 + hx-disabled-elt="find button[type='submit']" 10 11 hx-on::after-request="if(event.detail.successful) this.reset()" 11 - hx-disabled-elt="#reply-{{ .RoundNumber }}" 12 - class="w-full flex flex-wrap gap-2 group" 13 12 > 13 + <input name="subject-uri" type="hidden" value="{{ .Pull.AtUri }}"> 14 + <input name="pull-round-idx" type="hidden" value="{{ .RoundNumber }}"> 14 15 <textarea 15 - id="pull-comment-textarea" 16 - name="body" 17 - class="w-full p-2 rounded border" 18 - rows=8 19 - placeholder="Add to the discussion..."></textarea 16 + name="body" 17 + class="w-full p-2 rounded border" 18 + rows=8 19 + placeholder="Add to the discussion..."></textarea 20 20 > 21 21 {{ template "replyActions" . }} 22 22 <div id="pull-comment"></div> ··· 47 47 {{ define "reply" }} 48 48 <button 49 49 type="submit" 50 - id="reply-{{ .RoundNumber }}" 51 50 class="btn-create flex items-center gap-2"> 52 51 {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 53 52 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 54 53 reply 55 54 </button> 56 55 {{ end }} 57 -
+19 -2
appview/pages/templates/repo/pulls/pull.html
··· 592 592 </summary> 593 593 <div> 594 594 {{ range $item.Comments }} 595 - {{ template "submissionComment" . }} 595 + {{/* template "submissionComment" . */}} 596 + {{ template "comment" (dict "LoggedInUser" $root.LoggedInUser "Comment" .) }} 596 597 {{ end }} 597 598 </div> 598 599 {{ if gt $c 0}} ··· 607 608 {{ block "resubmitStatus" $root }} {{ end }} 608 609 {{ end }} 609 610 </div> 610 - <div class="relative -ml-10 bg-gray-50 dark:bg-gray-900"> 611 + <div hx-include="this" class="relative -ml-10 bg-gray-50 dark:bg-gray-900"> 611 612 {{ if $root.LoggedInUser }} 613 + <input name="subject-uri" type="hidden" value="{{ $root.Pull.AtUri }}"> 614 + <input name="pull-round-idx" type="hidden" value="{{ $item.RoundNumber }}"> 612 615 {{ template "repo/pulls/fragments/pullActions" 613 616 (dict 614 617 "LoggedInUser" $root.LoggedInUser ··· 622 625 {{ end }} 623 626 </div> 624 627 </details> 628 + {{ end }} 629 + 630 + {{ define "comment" }} 631 + <div class="flex gap-2 -ml-4 py-4 w-full mx-auto"> 632 + <!-- left column: profile picture --> 633 + <div class="flex-shrink-0 h-fit relative"> 634 + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8") }} 635 + </div> 636 + <!-- right column: name and body in two rows --> 637 + <div class="flex-1 min-w-0"> 638 + {{ template "fragments/comment/commentHeader" . }} 639 + {{ template "fragments/comment/commentBody" . }} 640 + </div> 641 + </div> 625 642 {{ end }} 626 643 627 644 {{ define "submissionComment" }}
+4 -125
appview/pulls/pulls.go
··· 288 288 diff = patchutil.Interdiff(previousPatch, currentPatch) 289 289 } 290 290 291 - s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 291 + err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 292 292 LoggedInUser: user, 293 293 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 294 294 Pull: pull, ··· 308 308 309 309 LabelDefs: defs, 310 310 }) 311 + if err != nil { 312 + s.logger.Error("failed to render", "err", err) 313 + } 311 314 } 312 315 313 316 func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { ··· 804 807 l = l.With("user", user.Active.Did) 805 808 } 806 809 807 - f, err := s.repoResolver.Resolve(r) 808 - if err != nil { 809 - l.Error("failed to get repo and knot", "err", err) 810 - return 811 - } 812 - 813 810 pull, ok := r.Context().Value("pull").(*models.Pull) 814 811 if !ok { 815 812 l.Error("failed to get pull") ··· 834 831 Pull: pull, 835 832 RoundNumber: roundNumber, 836 833 }) 837 - return 838 - case http.MethodPost: 839 - body := r.FormValue("body") 840 - if body == "" { 841 - s.pages.Notice(w, "pull-comment", "Comment body is required") 842 - return 843 - } 844 - 845 - // TODO(boltless): normalize markdown body 846 - normalizedBody := body 847 - mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 848 - 849 - markdownBody := tangled.MarkupMarkdown{ 850 - Text: normalizedBody, 851 - Original: &body, 852 - Blobs: nil, 853 - } 854 - 855 - // ingest CID of PR record on-demand. 856 - // TODO(boltless): appview should ingest CID of atproto records 857 - cid, err := func() (syntax.CID, error) { 858 - ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 859 - if err != nil { 860 - return "", err 861 - } 862 - 863 - xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 864 - out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoPullNSID, pull.OwnerDid, pull.Rkey) 865 - if err != nil { 866 - return "", err 867 - } 868 - if out.Cid == nil { 869 - return "", fmt.Errorf("record CID is empty") 870 - } 871 - 872 - cid, err := syntax.ParseCID(*out.Cid) 873 - if err != nil { 874 - return "", err 875 - } 876 - 877 - return cid, nil 878 - }() 879 - if err != nil { 880 - s.logger.Error("failed to backfill subject PR record", "err", err) 881 - s.pages.Notice(w, "pull-comment", "failed to backfill subject record") 882 - return 883 - } 884 - pullStrongRef := comatproto.RepoStrongRef{ 885 - Uri: pull.AtUri().String(), 886 - Cid: cid.String(), 887 - } 888 - 889 - comment := models.Comment{ 890 - Did: syntax.DID(user.Active.Did), 891 - Collection: tangled.FeedCommentNSID, 892 - Rkey: syntax.RecordKey(tid.TID()), 893 - 894 - Subject: pullStrongRef, 895 - Body: markdownBody, 896 - Created: time.Now(), 897 - ReplyTo: nil, 898 - PullRoundIdx: &roundNumber, 899 - } 900 - if err = comment.Validate(); err != nil { 901 - s.logger.Error("failed to validate comment", "err", err) 902 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 903 - return 904 - } 905 - 906 - client, err := s.oauth.AuthorizedClient(r) 907 - if err != nil { 908 - s.logger.Error("failed to get authorized client", "err", err) 909 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 910 - return 911 - } 912 - 913 - out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 914 - Collection: comment.Collection.String(), 915 - Repo: comment.Did.String(), 916 - Rkey: comment.Rkey.String(), 917 - Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 918 - }) 919 - if err != nil { 920 - s.logger.Error("failed to create pull comment", "err", err) 921 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 922 - return 923 - } 924 - 925 - comment.Cid = syntax.CID(out.Cid) 926 - 927 - // Start a transaction 928 - tx, err := s.db.BeginTx(r.Context(), nil) 929 - if err != nil { 930 - l.Error("failed to start transaction", "err", err) 931 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 932 - return 933 - } 934 - defer tx.Rollback() 935 - 936 - // Create the pull comment in the database 937 - err = db.PutComment(tx, &comment, references) 938 - if err != nil { 939 - l.Error("failed to create pull comment in database", "err", err) 940 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 941 - return 942 - } 943 - 944 - // Commit the transaction 945 - if err = tx.Commit(); err != nil { 946 - l.Error("failed to commit transaction", "err", err) 947 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 948 - return 949 - } 950 - 951 - s.notifier.NewComment(r.Context(), &comment, mentions) 952 - 953 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 954 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) 955 834 return 956 835 } 957 836 }
+1 -4
appview/pulls/router.go
··· 29 29 r.Get("/", s.RepoPullPatch) 30 30 r.Get("/interdiff", s.RepoPullInterdiff) 31 31 r.Get("/actions", s.PullActions) 32 - r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) { 33 - r.Get("/", s.PullComment) 34 - r.Post("/", s.PullComment) 35 - }) 32 + r.Get("/comment", s.PullComment) 36 33 }) 37 34 38 35 r.Route("/round/{round}.patch", func(r chi.Router) {
+396
appview/state/comment.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strconv" 7 + "time" 8 + 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + lexutil "github.com/bluesky-social/indigo/lex/util" 12 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 + 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/models" 17 + "tangled.org/core/appview/pages" 18 + "tangled.org/core/orm" 19 + "tangled.org/core/tid" 20 + ) 21 + 22 + func (s *State) CommentBodyFragment(w http.ResponseWriter, r *http.Request) { 23 + l := s.logger.With("handler", "CommentBodyFragment") 24 + 25 + commentAt := r.URL.Query().Get("aturi") 26 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 27 + if err != nil { 28 + l.Error("failed to fetch comment", "aturi", commentAt) 29 + http.Error(w, "Failed to fetch comment", http.StatusInternalServerError) 30 + return 31 + } 32 + 33 + err = s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 34 + Comment: comment, 35 + }) 36 + if err != nil { 37 + l.Error("failed to render") 38 + } 39 + } 40 + 41 + func (s *State) EditCommentFragment(w http.ResponseWriter, r *http.Request) { 42 + l := s.logger.With("handler", "EditCommentFragment") 43 + 44 + commentAt := r.URL.Query().Get("aturi") 45 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 46 + if err != nil { 47 + l.Error("failed to fetch comment", "aturi", commentAt) 48 + http.Error(w, "Failed to fetch comment", http.StatusInternalServerError) 49 + return 50 + } 51 + 52 + err = s.pages.EditCommentFragment(w, pages.EditCommentFragmentParams{ 53 + Comment: comment, 54 + }) 55 + if err != nil { 56 + l.Error("failed to render") 57 + } 58 + } 59 + 60 + func (s *State) NewReplyCommentFragment(w http.ResponseWriter, r *http.Request) { 61 + s.pages.ReplyCommentFragment(w, pages.ReplyCommentFragmentParams{ 62 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 63 + }) 64 + } 65 + 66 + func (s *State) ReplyPlaceholderFragment(w http.ResponseWriter, r *http.Request) { 67 + s.pages.ReplyPlaceholderFragment(w, pages.ReplyPlaceholderFragmentParams{ 68 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 69 + }) 70 + } 71 + 72 + func (s *State) NewComment(w http.ResponseWriter, r *http.Request) { 73 + l := s.logger.With("handler", "NewComment") 74 + user := s.oauth.GetMultiAccountUser(r) 75 + 76 + noticeId := "comment-error" 77 + ctx := r.Context() 78 + 79 + body := r.FormValue("body") 80 + if body == "" { 81 + s.pages.Notice(w, noticeId, "Body is required") 82 + return 83 + } 84 + 85 + // TODO(boltless): normalize markdown body 86 + normalizedBody := body 87 + _, references := s.mentionsResolver.Resolve(ctx, body) 88 + 89 + markdownBody := tangled.MarkupMarkdown{ 90 + Text: normalizedBody, 91 + Original: &body, 92 + Blobs: nil, 93 + } 94 + 95 + subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri")) 96 + if err != nil { 97 + l.Warn("invalid subject uri", "err", err) 98 + s.pages.Notice(w, noticeId, "Subject URI should be valid AT-URI") 99 + return 100 + } 101 + l = l.With("subject.uri", subjectUri) 102 + 103 + // ingest CID of subject record on-demand. 104 + // TODO(boltless): appview should ingest CID of all atproto records 105 + var subjectCid syntax.CID 106 + if subjectCidRaw := r.FormValue("subject-cid"); subjectCidRaw != "" { 107 + subjectCid, err = syntax.ParseCID(subjectCidRaw) 108 + if err != nil { 109 + l.Warn("invalid subject cid", "err", err) 110 + s.pages.Notice(w, noticeId, "Subject URI should be valid AT-URI") 111 + return 112 + } 113 + } else { 114 + l.Debug("ingesting subject record CID") 115 + subjectCid, err = func(uri syntax.ATURI) (syntax.CID, error) { 116 + ident, err := s.idResolver.ResolveIdent(ctx, uri.Authority().String()) 117 + if err != nil { 118 + return "", err 119 + } 120 + 121 + xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 122 + out, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) 123 + if err != nil { 124 + return "", err 125 + } 126 + if out.Cid == nil { 127 + return "", fmt.Errorf("record CID is empty") 128 + } 129 + 130 + cid, err := syntax.ParseCID(*out.Cid) 131 + if err != nil { 132 + return "", err 133 + } 134 + 135 + return cid, nil 136 + }(subjectUri) 137 + if err != nil { 138 + l.Error("failed to backfill subject record", "err", err) 139 + s.pages.Notice(w, noticeId, "failed to backfill subject record") 140 + return 141 + } 142 + } 143 + l = l.With("subject.cid", subjectCid) 144 + 145 + subject := comatproto.RepoStrongRef{ 146 + Uri: subjectUri.String(), 147 + Cid: subjectCid.String(), 148 + } 149 + 150 + var pullRoundIdx *int 151 + if pullRoundIdxRaw := r.FormValue("pull-round-idx"); pullRoundIdxRaw != "" { 152 + roundIdx, err := strconv.Atoi(pullRoundIdxRaw) 153 + if err != nil { 154 + l.Warn("invalid round idx", "err", err) 155 + s.pages.Notice(w, noticeId, "pull round index should be valid integer") 156 + return 157 + } 158 + pullRoundIdx = &roundIdx 159 + } 160 + 161 + var replyTo *comatproto.RepoStrongRef 162 + replyToUriRaw := r.FormValue("reply-to-uri") 163 + replyToCidRaw := r.FormValue("reply-to-cid") 164 + if replyToUriRaw != "" && replyToCidRaw != "" { 165 + uri, err := syntax.ParseATURI(replyToUriRaw) 166 + if err != nil { 167 + s.pages.Notice(w, noticeId, "reply-to-uri should be valid AT-URI") 168 + return 169 + } 170 + cid, err := syntax.ParseCID(replyToCidRaw) 171 + if err != nil { 172 + s.pages.Notice(w, noticeId, "reply-to-cid should be valid CID") 173 + return 174 + } 175 + replyTo = &comatproto.RepoStrongRef{ 176 + Uri: uri.String(), 177 + Cid: cid.String(), 178 + } 179 + } 180 + 181 + comment := models.Comment{ 182 + Did: syntax.DID(user.Active.Did), 183 + Collection: tangled.FeedCommentNSID, 184 + Rkey: syntax.RecordKey(tid.TID()), 185 + 186 + Subject: subject, 187 + Body: markdownBody, 188 + Created: time.Now(), 189 + ReplyTo: replyTo, 190 + PullRoundIdx: pullRoundIdx, 191 + } 192 + if err = comment.Validate(); err != nil { 193 + l.Error("failed to validate comment", "err", err) 194 + s.pages.Notice(w, noticeId, "Failed to create comment.") 195 + return 196 + } 197 + 198 + client, err := s.oauth.AuthorizedClient(r) 199 + if err != nil { 200 + l.Error("failed to get authorized client", "err", err) 201 + s.pages.Notice(w, noticeId, "Failed to create comment.") 202 + return 203 + } 204 + 205 + // create a record first 206 + out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 207 + Collection: comment.Collection.String(), 208 + Repo: comment.Did.String(), 209 + Rkey: comment.Rkey.String(), 210 + Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 211 + }) 212 + if err != nil { 213 + l.Error("failed to create comment", "err", err) 214 + s.pages.Notice(w, noticeId, "Failed to create comment.") 215 + return 216 + } 217 + 218 + comment.Cid = syntax.CID(out.Cid) 219 + 220 + tx, err := s.db.Begin() 221 + if err != nil { 222 + l.Error("failed to start transaction", "err", err) 223 + s.pages.Notice(w, noticeId, "Failed to create comment, try again later.") 224 + return 225 + } 226 + defer tx.Rollback() 227 + 228 + err = db.PutComment(tx, &comment, references) 229 + if err != nil { 230 + l.Error("failed to create comment", "err", err) 231 + s.pages.Notice(w, noticeId, "Failed to create comment.") 232 + return 233 + } 234 + 235 + err = tx.Commit() 236 + if err != nil { 237 + l.Error("failed to commit transaction", "err", err) 238 + s.pages.Notice(w, noticeId, "Failed to create comment, try again later.") 239 + return 240 + } 241 + 242 + // TODO: return comment or reply-comment fragment 243 + // onattach, htmx-callback to focus on comment. 244 + s.pages.HxRefresh(w) 245 + } 246 + 247 + func (s *State) EditComment(w http.ResponseWriter, r *http.Request) { 248 + l := s.logger.With("handler", "EditComment") 249 + user := s.oauth.GetMultiAccountUser(r) 250 + 251 + noticeId := "comment-error" 252 + ctx := r.Context() 253 + 254 + commentAt := r.FormValue("aturi") 255 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 256 + if err != nil { 257 + l.Error("failed to fetch comment", "aturi", commentAt, "err", err) 258 + s.pages.Notice(w, noticeId, "Failed to fetch comment") 259 + return 260 + } 261 + 262 + if comment.Did.String() != user.Active.Did { 263 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) 264 + s.pages.Notice(w, noticeId, "You are not the author of this comment") 265 + return 266 + } 267 + 268 + body := r.FormValue("body") 269 + if body == "" { 270 + s.pages.Notice(w, noticeId, "Body is required") 271 + return 272 + } 273 + 274 + // TODO(boltless): normalize markdown body 275 + normalizedBody := body 276 + _, references := s.mentionsResolver.Resolve(ctx, body) 277 + 278 + now := time.Now() 279 + newComment := comment 280 + newComment.Body = tangled.MarkupMarkdown{ 281 + Text: normalizedBody, 282 + Original: &body, 283 + Blobs: nil, 284 + } 285 + newComment.Edited = &now 286 + if err := newComment.Validate(); err != nil { 287 + l.Error("failed to validate comment", "err", err) 288 + s.pages.Notice(w, noticeId, "Failed to update comment.") 289 + return 290 + } 291 + 292 + client, err := s.oauth.AuthorizedClient(r) 293 + if err != nil { 294 + l.Error("failed to get authorized client", "err", err) 295 + s.pages.Notice(w, noticeId, "Failed to create comment. try again later.") 296 + return 297 + } 298 + 299 + // update the record first 300 + exCid := comment.Cid.String() 301 + out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 302 + Collection: newComment.Collection.String(), 303 + Repo: newComment.Did.String(), 304 + Rkey: newComment.Rkey.String(), 305 + SwapRecord: &exCid, 306 + Record: &lexutil.LexiconTypeDecoder{ 307 + Val: newComment.AsRecord(), 308 + }, 309 + }) 310 + if err != nil { 311 + l.Error("failed to update comment", "err", err) 312 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 313 + return 314 + } 315 + 316 + newComment.Cid = syntax.CID(out.Cid) 317 + 318 + tx, err := s.db.Begin() 319 + if err != nil { 320 + l.Error("failed to start transaction", "err", err) 321 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 322 + return 323 + } 324 + defer tx.Rollback() 325 + 326 + err = db.PutComment(tx, &newComment, references) 327 + if err != nil { 328 + l.Error("failed to perform update-description query", "err", err) 329 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 330 + return 331 + } 332 + err = tx.Commit() 333 + if err != nil { 334 + l.Error("failed to commit transaction", "err", err) 335 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 336 + return 337 + } 338 + 339 + // TODO: return full comment fragment so we can update comment header too 340 + s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 341 + Comment: newComment, 342 + }) 343 + } 344 + 345 + func (s *State) DeleteComment(w http.ResponseWriter, r *http.Request) { 346 + l := s.logger.With("handler", "DeleteComment") 347 + user := s.oauth.GetMultiAccountUser(r) 348 + 349 + noticeId := "comment" 350 + ctx := r.Context() 351 + 352 + commentAt := r.URL.Query().Get("aturi") 353 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 354 + if err != nil { 355 + l.Error("failed to fetch comment", "aturi", commentAt) 356 + s.pages.Notice(w, noticeId, "Failed to fetch comment.") 357 + return 358 + } 359 + 360 + if comment.Did.String() != user.Active.Did { 361 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did) 362 + s.pages.Notice(w, noticeId, "you are not the author of this comment") 363 + return 364 + } 365 + 366 + if comment.Deleted != nil { 367 + s.pages.Notice(w, noticeId, "Comment already deleted") 368 + return 369 + } 370 + 371 + client, err := s.oauth.AuthorizedClient(r) 372 + if err != nil { 373 + l.Error("failed to get authorized client", "err", err) 374 + s.pages.Notice(w, "comment", "Failed to delete comment.") 375 + return 376 + } 377 + _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 378 + Collection: comment.Collection.String(), 379 + Repo: comment.Did.String(), 380 + Rkey: comment.Rkey.String(), 381 + }) 382 + if err != nil { 383 + l.Error("failed to delete from PDS", "err", err) 384 + s.pages.Notice(w, noticeId, "Failed to delete comment, try again later.") 385 + return 386 + } 387 + 388 + // optimistic update for htmx response 389 + now := time.Now() 390 + comment.Body = tangled.MarkupMarkdown{} 391 + comment.Deleted = &now 392 + 393 + s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 394 + Comment: comment, 395 + }) 396 + }
+10
appview/state/router.go
··· 188 188 r.Delete("/", s.React) 189 189 }) 190 190 191 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) { 192 + r.Get("/", s.CommentBodyFragment) 193 + r.Get("/edit", s.EditCommentFragment) 194 + r.Get("/reply", s.NewReplyCommentFragment) 195 + r.Get("/reply/placeholder", s.ReplyPlaceholderFragment) 196 + r.Post("/", s.NewComment) 197 + r.Patch("/", s.EditComment) 198 + r.Delete("/", s.DeleteComment) 199 + }) 200 + 191 201 r.Route("/profile", func(r chi.Router) { 192 202 r.Use(middleware.AuthMiddleware(s.oauth)) 193 203 r.Get("/edit-bio", s.EditBioFragment)
-6
appview/strings/strings.go
··· 55 55 r.Get("/raw", s.contents) 56 56 r.Get("/edit", s.edit) 57 57 r.Post("/edit", s.edit) 58 - r. 59 - With(middleware.AuthMiddleware(s.OAuth)). 60 - Post("/comment", s.comment) 61 58 }) 62 59 }) 63 60 ··· 436 433 437 434 s.Pages.HxRedirect(w, "/strings/"+user.Active.Did) 438 435 } 439 - 440 - func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 441 - }