Monorepo for Tangled tangled.org
856
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>

+702 -904
+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
··· 1298 1298 return fmt.Errorf("failed to validate comment: %w", err) 1299 1299 } 1300 1300 1301 + var mentions []syntax.DID 1301 1302 var references []syntax.ATURI 1302 1303 if comment.Body.Original != nil { 1303 - _, references = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) 1304 + mentions, references = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) 1304 1305 } 1305 1306 1306 1307 tx, err := ddb.Begin() ··· 1316 1317 1317 1318 if err := tx.Commit(); err != nil { 1318 1319 return err 1320 + } 1321 + 1322 + if e.Commit.Operation == jmodels.CommitOperationCreate { 1323 + i.Notifier.NewComment(ctx, comment, mentions) 1319 1324 } 1320 1325 1321 1326 case jmodels.CommitOperationDelete:
-463
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" ··· 400 398 http.Error(w, "forbidden", http.StatusUnauthorized) 401 399 return 402 400 } 403 - } 404 - 405 - func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 406 - l := rp.logger.With("handler", "NewIssueComment") 407 - user := rp.oauth.GetMultiAccountUser(r) 408 - f, err := rp.repoResolver.Resolve(r) 409 - if err != nil { 410 - l.Error("failed to get repo and knot", "err", err) 411 - return 412 - } 413 - 414 - issue, ok := r.Context().Value("issue").(*models.Issue) 415 - if !ok { 416 - l.Error("failed to get issue") 417 - rp.pages.Error404(w) 418 - return 419 - } 420 - 421 - body := r.FormValue("body") 422 - if body == "" { 423 - rp.pages.Notice(w, "issue-comment", "Body is required") 424 - return 425 - } 426 - 427 - // TODO(boltless): normalize markdown body 428 - normalizedBody := body 429 - _, references := rp.mentionsResolver.Resolve(r.Context(), body) 430 - 431 - markdownBody := tangled.MarkupMarkdown{ 432 - Text: normalizedBody, 433 - Original: &body, 434 - Blobs: nil, 435 - } 436 - 437 - // ingest CID of issue record on-demand. 438 - // TODO(boltless): appview should ingest CID of atproto records 439 - cid, err := func() (syntax.CID, error) { 440 - ident, err := rp.idResolver.ResolveIdent(r.Context(), issue.Did) 441 - if err != nil { 442 - return "", err 443 - } 444 - 445 - xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 446 - out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoIssueNSID, issue.Did, issue.Rkey) 447 - if err != nil { 448 - return "", err 449 - } 450 - if out.Cid == nil { 451 - return "", fmt.Errorf("record CID is empty") 452 - } 453 - 454 - cid, err := syntax.ParseCID(*out.Cid) 455 - if err != nil { 456 - return "", err 457 - } 458 - 459 - return cid, nil 460 - }() 461 - if err != nil { 462 - rp.logger.Error("failed to backfill subject PR record", "err", err) 463 - rp.pages.Notice(w, "issue-comment", "failed to backfill subject record") 464 - return 465 - } 466 - issueStrongRef := comatproto.RepoStrongRef{ 467 - Uri: issue.AtUri().String(), 468 - Cid: cid.String(), 469 - } 470 - 471 - var replyTo *comatproto.RepoStrongRef 472 - replyToUriRaw := r.FormValue("reply-to-uri") 473 - replyToCidRaw := r.FormValue("reply-to-cid") 474 - if replyToUriRaw != "" && replyToCidRaw != "" { 475 - uri, err := syntax.ParseATURI(replyToUriRaw) 476 - if err != nil { 477 - rp.pages.Notice(w, "issue-comment", "reply-to-uri should be valid AT-URI") 478 - return 479 - } 480 - cid, err := syntax.ParseCID(replyToCidRaw) 481 - if err != nil { 482 - rp.pages.Notice(w, "issue-comment", "reply-to-cid should be valid CID") 483 - return 484 - } 485 - replyTo = &comatproto.RepoStrongRef{ 486 - Uri: uri.String(), 487 - Cid: cid.String(), 488 - } 489 - } 490 - 491 - mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 492 - 493 - comment := models.Comment{ 494 - Did: syntax.DID(user.Did), 495 - Collection: tangled.FeedCommentNSID, 496 - Rkey: syntax.RecordKey(tid.TID()), 497 - 498 - Subject: issueStrongRef, 499 - Body: markdownBody, 500 - Created: time.Now(), 501 - ReplyTo: replyTo, 502 - } 503 - if err = comment.Validate(); err != nil { 504 - l.Error("failed to validate comment", "err", err) 505 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 506 - return 507 - } 508 - 509 - client, err := rp.oauth.AuthorizedClient(r) 510 - if err != nil { 511 - l.Error("failed to get authorized client", "err", err) 512 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 513 - return 514 - } 515 - 516 - // create a record first 517 - out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 518 - Collection: comment.Collection.String(), 519 - Repo: comment.Did.String(), 520 - Rkey: comment.Rkey.String(), 521 - Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 522 - }) 523 - if err != nil { 524 - l.Error("failed to create comment", "err", err) 525 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 526 - return 527 - } 528 - 529 - comment.Cid = syntax.CID(out.Cid) 530 - 531 - tx, err := rp.db.Begin() 532 - if err != nil { 533 - l.Error("failed to start transaction", "err", err) 534 - rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 535 - return 536 - } 537 - defer tx.Rollback() 538 - 539 - err = db.PutComment(tx, &comment, references) 540 - if err != nil { 541 - l.Error("failed to create comment", "err", err) 542 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 543 - return 544 - } 545 - 546 - err = tx.Commit() 547 - if err != nil { 548 - l.Error("failed to commit transaction", "err", err) 549 - rp.pages.Notice(w, "issue-comment", "Failed to create comment, try again later.") 550 - return 551 - } 552 - 553 - rp.notifier.NewComment(r.Context(), &comment, mentions) 554 - 555 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 556 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) 557 - } 558 - 559 - func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 560 - l := rp.logger.With("handler", "IssueComment") 561 - user := rp.oauth.GetMultiAccountUser(r) 562 - 563 - issue, ok := r.Context().Value("issue").(*models.Issue) 564 - if !ok { 565 - l.Error("failed to get issue") 566 - rp.pages.Error404(w) 567 - return 568 - } 569 - 570 - commentId := chi.URLParam(r, "commentId") 571 - comments, err := db.GetComments( 572 - rp.db, 573 - orm.FilterEq("id", commentId), 574 - ) 575 - if err != nil { 576 - l.Error("failed to fetch comment", "id", commentId) 577 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 578 - return 579 - } 580 - if len(comments) != 1 { 581 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 582 - http.Error(w, "invalid comment id", http.StatusBadRequest) 583 - return 584 - } 585 - comment := comments[0] 586 - 587 - rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 588 - LoggedInUser: user, 589 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 590 - Issue: issue, 591 - Comment: &comment, 592 - }) 593 - } 594 - 595 - func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 596 - l := rp.logger.With("handler", "EditIssueComment") 597 - user := rp.oauth.GetMultiAccountUser(r) 598 - 599 - issue, ok := r.Context().Value("issue").(*models.Issue) 600 - if !ok { 601 - l.Error("failed to get issue") 602 - rp.pages.Error404(w) 603 - return 604 - } 605 - 606 - commentId := chi.URLParam(r, "commentId") 607 - comments, err := db.GetComments( 608 - rp.db, 609 - orm.FilterEq("id", commentId), 610 - ) 611 - if err != nil { 612 - l.Error("failed to fetch comment", "id", commentId) 613 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 614 - return 615 - } 616 - if len(comments) != 1 { 617 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 618 - http.Error(w, "invalid comment id", http.StatusBadRequest) 619 - return 620 - } 621 - comment := comments[0] 622 - 623 - if comment.Did.String() != user.Did { 624 - l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 625 - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 626 - return 627 - } 628 - 629 - switch r.Method { 630 - case http.MethodGet: 631 - rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 632 - LoggedInUser: user, 633 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 634 - Issue: issue, 635 - Comment: &comment, 636 - }) 637 - case http.MethodPost: 638 - // extract form value 639 - body := r.FormValue("body") 640 - if body == "" { 641 - rp.pages.Notice(w, "issue-comment", "Body is required") 642 - return 643 - } 644 - 645 - // TODO(boltless): normalize markdown body 646 - normalizedBody := body 647 - _, references := rp.mentionsResolver.Resolve(r.Context(), body) 648 - 649 - now := time.Now() 650 - newComment := comment 651 - newComment.Body = tangled.MarkupMarkdown{ 652 - Text: normalizedBody, 653 - Original: &body, 654 - Blobs: nil, 655 - } 656 - newComment.Edited = &now 657 - 658 - client, err := rp.oauth.AuthorizedClient(r) 659 - if err != nil { 660 - l.Error("failed to get authorized client", "err", err) 661 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 662 - return 663 - } 664 - 665 - // update a record first 666 - exCid := comment.Cid.String() 667 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 668 - Collection: newComment.Collection.String(), 669 - Repo: newComment.Did.String(), 670 - Rkey: newComment.Rkey.String(), 671 - SwapRecord: &exCid, 672 - Record: &lexutil.LexiconTypeDecoder{ 673 - Val: newComment.AsRecord(), 674 - }, 675 - }) 676 - if err != nil { 677 - l.Error("failed to update comment", "err", err) 678 - rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") 679 - return 680 - } 681 - 682 - newComment.Cid = syntax.CID(resp.Cid) 683 - 684 - tx, err := rp.db.Begin() 685 - if err != nil { 686 - l.Error("failed to start transaction", "err", err) 687 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 688 - return 689 - } 690 - defer tx.Rollback() 691 - 692 - err = db.PutComment(tx, &newComment, references) 693 - if err != nil { 694 - l.Error("failed to perform update-description query", "err", err) 695 - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 696 - return 697 - } 698 - err = tx.Commit() 699 - if err != nil { 700 - l.Error("failed to commit transaction", "err", err) 701 - rp.pages.Notice(w, "issue-comment", "Failed to update comment, try again later.") 702 - return 703 - } 704 - 705 - // return new comment body with htmx 706 - rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 707 - LoggedInUser: user, 708 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 709 - Issue: issue, 710 - Comment: &newComment, 711 - }) 712 - } 713 - } 714 - 715 - func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 716 - l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 717 - user := rp.oauth.GetMultiAccountUser(r) 718 - 719 - issue, ok := r.Context().Value("issue").(*models.Issue) 720 - if !ok { 721 - l.Error("failed to get issue") 722 - rp.pages.Error404(w) 723 - return 724 - } 725 - 726 - commentId := chi.URLParam(r, "commentId") 727 - comments, err := db.GetComments( 728 - rp.db, 729 - orm.FilterEq("id", commentId), 730 - ) 731 - if err != nil { 732 - l.Error("failed to fetch comment", "id", commentId) 733 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 734 - return 735 - } 736 - if len(comments) != 1 { 737 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 738 - http.Error(w, "invalid comment id", http.StatusBadRequest) 739 - return 740 - } 741 - comment := comments[0] 742 - 743 - rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 744 - LoggedInUser: user, 745 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 746 - Issue: issue, 747 - Comment: &comment, 748 - }) 749 - } 750 - 751 - func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 752 - l := rp.logger.With("handler", "ReplyIssueComment") 753 - user := rp.oauth.GetMultiAccountUser(r) 754 - 755 - issue, ok := r.Context().Value("issue").(*models.Issue) 756 - if !ok { 757 - l.Error("failed to get issue") 758 - rp.pages.Error404(w) 759 - return 760 - } 761 - 762 - commentId := chi.URLParam(r, "commentId") 763 - comments, err := db.GetComments( 764 - rp.db, 765 - orm.FilterEq("id", commentId), 766 - ) 767 - if err != nil { 768 - l.Error("failed to fetch comment", "id", commentId) 769 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 770 - return 771 - } 772 - if len(comments) != 1 { 773 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 774 - http.Error(w, "invalid comment id", http.StatusBadRequest) 775 - return 776 - } 777 - comment := comments[0] 778 - 779 - rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 780 - LoggedInUser: user, 781 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 782 - Issue: issue, 783 - Comment: &comment, 784 - }) 785 - } 786 - 787 - func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 788 - l := rp.logger.With("handler", "DeleteIssueComment") 789 - user := rp.oauth.GetMultiAccountUser(r) 790 - 791 - issue, ok := r.Context().Value("issue").(*models.Issue) 792 - if !ok { 793 - l.Error("failed to get issue") 794 - rp.pages.Error404(w) 795 - return 796 - } 797 - 798 - commentId := chi.URLParam(r, "commentId") 799 - comments, err := db.GetComments( 800 - rp.db, 801 - orm.FilterEq("id", commentId), 802 - ) 803 - if err != nil { 804 - l.Error("failed to fetch comment", "id", commentId) 805 - http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 806 - return 807 - } 808 - if len(comments) != 1 { 809 - l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 810 - http.Error(w, "invalid comment id", http.StatusBadRequest) 811 - return 812 - } 813 - comment := comments[0] 814 - 815 - if comment.Did.String() != user.Did { 816 - l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 817 - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 818 - return 819 - } 820 - 821 - if comment.Deleted != nil { 822 - http.Error(w, "comment already deleted", http.StatusBadRequest) 823 - return 824 - } 825 - 826 - // optimistic deletion 827 - deleted := time.Now() 828 - err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) 829 - if err != nil { 830 - l.Error("failed to delete comment", "err", err) 831 - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 832 - return 833 - } 834 - 835 - // delete from pds 836 - if comment.Rkey != "" { 837 - client, err := rp.oauth.AuthorizedClient(r) 838 - if err != nil { 839 - l.Error("failed to get authorized client", "err", err) 840 - rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 841 - return 842 - } 843 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 844 - Collection: comment.Collection.String(), 845 - Repo: comment.Did.String(), 846 - Rkey: comment.Rkey.String(), 847 - }) 848 - if err != nil { 849 - l.Error("failed to delete from PDS", "err", err) 850 - } 851 - } 852 - 853 - // optimistic update for htmx 854 - comment.Body = tangled.MarkupMarkdown{} 855 - comment.Deleted = &deleted 856 - 857 - // htmx fragment of comment after deletion 858 - rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 859 - LoggedInUser: user, 860 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 861 - Issue: issue, 862 - Comment: &comment, 863 - }) 864 401 } 865 402 866 403 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
+34 -44
appview/pages/pages.go
··· 1234 1234 return p.executeRepo("repo/issues/new", w, params) 1235 1235 } 1236 1236 1237 - type EditIssueCommentParams struct { 1238 - LoggedInUser *oauth.MultiAccountUser 1239 - RepoInfo repoinfo.RepoInfo 1240 - Issue *models.Issue 1241 - Comment *models.Comment 1242 - } 1243 - 1244 - func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 1245 - return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 1246 - } 1247 - 1248 - type ReplyIssueCommentPlaceholderParams struct { 1249 - LoggedInUser *oauth.MultiAccountUser 1250 - RepoInfo repoinfo.RepoInfo 1251 - Issue *models.Issue 1252 - Comment *models.Comment 1253 - } 1254 - 1255 - func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 1256 - return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 1257 - } 1258 - 1259 - type ReplyIssueCommentParams struct { 1260 - LoggedInUser *oauth.MultiAccountUser 1261 - RepoInfo repoinfo.RepoInfo 1262 - Issue *models.Issue 1263 - Comment *models.Comment 1264 - } 1265 - 1266 - func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 1267 - return p.executePlain("repo/issues/fragments/replyComment", w, params) 1268 - } 1269 - 1270 - type IssueCommentBodyParams struct { 1271 - LoggedInUser *oauth.MultiAccountUser 1272 - RepoInfo repoinfo.RepoInfo 1273 - Issue *models.Issue 1274 - Comment *models.Comment 1275 - } 1276 - 1277 - func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 1278 - return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 1279 - } 1280 - 1281 1237 type StackedDiff struct { 1282 1238 Diff *types.NiceDiff 1283 1239 Opts types.DiffOpts ··· 1676 1632 1677 1633 func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1678 1634 return p.execute("timeline/home", w, params) 1635 + } 1636 + 1637 + type CommentBodyFragmentParams struct { 1638 + Comment models.Comment 1639 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1640 + UserReacted map[models.ReactionKind]bool 1641 + } 1642 + 1643 + func (p *Pages) CommentBodyFragment(w io.Writer, params CommentBodyFragmentParams) error { 1644 + return p.executePlain("fragments/comment/commentBody", w, params) 1645 + } 1646 + 1647 + type EditCommentFragmentParams struct { 1648 + Comment models.Comment 1649 + } 1650 + 1651 + func (p *Pages) EditCommentFragment(w io.Writer, params EditCommentFragmentParams) error { 1652 + return p.executePlain("fragments/comment/edit", w, params) 1653 + } 1654 + 1655 + type ReplyCommentFragmentParams struct { 1656 + LoggedInUser *oauth.MultiAccountUser 1657 + } 1658 + 1659 + func (p *Pages) ReplyCommentFragment(w io.Writer, params ReplyCommentFragmentParams) error { 1660 + return p.executePlain("fragments/comment/reply", w, params) 1661 + } 1662 + 1663 + type ReplyPlaceholderFragmentParams struct { 1664 + LoggedInUser *oauth.MultiAccountUser 1665 + } 1666 + 1667 + func (p *Pages) ReplyPlaceholderFragment(w io.Writer, params ReplyPlaceholderFragmentParams) error { 1668 + return p.executePlain("fragments/comment/replyPlaceholder", w, params) 1679 1669 } 1680 1670 1681 1671 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 }}
+26 -20
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 - "LoggedInUser" $root.LoggedInUser 16 - "Issue" $root.Issue 17 - "Comment" $comment.Self 18 - "VouchRelationship" (index $root.VouchRelationships $comment.Self.Did) 19 - ) }} 14 + "LoggedInUser" $root.LoggedInUser 15 + "VouchRelationship" (index $root.VouchRelationships $item.Self.Did) 16 + "Comment" $item.Self) }} 20 17 21 18 <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"> 22 19 {{ template "topLevelComment" $params }} 23 20 24 21 <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 25 - {{ range $index, $reply := $comment.Replies }} 22 + {{ range $index, $reply := $item.Replies }} 26 23 <div class="-ml-4"> 27 24 {{ 28 25 template "replyComment" 29 26 (dict 30 - "RepoInfo" $root.RepoInfo 31 - "LoggedInUser" $root.LoggedInUser 32 - "Issue" $root.Issue 33 - "Comment" $reply 27 + "LoggedInUser" $root.LoggedInUser 34 28 "VouchRelationship" (index $root.VouchRelationships $reply.Did) 35 - ) }} 29 + "Comment" $reply) }} 36 30 </div> 37 31 {{ end }} 38 32 </div> 39 33 40 - {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} 34 + <div hx-include="this"> 35 + <input name="subject-uri" type="hidden" value="{{ $item.Self.Subject.Uri }}"> 36 + <input name="subject-cid" type="hidden" value="{{ $item.Self.Subject.Cid }}"> 37 + <input name="reply-to-uri" type="hidden" value="{{ $item.Self.AtUri }}"> 38 + <input name="reply-to-cid" type="hidden" value="{{ $item.Self.Cid }}"> 39 + {{ if $item.Self.IsLegacy }} 40 + <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 41 + <span class="text-orange-500">Can't reply to legacy comment.</span> 42 + </div> 43 + {{ else }} 44 + {{ template "fragments/comment/replyPlaceholder" (dict "LoggedInUser" $root.LoggedInUser) }} 45 + {{ end }} 46 + </div> 41 47 </div> 42 48 {{ end }} 43 49 ··· 47 53 {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1" .VouchRelationship) }} 48 54 </div> 49 55 <div class="flex-1 min-w-0"> 50 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 51 - {{ template "repo/issues/fragments/issueCommentBody" . }} 56 + {{ template "fragments/comment/commentHeader" . }} 57 + {{ template "fragments/comment/commentBody" . }} 52 58 </div> 53 59 </div> 54 60 {{ end }} ··· 59 65 {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8 mr-1" .VouchRelationship) }} 60 66 </div> 61 67 <div class="flex-1 min-w-0"> 62 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 63 - {{ template "repo/issues/fragments/issueCommentBody" . }} 68 + {{ template "fragments/comment/commentHeader" . }} 69 + {{ template "fragments/comment/commentBody" . }} 64 70 </div> 65 71 </div> 66 72 {{ 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 -
+22 -2
appview/pages/templates/repo/pulls/pull.html
··· 592 592 </summary> 593 593 <div> 594 594 {{ range $item.Comments }} 595 - {{ template "submissionComment" (list . $root) }} 595 + {{/* template "submissionComment" . */}} 596 + {{ template "comment" 597 + (dict "LoggedInUser" $root.LoggedInUser 598 + "VouchRelationship" (index $root.VouchRelationships .Did) 599 + "Comment" .) }} 596 600 {{ end }} 597 601 </div> 598 602 {{ if gt $c 0}} ··· 607 611 {{ block "resubmitStatus" $root }} {{ end }} 608 612 {{ end }} 609 613 </div> 610 - <div class="relative -ml-10 bg-gray-50 dark:bg-gray-900"> 614 + <div hx-include="this" class="relative -ml-10 bg-gray-50 dark:bg-gray-900"> 611 615 {{ if $root.LoggedInUser }} 616 + <input name="subject-uri" type="hidden" value="{{ $root.Pull.AtUri }}"> 617 + <input name="pull-round-idx" type="hidden" value="{{ $item.RoundNumber }}"> 612 618 {{ template "repo/pulls/fragments/pullActions" 613 619 (dict 614 620 "LoggedInUser" $root.LoggedInUser ··· 622 628 {{ end }} 623 629 </div> 624 630 </details> 631 + {{ end }} 632 + 633 + {{ define "comment" }} 634 + <div class="flex gap-2 -ml-4 py-4 w-full mx-auto"> 635 + <!-- left column: profile picture --> 636 + <div class="flex-shrink-0 h-fit relative"> 637 + {{ template "user/fragments/picLink" (list .Comment.Did.String "size-8") }} 638 + </div> 639 + <!-- right column: name and body in two rows --> 640 + <div class="flex-1 min-w-0"> 641 + {{ template "fragments/comment/commentHeader" . }} 642 + {{ template "fragments/comment/commentBody" . }} 643 + </div> 644 + </div> 625 645 {{ end }} 626 646 627 647 {{ define "submissionComment" }}
-134
appview/pulls/comment.go
··· 1 1 package pulls 2 2 3 3 import ( 4 - "fmt" 5 4 "net/http" 6 5 "strconv" 7 - "time" 8 6 9 - "tangled.org/core/api/tangled" 10 - "tangled.org/core/appview/db" 11 7 "tangled.org/core/appview/models" 12 8 "tangled.org/core/appview/pages" 13 - "tangled.org/core/appview/reporesolver" 14 - "tangled.org/core/tid" 15 9 16 - comatproto "github.com/bluesky-social/indigo/api/atproto" 17 - "github.com/bluesky-social/indigo/atproto/syntax" 18 - lexutil "github.com/bluesky-social/indigo/lex/util" 19 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 20 10 "github.com/go-chi/chi/v5" 21 11 ) 22 12 ··· 26 16 user := s.oauth.GetMultiAccountUser(r) 27 17 if user != nil { 28 18 l = l.With("user", user.Did) 29 - } 30 - 31 - f, err := s.repoResolver.Resolve(r) 32 - if err != nil { 33 - l.Error("failed to get repo and knot", "err", err) 34 - return 35 19 } 36 20 37 21 pull, ok := r.Context().Value("pull").(*models.Pull) ··· 58 42 Pull: pull, 59 43 RoundNumber: roundNumber, 60 44 }) 61 - return 62 - case http.MethodPost: 63 - body := r.FormValue("body") 64 - if body == "" { 65 - s.pages.Notice(w, "pull-comment", "Comment body is required") 66 - return 67 - } 68 - 69 - // TODO(boltless): normalize markdown body 70 - normalizedBody := body 71 - mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 72 - 73 - markdownBody := tangled.MarkupMarkdown{ 74 - Text: normalizedBody, 75 - Original: &body, 76 - Blobs: nil, 77 - } 78 - 79 - // ingest CID of PR record on-demand. 80 - // TODO(boltless): appview should ingest CID of atproto records 81 - cid, err := func() (syntax.CID, error) { 82 - ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 83 - if err != nil { 84 - return "", err 85 - } 86 - 87 - xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 88 - out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoPullNSID, pull.OwnerDid, pull.Rkey) 89 - if err != nil { 90 - return "", err 91 - } 92 - if out.Cid == nil { 93 - return "", fmt.Errorf("record CID is empty") 94 - } 95 - 96 - cid, err := syntax.ParseCID(*out.Cid) 97 - if err != nil { 98 - return "", err 99 - } 100 - 101 - return cid, nil 102 - }() 103 - if err != nil { 104 - s.logger.Error("failed to backfill subject PR record", "err", err) 105 - s.pages.Notice(w, "pull-comment", "failed to backfill subject record") 106 - return 107 - } 108 - pullStrongRef := comatproto.RepoStrongRef{ 109 - Uri: pull.AtUri().String(), 110 - Cid: cid.String(), 111 - } 112 - 113 - comment := models.Comment{ 114 - Did: syntax.DID(user.Did), 115 - Collection: tangled.FeedCommentNSID, 116 - Rkey: syntax.RecordKey(tid.TID()), 117 - 118 - Subject: pullStrongRef, 119 - Body: markdownBody, 120 - Created: time.Now(), 121 - ReplyTo: nil, 122 - PullRoundIdx: &roundNumber, 123 - } 124 - if err = comment.Validate(); err != nil { 125 - s.logger.Error("failed to validate comment", "err", err) 126 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 127 - return 128 - } 129 - 130 - client, err := s.oauth.AuthorizedClient(r) 131 - if err != nil { 132 - s.logger.Error("failed to get authorized client", "err", err) 133 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 134 - return 135 - } 136 - 137 - out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 138 - Collection: comment.Collection.String(), 139 - Repo: comment.Did.String(), 140 - Rkey: comment.Rkey.String(), 141 - Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 142 - }) 143 - if err != nil { 144 - s.logger.Error("failed to create pull comment", "err", err) 145 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 146 - return 147 - } 148 - 149 - comment.Cid = syntax.CID(out.Cid) 150 - 151 - // Start a transaction 152 - tx, err := s.db.BeginTx(r.Context(), nil) 153 - if err != nil { 154 - l.Error("failed to start transaction", "err", err) 155 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 156 - return 157 - } 158 - defer tx.Rollback() 159 - 160 - // Create the pull comment in the database 161 - err = db.PutComment(tx, &comment, references) 162 - if err != nil { 163 - l.Error("failed to create pull comment in database", "err", err) 164 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 165 - return 166 - } 167 - 168 - // Commit the transaction 169 - if err = tx.Commit(); err != nil { 170 - l.Error("failed to commit transaction", "err", err) 171 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 172 - return 173 - } 174 - 175 - s.notifier.NewComment(r.Context(), &comment, mentions) 176 - 177 - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 178 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) 179 45 return 180 46 } 181 47 }
+1 -4
appview/pulls/router.go
··· 27 27 r.Get("/", s.RepoPullPatch) 28 28 r.Get("/interdiff", s.RepoPullInterdiff) 29 29 r.Get("/actions", s.PullActions) 30 - r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) { 31 - r.Get("/", s.PullComment) 32 - r.Post("/", s.PullComment) 33 - }) 30 + r.Get("/comment", s.PullComment) 34 31 }) 35 32 36 33 r.Route("/round/{round}.patch", func(r chi.Router) {
+422
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 + user := s.oauth.GetMultiAccountUser(r) 25 + 26 + commentAt := r.URL.Query().Get("aturi") 27 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 28 + if err != nil { 29 + l.Error("failed to fetch comment", "aturi", commentAt) 30 + http.Error(w, "Failed to fetch comment", http.StatusInternalServerError) 31 + return 32 + } 33 + 34 + reactions, err := db.GetReactionMap(s.db, 20, comment.AtUri()) 35 + if err != nil { 36 + l.Error("failed to get reactions", "err", err) 37 + } 38 + var userReactions map[models.ReactionKind]bool 39 + if user != nil { 40 + userReactions, err = db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.AtUri()) 41 + if err != nil { 42 + l.Error("failed to get user reactions", "err", err) 43 + } 44 + } 45 + 46 + err = s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 47 + Comment: comment, 48 + Reactions: reactions, 49 + UserReacted: userReactions, 50 + }) 51 + if err != nil { 52 + l.Error("failed to render") 53 + } 54 + } 55 + 56 + func (s *State) EditCommentFragment(w http.ResponseWriter, r *http.Request) { 57 + l := s.logger.With("handler", "EditCommentFragment") 58 + 59 + commentAt := r.URL.Query().Get("aturi") 60 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 61 + if err != nil { 62 + l.Error("failed to fetch comment", "aturi", commentAt) 63 + http.Error(w, "Failed to fetch comment", http.StatusInternalServerError) 64 + return 65 + } 66 + 67 + err = s.pages.EditCommentFragment(w, pages.EditCommentFragmentParams{ 68 + Comment: comment, 69 + }) 70 + if err != nil { 71 + l.Error("failed to render") 72 + } 73 + } 74 + 75 + func (s *State) NewReplyCommentFragment(w http.ResponseWriter, r *http.Request) { 76 + s.pages.ReplyCommentFragment(w, pages.ReplyCommentFragmentParams{ 77 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 78 + }) 79 + } 80 + 81 + func (s *State) ReplyPlaceholderFragment(w http.ResponseWriter, r *http.Request) { 82 + s.pages.ReplyPlaceholderFragment(w, pages.ReplyPlaceholderFragmentParams{ 83 + LoggedInUser: s.oauth.GetMultiAccountUser(r), 84 + }) 85 + } 86 + 87 + func (s *State) NewComment(w http.ResponseWriter, r *http.Request) { 88 + l := s.logger.With("handler", "NewComment") 89 + user := s.oauth.GetMultiAccountUser(r) 90 + 91 + noticeId := "comment-error" 92 + ctx := r.Context() 93 + 94 + body := r.FormValue("body") 95 + if body == "" { 96 + s.pages.Notice(w, noticeId, "Body is required") 97 + return 98 + } 99 + 100 + // TODO(boltless): normalize markdown body 101 + normalizedBody := body 102 + _, references := s.mentionsResolver.Resolve(ctx, body) 103 + 104 + markdownBody := tangled.MarkupMarkdown{ 105 + Text: normalizedBody, 106 + Original: &body, 107 + Blobs: nil, 108 + } 109 + 110 + subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri")) 111 + if err != nil { 112 + l.Warn("invalid subject uri", "err", err) 113 + s.pages.Notice(w, noticeId, "Subject URI should be valid AT-URI") 114 + return 115 + } 116 + l = l.With("subject.uri", subjectUri) 117 + 118 + // ingest CID of subject record on-demand. 119 + // TODO(boltless): appview should ingest CID of all atproto records 120 + var subjectCid syntax.CID 121 + if subjectCidRaw := r.FormValue("subject-cid"); subjectCidRaw != "" { 122 + subjectCid, err = syntax.ParseCID(subjectCidRaw) 123 + if err != nil { 124 + l.Warn("invalid subject cid", "err", err) 125 + s.pages.Notice(w, noticeId, "Subject CID should be valid CID") 126 + return 127 + } 128 + } else { 129 + l.Debug("fetching subject record CID") 130 + subjectCid, err = func(uri syntax.ATURI) (syntax.CID, error) { 131 + ident, err := s.idResolver.ResolveIdent(ctx, uri.Authority().String()) 132 + if err != nil { 133 + return "", err 134 + } 135 + 136 + xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 137 + out, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", uri.Collection().String(), ident.DID.String(), uri.RecordKey().String()) 138 + if err != nil { 139 + return "", err 140 + } 141 + if out.Cid == nil { 142 + return "", fmt.Errorf("record CID is empty") 143 + } 144 + 145 + cid, err := syntax.ParseCID(*out.Cid) 146 + if err != nil { 147 + return "", err 148 + } 149 + 150 + return cid, nil 151 + }(subjectUri) 152 + if err != nil { 153 + l.Error("failed to backfill subject record", "err", err) 154 + s.pages.Notice(w, noticeId, "failed to backfill subject record") 155 + return 156 + } 157 + } 158 + l = l.With("subject.cid", subjectCid) 159 + 160 + subject := comatproto.RepoStrongRef{ 161 + Uri: subjectUri.String(), 162 + Cid: subjectCid.String(), 163 + } 164 + 165 + var pullRoundIdx *int 166 + if pullRoundIdxRaw := r.FormValue("pull-round-idx"); pullRoundIdxRaw != "" { 167 + roundIdx, err := strconv.Atoi(pullRoundIdxRaw) 168 + if err != nil { 169 + l.Warn("invalid round idx", "err", err) 170 + s.pages.Notice(w, noticeId, "pull round index should be valid integer") 171 + return 172 + } 173 + pullRoundIdx = &roundIdx 174 + } 175 + 176 + var replyTo *comatproto.RepoStrongRef 177 + replyToUriRaw := r.FormValue("reply-to-uri") 178 + replyToCidRaw := r.FormValue("reply-to-cid") 179 + if replyToUriRaw != "" && replyToCidRaw != "" { 180 + uri, err := syntax.ParseATURI(replyToUriRaw) 181 + if err != nil { 182 + s.pages.Notice(w, noticeId, "reply-to-uri should be valid AT-URI") 183 + return 184 + } 185 + cid, err := syntax.ParseCID(replyToCidRaw) 186 + if err != nil { 187 + s.pages.Notice(w, noticeId, "reply-to-cid should be valid CID") 188 + return 189 + } 190 + replyTo = &comatproto.RepoStrongRef{ 191 + Uri: uri.String(), 192 + Cid: cid.String(), 193 + } 194 + } 195 + 196 + comment := models.Comment{ 197 + Did: syntax.DID(user.Did), 198 + Collection: tangled.FeedCommentNSID, 199 + Rkey: syntax.RecordKey(tid.TID()), 200 + 201 + Subject: subject, 202 + Body: markdownBody, 203 + Created: time.Now(), 204 + ReplyTo: replyTo, 205 + PullRoundIdx: pullRoundIdx, 206 + } 207 + if err = comment.Validate(); err != nil { 208 + l.Error("failed to validate comment", "err", err) 209 + s.pages.Notice(w, noticeId, "Failed to create comment.") 210 + return 211 + } 212 + 213 + client, err := s.oauth.AuthorizedClient(r) 214 + if err != nil { 215 + l.Error("failed to get authorized client", "err", err) 216 + s.pages.Notice(w, noticeId, "Failed to create comment.") 217 + return 218 + } 219 + 220 + // create a record first 221 + out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 222 + Collection: comment.Collection.String(), 223 + Repo: comment.Did.String(), 224 + Rkey: comment.Rkey.String(), 225 + Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 226 + }) 227 + if err != nil { 228 + l.Error("failed to create comment", "err", err) 229 + s.pages.Notice(w, noticeId, "Failed to create comment.") 230 + return 231 + } 232 + 233 + comment.Cid = syntax.CID(out.Cid) 234 + 235 + tx, err := s.db.Begin() 236 + if err != nil { 237 + l.Error("failed to start transaction", "err", err) 238 + s.pages.Notice(w, noticeId, "Failed to create comment, try again later.") 239 + return 240 + } 241 + defer tx.Rollback() 242 + 243 + err = db.PutComment(tx, &comment, references) 244 + if err != nil { 245 + l.Error("failed to create comment", "err", err) 246 + s.pages.Notice(w, noticeId, "Failed to create comment.") 247 + return 248 + } 249 + 250 + err = tx.Commit() 251 + if err != nil { 252 + l.Error("failed to commit transaction", "err", err) 253 + s.pages.Notice(w, noticeId, "Failed to create comment, try again later.") 254 + return 255 + } 256 + 257 + // TODO: return comment or reply-comment fragment 258 + // onattach, htmx-callback to focus on comment. 259 + s.pages.HxRefresh(w) 260 + } 261 + 262 + func (s *State) EditComment(w http.ResponseWriter, r *http.Request) { 263 + l := s.logger.With("handler", "EditComment") 264 + user := s.oauth.GetMultiAccountUser(r) 265 + 266 + noticeId := "comment-error" 267 + ctx := r.Context() 268 + 269 + commentAt := r.FormValue("aturi") 270 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 271 + if err != nil { 272 + l.Error("failed to fetch comment", "aturi", commentAt, "err", err) 273 + s.pages.Notice(w, noticeId, "Failed to fetch comment") 274 + return 275 + } 276 + 277 + if comment.Did.String() != user.Did { 278 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 279 + s.pages.Notice(w, noticeId, "You are not the author of this comment") 280 + return 281 + } 282 + 283 + body := r.FormValue("body") 284 + if body == "" { 285 + s.pages.Notice(w, noticeId, "Body is required") 286 + return 287 + } 288 + 289 + // TODO(boltless): normalize markdown body 290 + normalizedBody := body 291 + _, references := s.mentionsResolver.Resolve(ctx, body) 292 + 293 + now := time.Now() 294 + newComment := comment 295 + newComment.Body = tangled.MarkupMarkdown{ 296 + Text: normalizedBody, 297 + Original: &body, 298 + Blobs: nil, 299 + } 300 + newComment.Edited = &now 301 + if err := newComment.Validate(); err != nil { 302 + l.Error("failed to validate comment", "err", err) 303 + s.pages.Notice(w, noticeId, "Failed to update comment.") 304 + return 305 + } 306 + 307 + client, err := s.oauth.AuthorizedClient(r) 308 + if err != nil { 309 + l.Error("failed to get authorized client", "err", err) 310 + s.pages.Notice(w, noticeId, "Failed to create comment. try again later.") 311 + return 312 + } 313 + 314 + // update the record first 315 + exCid := comment.Cid.String() 316 + out, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 317 + Collection: newComment.Collection.String(), 318 + Repo: newComment.Did.String(), 319 + Rkey: newComment.Rkey.String(), 320 + SwapRecord: &exCid, 321 + Record: &lexutil.LexiconTypeDecoder{ 322 + Val: newComment.AsRecord(), 323 + }, 324 + }) 325 + if err != nil { 326 + l.Error("failed to update comment", "err", err) 327 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 328 + return 329 + } 330 + 331 + newComment.Cid = syntax.CID(out.Cid) 332 + 333 + tx, err := s.db.Begin() 334 + if err != nil { 335 + l.Error("failed to start transaction", "err", err) 336 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 337 + return 338 + } 339 + defer tx.Rollback() 340 + 341 + err = db.PutComment(tx, &newComment, references) 342 + if err != nil { 343 + l.Error("failed to perform update-description query", "err", err) 344 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 345 + return 346 + } 347 + err = tx.Commit() 348 + if err != nil { 349 + l.Error("failed to commit transaction", "err", err) 350 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 351 + return 352 + } 353 + 354 + reactions, err := db.GetReactionMap(s.db, 20, comment.AtUri()) 355 + if err != nil { 356 + l.Error("failed to get reactions", "err", err) 357 + } 358 + userReactions, err := db.GetReactionStatusMap(s.db, syntax.DID(user.Did), comment.AtUri()) 359 + if err != nil { 360 + l.Error("failed to get user reactions", "err", err) 361 + } 362 + 363 + // TODO: return full comment fragment so we can update comment header too 364 + s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 365 + Comment: newComment, 366 + Reactions: reactions, 367 + UserReacted: userReactions, 368 + }) 369 + } 370 + 371 + func (s *State) DeleteComment(w http.ResponseWriter, r *http.Request) { 372 + l := s.logger.With("handler", "DeleteComment") 373 + user := s.oauth.GetMultiAccountUser(r) 374 + 375 + noticeId := "comment" 376 + ctx := r.Context() 377 + 378 + commentAt := r.URL.Query().Get("aturi") 379 + comment, err := db.GetComment(s.db, orm.FilterEq("at_uri", commentAt)) 380 + if err != nil { 381 + l.Error("failed to fetch comment", "aturi", commentAt) 382 + s.pages.Notice(w, noticeId, "Failed to fetch comment.") 383 + return 384 + } 385 + 386 + if comment.Did.String() != user.Did { 387 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 388 + s.pages.Notice(w, noticeId, "you are not the author of this comment") 389 + return 390 + } 391 + 392 + if comment.Deleted != nil { 393 + s.pages.Notice(w, noticeId, "Comment already deleted") 394 + return 395 + } 396 + 397 + client, err := s.oauth.AuthorizedClient(r) 398 + if err != nil { 399 + l.Error("failed to get authorized client", "err", err) 400 + s.pages.Notice(w, "comment", "Failed to delete comment.") 401 + return 402 + } 403 + _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 404 + Collection: comment.Collection.String(), 405 + Repo: comment.Did.String(), 406 + Rkey: comment.Rkey.String(), 407 + }) 408 + if err != nil { 409 + l.Error("failed to delete from PDS", "err", err) 410 + s.pages.Notice(w, noticeId, "Failed to delete comment, try again later.") 411 + return 412 + } 413 + 414 + // optimistic update for htmx response 415 + now := time.Now() 416 + comment.Body = tangled.MarkupMarkdown{} 417 + comment.Deleted = &now 418 + 419 + s.pages.CommentBodyFragment(w, pages.CommentBodyFragmentParams{ 420 + Comment: comment, 421 + }) 422 + }
+10
appview/state/router.go
··· 195 195 r.Delete("/", s.React) 196 196 }) 197 197 198 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) { 199 + r.Get("/", s.CommentBodyFragment) 200 + r.Get("/edit", s.EditCommentFragment) 201 + r.Get("/reply", s.NewReplyCommentFragment) 202 + r.Get("/reply/placeholder", s.ReplyPlaceholderFragment) 203 + r.Post("/", s.NewComment) 204 + r.Patch("/", s.EditComment) 205 + r.Delete("/", s.DeleteComment) 206 + }) 207 + 198 208 r.Get("/profile/popover", s.ProfilePopover) 199 209 200 210 r.Route("/profile", func(r chi.Router) {
-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.Did) 438 435 } 439 - 440 - func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 441 - }