Yōten: A social tracker for your language learning journey built on the atproto.

feat: add study session comments #1

merged opened by brookjeynes.dev targeting master from bj/2025-09-05/feat/study-session-comments
Labels

None yet.

Participants 1
AT URI
at://did:plc:4mj54vc4ha3lh32ksxwunnbh/sh.tangled.repo.pull/3lza2rhz7il22
+1852 -202
Diff #2
+16 -4
internal/server/handlers/router.go
··· 83 83 84 84 85 85 86 + r.Delete("/{rkey}", h.HandleDeleteResource) 87 + }) 86 88 89 + r.Route("/comment", func(r chi.Router) { 90 + r.Use(middleware.AuthMiddleware(h.Oauth)) 91 + r.Post("/new", h.HandleNewComment) 92 + r.Get("/edit/{rkey}", h.HandleEditCommentPage) 93 + r.Post("/edit/{rkey}", h.HandleEditCommentPage) 94 + r.Delete("/{rkey}", h.HandleDeleteComment) 95 + }) 87 96 97 + r.Route("/activity", func(r chi.Router) { 98 + r.Use(middleware.AuthMiddleware(h.Oauth)) 99 + r.Get("/new", h.HandleNewActivityPage) 88 100 89 101 90 102 ··· 122 134 123 135 124 136 125 - 126 - 127 - 128 - 129 137 r.Route("/{user}", func(r chi.Router) { 130 138 r.Get("/", h.HandleProfilePage) 131 139 r.Get("/feed", h.HandleProfileFeed) 140 + r.Route("/session/{rkey}", func(r chi.Router) { 141 + r.Get("/", h.HandleStudySessionPage) 142 + r.Get("/feed", h.HandleStudySessionPageCommentFeed) 143 + }) 132 144 }) 133 145 }) 134 146
+123
internal/server/handlers/study-session.go
··· 9 9 "time" 10 10 11 11 comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/identity" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 12 14 lexutil "github.com/bluesky-social/indigo/lex/util" 13 15 "github.com/go-chi/chi/v5" 14 16 "github.com/posthog/posthog-go" ··· 629 631 630 632 631 633 return nil 634 + } 635 + 636 + func (h *Handler) HandleStudySessionPage(w http.ResponseWriter, r *http.Request) { 637 + user, _ := bsky.GetUserWithBskyProfile(h.Oauth, r) 638 + didOrHandle := chi.URLParam(r, "user") 639 + if didOrHandle == "" { 640 + http.Error(w, "Bad request", http.StatusBadRequest) 641 + return 642 + } 643 + 644 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 645 + if !ok { 646 + w.WriteHeader(http.StatusNotFound) 647 + views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w) 648 + return 649 + } 650 + rkey := chi.URLParam(r, "rkey") 651 + 652 + studySession, err := db.GetStudySessionByRkey(h.Db, ident.DID.String(), rkey) 653 + if err != nil { 654 + log.Println("failed to retrieve study session:", err) 655 + htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve study session, try again later.") 656 + return 657 + } 658 + 659 + bskyProfile, err := bsky.GetBskyProfile(ident.DID.String()) 660 + if err != nil { 661 + log.Println("failed to retrieve bsky profile for study session:", err) 662 + htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve bsky profile, try again later.") 663 + return 664 + } 665 + 666 + profile, err := db.GetProfile(h.Db, ident.DID.String()) 667 + if err != nil { 668 + log.Println("failed to retrieve profile for study session:", err) 669 + htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve profile, try again later.") 670 + return 671 + } 672 + 673 + isSelf := false 674 + if user != nil { 675 + isSelf = user.Did == studySession.Did 676 + } 677 + 678 + views.StudySessionPage(views.StudySessionPageParams{ 679 + User: user, 680 + DoesOwn: isSelf, 681 + StudySession: db.StudySessionFeedItem{ 682 + StudySession: *studySession, 683 + ProfileDisplayName: profile.DisplayName, 684 + ProfileLevel: profile.Level, 685 + BskyProfile: bskyProfile, 686 + }, 687 + }).Render(r.Context(), w) 688 + } 689 + 690 + func (h *Handler) HandleStudySessionPageCommentFeed(w http.ResponseWriter, r *http.Request) { 691 + user, _ := bsky.GetUserWithBskyProfile(h.Oauth, r) 692 + 693 + didOrHandle := chi.URLParam(r, "user") 694 + if didOrHandle == "" { 695 + http.Error(w, "Bad request", http.StatusBadRequest) 696 + return 697 + } 698 + 699 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 700 + if !ok { 701 + w.WriteHeader(http.StatusNotFound) 702 + views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w) 703 + return 704 + } 705 + rkey := chi.URLParam(r, "rkey") 706 + studySessionUri := syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", ident.DID.String(), yoten.FeedSessionNSID, rkey)) 707 + 708 + pageStr := r.URL.Query().Get("page") 709 + if pageStr == "" { 710 + pageStr = "1" 711 + } 712 + page, err := strconv.ParseInt(pageStr, 10, 64) 713 + if err != nil { 714 + log.Println("failed to parse page value:", err) 715 + page = 1 716 + } 717 + if page == 0 { 718 + page = 1 719 + } 720 + 721 + const pageSize = 2 722 + offset := (page - 1) * pageSize 723 + 724 + commentFeed, err := db.GetCommentsForSession(h.Db, studySessionUri.String(), pageSize+1, int(offset)) 725 + if err != nil { 726 + log.Println("failed to get comment feed:", err) 727 + htmx.HxError(w, http.StatusInternalServerError, "Failed to get comment feed, try again later.") 728 + return 729 + } 730 + 731 + commentFeed = utils.Filter(commentFeed, func(cwlp db.CommentWithLocalProfile) bool { 732 + return !cwlp.IsDeleted 733 + }) 734 + 735 + nextPage := 0 736 + if len(commentFeed) > pageSize { 737 + nextPage = int(page + 1) 738 + commentFeed = commentFeed[:pageSize] 739 + } 740 + 741 + populatedCommentFeed, err := h.BuildCommentFeed(commentFeed) 742 + if err != nil { 743 + log.Println("failed to populate comment feed:", err) 744 + htmx.HxError(w, http.StatusInternalServerError, "Failed to get comment feed, try again later.") 745 + return 746 + } 747 + 748 + partials.CommentFeed(partials.CommentFeedProps{ 749 + Feed: populatedCommentFeed, 750 + NextPage: nextPage, 751 + User: user, 752 + StudySessionDid: ident.DID.String(), 753 + StudySessionRkey: rkey, 754 + }).Render(r.Context(), w) 632 755 }
+52
internal/server/views/partials/discussion.templ
··· 1 + package partials 2 + 3 + import "fmt" 4 + 5 + templ Discussion(params DiscussionProps) { 6 + <div class="card"> 7 + <div class="flex items-center gap-2"> 8 + <i class="w-5 h-5" data-lucide="message-square"></i> 9 + <h1 class="text-2xl font-bold">Discussion</h1> 10 + </div> 11 + <form 12 + hx-post="/comment/new" 13 + hx-swap="afterbegin" 14 + hx-target="#comment-feed" 15 + hx-disabled-elt="#post-comment-button" 16 + @htmx:after-request="text = ''" 17 + x-data="{ text: '' }" 18 + > 19 + <input type="hidden" name="study_session_uri" value={ params.StudySessionUri }/> 20 + <div class="mt-2"> 21 + <textarea 22 + x-model="text" 23 + id="comment" 24 + name="comment" 25 + placeholder="Share your thoughts about this study session..." 26 + class="input w-full" 27 + maxLength="256" 28 + rows="3" 29 + ></textarea> 30 + <div class="flex justify-between mt-2"> 31 + <div class="text-sm text-text-muted"> 32 + <span x-text="text.length"></span> / 256 33 + </div> 34 + <button type="submit" id="post-comment-button" class="btn btn-primary w-fit"> 35 + Post Comment 36 + </button> 37 + </div> 38 + </div> 39 + </form> 40 + <div 41 + id="comment-feed" 42 + hx-trigger="load" 43 + hx-swap="innerHTML" 44 + hx-get={ templ.SafeURL(fmt.Sprintf("/%s/session/%s/feed", params.StudySessionDid, params.StudySessionRkey)) } 45 + class="flex flex-col gap-4 mt-2" 46 + > 47 + <div class="flex justify-center py-4"> 48 + <i data-lucide="loader-circle" class="w-6 h-6 animate-spin text-text-muted"></i> 49 + </div> 50 + </div> 51 + </div> 52 + }
+24
internal/server/views/partials/partials.go
··· 218 218 Feed []db.NotificationWithBskyHandle 219 219 NextPage int 220 220 } 221 + 222 + type DiscussionProps struct { 223 + StudySessionUri string 224 + StudySessionDid string 225 + StudySessionRkey string 226 + } 227 + 228 + type CommentProps struct { 229 + Comment db.CommentFeedItem 230 + DoesOwn bool 231 + } 232 + 233 + type EditCommentProps struct { 234 + Comment db.Comment 235 + } 236 + 237 + type CommentFeedProps struct { 238 + // The current logged in user 239 + User *types.User 240 + Feed []db.CommentFeedItem 241 + NextPage int 242 + StudySessionDid string 243 + StudySessionRkey string 244 + }
+19 -13
internal/server/views/partials/study-session.templ
··· 52 52 53 53 54 54 55 + class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group" 56 + type="button" 57 + id="delete-button" 58 + hx-disabled-elt="#delete-button,#edit-button" 59 + hx-delete={ templ.URL(fmt.Sprintf("/session/%s", params.StudySession.Rkey)) } 60 + > 61 + <i class="w-4 h-4" data-lucide="trash-2"></i> 55 62 56 63 57 64 ··· 60 67 61 68 62 69 63 - 64 - 65 - 66 - 67 - 68 - 69 - 70 70 templ StudySession(params StudySessionProps) { 71 71 {{ elementId := SanitiseHtmlId(fmt.Sprintf("study-session-%s-%s", params.StudySession.Did, params.StudySession.Rkey)) }} 72 + {{ studySessionUrl := templ.SafeURL("/" + params.StudySession.Did + "/session/" + params.StudySession.Rkey) }} 72 73 <div id={ elementId } class="card relative" x-data="{ open: false }" :class="{ 'z-20': open }"> 73 74 <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> 74 75 <div class="flex items-center justify-between"> ··· 148 149 } 149 150 <hr class="border-gray"/> 150 151 <div class="flex flex-col sm:flex-row justify-between sm:items-center gap-4"> 151 - @NewReactions(NewReactionsProps{ 152 - User: params.User, 153 - SessionDid: params.StudySession.Did, 154 - SessionRkey: params.StudySession.Rkey, 155 - ReactionEvents: params.StudySession.Reactions, 156 - }) 152 + <div class="flex items-center gap-1"> 153 + @NewReactions(NewReactionsProps{ 154 + User: params.User, 155 + SessionDid: params.StudySession.Did, 156 + SessionRkey: params.StudySession.Rkey, 157 + ReactionEvents: params.StudySession.Reactions, 158 + }) 159 + <a href={ studySessionUrl } title="comments"> 160 + <i class="w-5 h-5" data-lucide="message-square-share"></i> 161 + </a> 162 + </div> 157 163 <div class="flex flex-col sm:flex-row sm:items-center gap-2 text-sm text-text-muted"> 158 164 <div class="flex items-center gap-1"> 159 165 <i class="w-4 h-4" data-lucide="clock"></i>
+24
internal/server/views/study-session.templ
··· 1 + package views 2 + 3 + import ( 4 + "yoten.app/internal/server/views/layouts" 5 + "yoten.app/internal/server/views/partials" 6 + ) 7 + 8 + templ StudySessionPage(params StudySessionPageParams) { 9 + @layouts.Base(layouts.BaseParams{Title: "study session"}) { 10 + @partials.Header(partials.HeaderProps{User: params.User}) 11 + <div class="container mx-auto px-4 py-8 max-w-4xl flex flex-col gap-8"> 12 + @partials.StudySession(partials.StudySessionProps{ 13 + User: params.User, 14 + DoesOwn: params.DoesOwn, 15 + StudySession: params.StudySession, 16 + }) 17 + @partials.Discussion(partials.DiscussionProps{ 18 + StudySessionDid: params.StudySession.Did, 19 + StudySessionRkey: params.StudySession.Rkey, 20 + StudySessionUri: params.StudySession.StudySessionAt().String(), 21 + }) 22 + </div> 23 + } 24 + }
+7 -1
internal/server/views/views.go
··· 123 123 // The current logged in user. 124 124 User *types.User 125 125 Notifications []db.NotificationWithBskyHandle 126 - ActiveTab string 126 + } 127 + 128 + type StudySessionPageParams struct { 129 + // The current logged in user. 130 + User *types.User 131 + StudySession db.StudySessionFeedItem 132 + DoesOwn bool 127 133 }
+376
api/yoten/cbor_gen.go
··· 648 648 649 649 return nil 650 650 } 651 + func (t *FeedComment) MarshalCBOR(w io.Writer) error { 652 + if t == nil { 653 + _, err := w.Write(cbg.CborNull) 654 + return err 655 + } 656 + 657 + cw := cbg.NewCborWriter(w) 658 + fieldCount := 5 659 + 660 + if t.Reply == nil { 661 + fieldCount-- 662 + } 663 + 664 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 665 + return err 666 + } 667 + 668 + // t.Body (string) (string) 669 + if len("body") > 1000000 { 670 + return xerrors.Errorf("Value in field \"body\" was too long") 671 + } 672 + 673 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 674 + return err 675 + } 676 + if _, err := cw.WriteString(string("body")); err != nil { 677 + return err 678 + } 679 + 680 + if len(t.Body) > 1000000 { 681 + return xerrors.Errorf("Value in field t.Body was too long") 682 + } 683 + 684 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil { 685 + return err 686 + } 687 + if _, err := cw.WriteString(string(t.Body)); err != nil { 688 + return err 689 + } 690 + 691 + // t.LexiconTypeID (string) (string) 692 + if len("$type") > 1000000 { 693 + return xerrors.Errorf("Value in field \"$type\" was too long") 694 + } 695 + 696 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 697 + return err 698 + } 699 + if _, err := cw.WriteString(string("$type")); err != nil { 700 + return err 701 + } 702 + 703 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("app.yoten.feed.comment"))); err != nil { 704 + return err 705 + } 706 + if _, err := cw.WriteString(string("app.yoten.feed.comment")); err != nil { 707 + return err 708 + } 709 + 710 + // t.Reply (yoten.FeedComment_Reply) (struct) 711 + if t.Reply != nil { 712 + 713 + if len("reply") > 1000000 { 714 + return xerrors.Errorf("Value in field \"reply\" was too long") 715 + } 716 + 717 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("reply"))); err != nil { 718 + return err 719 + } 720 + if _, err := cw.WriteString(string("reply")); err != nil { 721 + return err 722 + } 723 + 724 + if err := t.Reply.MarshalCBOR(cw); err != nil { 725 + return err 726 + } 727 + } 728 + 729 + // t.Subject (string) (string) 730 + if len("subject") > 1000000 { 731 + return xerrors.Errorf("Value in field \"subject\" was too long") 732 + } 733 + 734 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 735 + return err 736 + } 737 + if _, err := cw.WriteString(string("subject")); err != nil { 738 + return err 739 + } 740 + 741 + if len(t.Subject) > 1000000 { 742 + return xerrors.Errorf("Value in field t.Subject was too long") 743 + } 744 + 745 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 746 + return err 747 + } 748 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 749 + return err 750 + } 751 + 752 + // t.CreatedAt (string) (string) 753 + if len("createdAt") > 1000000 { 754 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 755 + } 756 + 757 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 758 + return err 759 + } 760 + if _, err := cw.WriteString(string("createdAt")); err != nil { 761 + return err 762 + } 763 + 764 + if len(t.CreatedAt) > 1000000 { 765 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 766 + } 767 + 768 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 769 + return err 770 + } 771 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 772 + return err 773 + } 774 + return nil 775 + } 776 + 777 + func (t *FeedComment) UnmarshalCBOR(r io.Reader) (err error) { 778 + *t = FeedComment{} 779 + 780 + cr := cbg.NewCborReader(r) 781 + 782 + maj, extra, err := cr.ReadHeader() 783 + if err != nil { 784 + return err 785 + } 786 + defer func() { 787 + if err == io.EOF { 788 + err = io.ErrUnexpectedEOF 789 + } 790 + }() 791 + 792 + if maj != cbg.MajMap { 793 + return fmt.Errorf("cbor input should be of type map") 794 + } 795 + 796 + if extra > cbg.MaxLength { 797 + return fmt.Errorf("FeedComment: map struct too large (%d)", extra) 798 + } 799 + 800 + n := extra 801 + 802 + nameBuf := make([]byte, 9) 803 + for i := uint64(0); i < n; i++ { 804 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 805 + if err != nil { 806 + return err 807 + } 808 + 809 + if !ok { 810 + // Field doesn't exist on this type, so ignore it 811 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 812 + return err 813 + } 814 + continue 815 + } 816 + 817 + switch string(nameBuf[:nameLen]) { 818 + // t.Body (string) (string) 819 + case "body": 820 + 821 + { 822 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 823 + if err != nil { 824 + return err 825 + } 826 + 827 + t.Body = string(sval) 828 + } 829 + // t.LexiconTypeID (string) (string) 830 + case "$type": 831 + 832 + { 833 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 834 + if err != nil { 835 + return err 836 + } 837 + 838 + t.LexiconTypeID = string(sval) 839 + } 840 + // t.Reply (yoten.FeedComment_Reply) (struct) 841 + case "reply": 842 + 843 + { 844 + 845 + b, err := cr.ReadByte() 846 + if err != nil { 847 + return err 848 + } 849 + if b != cbg.CborNull[0] { 850 + if err := cr.UnreadByte(); err != nil { 851 + return err 852 + } 853 + t.Reply = new(FeedComment_Reply) 854 + if err := t.Reply.UnmarshalCBOR(cr); err != nil { 855 + return xerrors.Errorf("unmarshaling t.Reply pointer: %w", err) 856 + } 857 + } 858 + 859 + } 860 + // t.Subject (string) (string) 861 + case "subject": 862 + 863 + { 864 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 865 + if err != nil { 866 + return err 867 + } 868 + 869 + t.Subject = string(sval) 870 + } 871 + // t.CreatedAt (string) (string) 872 + case "createdAt": 873 + 874 + { 875 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 876 + if err != nil { 877 + return err 878 + } 879 + 880 + t.CreatedAt = string(sval) 881 + } 882 + 883 + default: 884 + // Field doesn't exist on this type, so ignore it 885 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 886 + return err 887 + } 888 + } 889 + } 890 + 891 + return nil 892 + } 893 + func (t *FeedComment_Reply) MarshalCBOR(w io.Writer) error { 894 + if t == nil { 895 + _, err := w.Write(cbg.CborNull) 896 + return err 897 + } 898 + 899 + cw := cbg.NewCborWriter(w) 900 + 901 + if _, err := cw.Write([]byte{162}); err != nil { 902 + return err 903 + } 904 + 905 + // t.Root (string) (string) 906 + if len("root") > 1000000 { 907 + return xerrors.Errorf("Value in field \"root\" was too long") 908 + } 909 + 910 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("root"))); err != nil { 911 + return err 912 + } 913 + if _, err := cw.WriteString(string("root")); err != nil { 914 + return err 915 + } 916 + 917 + if len(t.Root) > 1000000 { 918 + return xerrors.Errorf("Value in field t.Root was too long") 919 + } 920 + 921 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Root))); err != nil { 922 + return err 923 + } 924 + if _, err := cw.WriteString(string(t.Root)); err != nil { 925 + return err 926 + } 927 + 928 + // t.Parent (string) (string) 929 + if len("parent") > 1000000 { 930 + return xerrors.Errorf("Value in field \"parent\" was too long") 931 + } 932 + 933 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("parent"))); err != nil { 934 + return err 935 + } 936 + if _, err := cw.WriteString(string("parent")); err != nil { 937 + return err 938 + } 939 + 940 + if len(t.Parent) > 1000000 { 941 + return xerrors.Errorf("Value in field t.Parent was too long") 942 + } 943 + 944 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Parent))); err != nil { 945 + return err 946 + } 947 + if _, err := cw.WriteString(string(t.Parent)); err != nil { 948 + return err 949 + } 950 + return nil 951 + } 952 + 953 + func (t *FeedComment_Reply) UnmarshalCBOR(r io.Reader) (err error) { 954 + *t = FeedComment_Reply{} 955 + 956 + cr := cbg.NewCborReader(r) 957 + 958 + maj, extra, err := cr.ReadHeader() 959 + if err != nil { 960 + return err 961 + } 962 + defer func() { 963 + if err == io.EOF { 964 + err = io.ErrUnexpectedEOF 965 + } 966 + }() 967 + 968 + if maj != cbg.MajMap { 969 + return fmt.Errorf("cbor input should be of type map") 970 + } 971 + 972 + if extra > cbg.MaxLength { 973 + return fmt.Errorf("FeedComment_Reply: map struct too large (%d)", extra) 974 + } 975 + 976 + n := extra 977 + 978 + nameBuf := make([]byte, 6) 979 + for i := uint64(0); i < n; i++ { 980 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 981 + if err != nil { 982 + return err 983 + } 984 + 985 + if !ok { 986 + // Field doesn't exist on this type, so ignore it 987 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 988 + return err 989 + } 990 + continue 991 + } 992 + 993 + switch string(nameBuf[:nameLen]) { 994 + // t.Root (string) (string) 995 + case "root": 996 + 997 + { 998 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 999 + if err != nil { 1000 + return err 1001 + } 1002 + 1003 + t.Root = string(sval) 1004 + } 1005 + // t.Parent (string) (string) 1006 + case "parent": 1007 + 1008 + { 1009 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1010 + if err != nil { 1011 + return err 1012 + } 1013 + 1014 + t.Parent = string(sval) 1015 + } 1016 + 1017 + default: 1018 + // Field doesn't exist on this type, so ignore it 1019 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1020 + return err 1021 + } 1022 + } 1023 + } 1024 + 1025 + return nil 1026 + } 651 1027 func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 652 1028 if t == nil { 653 1029 _, err := w.Write(cbg.CborNull)
+35
api/yoten/feedcomment.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package yoten 4 + 5 + // schema: app.yoten.feed.comment 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + FeedCommentNSID = "app.yoten.feed.comment" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("app.yoten.feed.comment", &FeedComment{}) 17 + } // 18 + // RECORDTYPE: FeedComment 19 + type FeedComment struct { 20 + LexiconTypeID string `json:"$type,const=app.yoten.feed.comment" cborgen:"$type,const=app.yoten.feed.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + // reply: Indicates that this comment is a reply to another comment. 24 + Reply *FeedComment_Reply `json:"reply,omitempty" cborgen:"reply,omitempty"` 25 + // subject: A reference to the study session being commented on. 26 + Subject string `json:"subject" cborgen:"subject"` 27 + } 28 + 29 + // Indicates that this comment is a reply to another comment. 30 + type FeedComment_Reply struct { 31 + // parent: A reference to the specific comment being replied to. 32 + Parent string `json:"parent" cborgen:"parent"` 33 + // root: A reference to the original study session (the root of the conversation). 34 + Root string `json:"root" cborgen:"root"` 35 + }
+2
cmd/gen.go
··· 28 28 yoten.ActivityDef{}, 29 29 yoten.GraphFollow{}, 30 30 yoten.FeedReaction{}, 31 + yoten.FeedComment{}, 32 + yoten.FeedComment_Reply{}, 31 33 } 32 34 33 35 for name, rt := range AllLexTypes() {
+249
internal/db/comment.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "sort" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "yoten.app/api/yoten" 11 + "yoten.app/internal/types" 12 + ) 13 + 14 + type CommentFeedItem struct { 15 + CommentWithBskyProfile 16 + Replies []CommentWithBskyProfile 17 + } 18 + 19 + type CommentWithBskyProfile struct { 20 + Comment 21 + ProfileLevel int 22 + ProfileDisplayName string 23 + BskyProfile types.BskyProfile 24 + } 25 + 26 + type CommentWithLocalProfile struct { 27 + Comment 28 + ProfileLevel int 29 + ProfileDisplayName string 30 + } 31 + 32 + type Comment struct { 33 + ID int 34 + Did string 35 + Rkey string 36 + StudySessionUri syntax.ATURI 37 + ParentCommentUri *syntax.ATURI 38 + Body string 39 + IsDeleted bool 40 + CreatedAt time.Time 41 + } 42 + 43 + func (c Comment) CommentAt() syntax.ATURI { 44 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, yoten.FeedCommentNSID, c.Rkey)) 45 + } 46 + 47 + func (c Comment) GetRkey() string { 48 + return c.Rkey 49 + } 50 + 51 + func UpsertComment(e Execer, comment Comment) error { 52 + var parentCommentUri *string 53 + if comment.ParentCommentUri != nil { 54 + parentCommentUri = ToPtr(comment.ParentCommentUri.String()) 55 + } else { 56 + parentCommentUri = nil 57 + } 58 + 59 + _, err := e.Exec(` 60 + insert into comments ( 61 + did, 62 + rkey, 63 + study_session_uri, 64 + parent_comment_uri, 65 + body, 66 + is_deleted, 67 + created_at 68 + ) 69 + values (?, ?, ?, ?, ?, ?, ?) 70 + on conflict(did, rkey) do update set 71 + is_deleted = excluded.is_deleted, 72 + body = excluded.body`, 73 + comment.Did, 74 + comment.Rkey, 75 + comment.StudySessionUri.String(), 76 + parentCommentUri, 77 + comment.Body, 78 + comment.IsDeleted, 79 + comment.CreatedAt.Format(time.RFC3339), 80 + ) 81 + if err != nil { 82 + return fmt.Errorf("failed to insert or update comment: %w", err) 83 + } 84 + 85 + return nil 86 + } 87 + 88 + func DeleteCommentByRkey(e Execer, did string, rkey string) error { 89 + _, err := e.Exec(` 90 + update comments 91 + set is_deleted = ? 92 + where did = ? and rkey = ?`, 93 + Deleted, did, rkey, 94 + ) 95 + return err 96 + } 97 + 98 + func GetCommentByRkey(e Execer, did string, rkey string) (Comment, error) { 99 + comment := Comment{} 100 + var parentCommentUri sql.NullString 101 + var studySessionUriStr string 102 + var createdAtStr string 103 + 104 + err := e.QueryRow(` 105 + select id, did, rkey, study_session_uri, parent_comment_uri, body, is_deleted, created_at 106 + from comments 107 + where did is ? and rkey = ?`, 108 + did, rkey, 109 + ).Scan(&comment.ID, &comment.Did, &comment.Rkey, &studySessionUriStr, &parentCommentUri, &comment.Body, &comment.IsDeleted, &createdAtStr) 110 + if err != nil { 111 + if err == sql.ErrNoRows { 112 + return Comment{}, fmt.Errorf("comment does not exist") 113 + } 114 + return Comment{}, err 115 + } 116 + 117 + comment.CreatedAt, err = time.Parse(time.RFC3339, createdAtStr) 118 + if err != nil { 119 + return Comment{}, fmt.Errorf("failed to parse created at string '%s': %w", createdAtStr, err) 120 + } 121 + 122 + comment.StudySessionUri, err = syntax.ParseATURI(studySessionUriStr) 123 + if err != nil { 124 + return Comment{}, fmt.Errorf("failed to parse study session at-uri: %w", err) 125 + } 126 + 127 + return comment, nil 128 + } 129 + 130 + func GetCommentsForSession(e Execer, studySessionUri string, limit, offset int) ([]CommentWithLocalProfile, error) { 131 + topLevelCommentsQuery := ` 132 + select 133 + c.id, c.did, c.rkey, c.study_session_uri, c.parent_comment_uri, 134 + c.body, c.is_deleted, c.created_at, 135 + p.display_name, p.level 136 + from comments c 137 + join profiles p on c.did = p.did 138 + where c.study_session_uri = ? and c.parent_comment_uri is null 139 + order by c.created_at asc 140 + limit ? offset ?; 141 + ` 142 + rows, err := e.Query(topLevelCommentsQuery, studySessionUri, limit, offset) 143 + if err != nil { 144 + return nil, fmt.Errorf("failed to query top-level comments: %w", err) 145 + } 146 + defer rows.Close() 147 + 148 + allCommentsMap := make(map[string]CommentWithLocalProfile) 149 + var topLevelCommentUris []string 150 + 151 + for rows.Next() { 152 + comment, err := scanCommentWithLocalProfile(rows) 153 + if err != nil { 154 + return nil, err 155 + } 156 + allCommentsMap[comment.CommentAt().String()] = comment 157 + topLevelCommentUris = append(topLevelCommentUris, comment.CommentAt().String()) 158 + } 159 + if err = rows.Err(); err != nil { 160 + return nil, fmt.Errorf("error iterating top-level comment rows: %w", err) 161 + } 162 + rows.Close() 163 + 164 + if len(topLevelCommentUris) == 0 { 165 + return []CommentWithLocalProfile{}, nil 166 + } 167 + 168 + repliesQuery := ` 169 + select 170 + c.id, c.did, c.rkey, c.study_session_uri, c.parent_comment_uri, 171 + c.body, c.is_deleted, c.created_at, 172 + p.display_name, p.level 173 + from comments c 174 + join profiles p on c.did = p.did 175 + where c.study_session_uri = ? and c.parent_comment_uri in (` + GetPlaceholders(len(topLevelCommentUris)) + `); 176 + ` 177 + args := make([]any, len(topLevelCommentUris)+1) 178 + args[0] = studySessionUri 179 + for i, uri := range topLevelCommentUris { 180 + args[i+1] = uri 181 + } 182 + 183 + replyRows, err := e.Query(repliesQuery, args...) 184 + if err != nil { 185 + return nil, fmt.Errorf("failed to query replies: %w", err) 186 + } 187 + defer replyRows.Close() 188 + 189 + for replyRows.Next() { 190 + reply, err := scanCommentWithLocalProfile(replyRows) 191 + if err != nil { 192 + return nil, err 193 + } 194 + allCommentsMap[reply.CommentAt().String()] = reply 195 + } 196 + if err = replyRows.Err(); err != nil { 197 + return nil, fmt.Errorf("error iterating reply rows: %w", err) 198 + } 199 + 200 + finalComments := make([]CommentWithLocalProfile, 0, len(allCommentsMap)) 201 + for _, comment := range allCommentsMap { 202 + finalComments = append(finalComments, comment) 203 + } 204 + 205 + sort.Slice(finalComments, func(i, j int) bool { 206 + return finalComments[i].CreatedAt.Before(finalComments[j].CreatedAt) 207 + }) 208 + 209 + return finalComments, nil 210 + } 211 + 212 + func scanCommentWithLocalProfile(rows *sql.Rows) (CommentWithLocalProfile, error) { 213 + var comment CommentWithLocalProfile 214 + var parentUri sql.NullString 215 + var studySessionUriStr string 216 + var createdAtStr string 217 + 218 + err := rows.Scan( 219 + &comment.ID, &comment.Did, &comment.Rkey, &studySessionUriStr, 220 + &parentUri, &comment.Body, &comment.IsDeleted, &createdAtStr, 221 + &comment.ProfileDisplayName, &comment.ProfileLevel, 222 + ) 223 + if err != nil { 224 + return CommentWithLocalProfile{}, fmt.Errorf("failed to scan comment row: %w", err) 225 + } 226 + 227 + comment.CreatedAt, err = time.Parse(time.RFC3339, createdAtStr) 228 + if err != nil { 229 + return CommentWithLocalProfile{}, fmt.Errorf("failed to parse created at string '%s': %w", createdAtStr, err) 230 + } 231 + 232 + parsedStudySessionUri, err := syntax.ParseATURI(studySessionUriStr) 233 + if err != nil { 234 + return CommentWithLocalProfile{}, fmt.Errorf("failed to parse at-uri: %w", err) 235 + } 236 + comment.StudySessionUri = parsedStudySessionUri 237 + 238 + if parentUri.Valid { 239 + parsedParentUri, err := syntax.ParseATURI(parentUri.String) 240 + if err != nil { 241 + return CommentWithLocalProfile{}, fmt.Errorf("failed to parse at-uri: %w", err) 242 + } 243 + comment.ParentCommentUri = &parsedParentUri 244 + } 245 + 246 + comment.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr) 247 + 248 + return comment, nil 249 + }
+177 -161
internal/db/db.go
··· 29 29 return nil, fmt.Errorf("failed to open db: %w", err) 30 30 } 31 31 _, err = db.Exec(` 32 - pragma journal_mode = WAL; 33 - pragma synchronous = normal; 34 - pragma foreign_keys = on; 35 - pragma temp_store = memory; 36 - pragma mmap_size = 30000000000; 37 - pragma page_size = 32768; 38 - pragma auto_vacuum = incremental; 39 - pragma busy_timeout = 5000; 32 + pragma journal_mode = WAL; 33 + pragma synchronous = normal; 34 + pragma foreign_keys = on; 35 + pragma temp_store = memory; 36 + pragma mmap_size = 30000000000; 37 + pragma page_size = 32768; 38 + pragma auto_vacuum = incremental; 39 + pragma busy_timeout = 5000; 40 40 41 - create table if not exists oauth_requests ( 42 - id integer primary key autoincrement, 43 - auth_server_iss text not null, 44 - state text not null, 45 - did text not null, 46 - handle text not null, 47 - pds_url text not null, 48 - pkce_verifier text not null, 49 - dpop_auth_server_nonce text not null, 50 - dpop_private_jwk text not null 51 - ); 41 + create table if not exists oauth_requests ( 42 + id integer primary key autoincrement, 43 + auth_server_iss text not null, 44 + state text not null, 45 + did text not null, 46 + handle text not null, 47 + pds_url text not null, 48 + pkce_verifier text not null, 49 + dpop_auth_server_nonce text not null, 50 + dpop_private_jwk text not null 51 + ); 52 52 53 - create table if not exists oauth_sessions ( 54 - id integer primary key autoincrement, 55 - did text not null, 56 - handle text not null, 57 - pds_url text not null, 58 - auth_server_iss text not null, 59 - access_jwt text not null, 60 - refresh_jwt text not null, 61 - dpop_pds_nonce text, 62 - dpop_auth_server_nonce text not null, 63 - dpop_private_jwk text not null, 64 - expiry text not null 65 - ); 53 + create table if not exists oauth_sessions ( 54 + id integer primary key autoincrement, 55 + did text not null, 56 + handle text not null, 57 + pds_url text not null, 58 + auth_server_iss text not null, 59 + access_jwt text not null, 60 + refresh_jwt text not null, 61 + dpop_pds_nonce text, 62 + dpop_auth_server_nonce text not null, 63 + dpop_private_jwk text not null, 64 + expiry text not null 65 + ); 66 66 67 - create table if not exists profiles ( 68 - -- id 69 - id integer primary key autoincrement, 70 - did text not null, 67 + create table if not exists profiles ( 68 + -- id 69 + id integer primary key autoincrement, 70 + did text not null, 71 71 72 - -- data 73 - display_name text not null, 74 - description text, 75 - location text, 76 - xp integer not null default 0, -- total accumulated xp 77 - level integer not null default 0, 78 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 72 + -- data 73 + display_name text not null, 74 + description text, 75 + location text, 76 + xp integer not null default 0, -- total accumulated xp 77 + level integer not null default 0, 78 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 79 79 80 - -- constraints 81 - unique(did) 82 - ); 80 + -- constraints 81 + unique(did) 82 + ); 83 83 84 - create table if not exists profile_languages ( 85 - -- id 86 - did text not null, 84 + create table if not exists profile_languages ( 85 + -- id 86 + did text not null, 87 87 88 - -- data 89 - language_code text not null, 88 + -- data 89 + language_code text not null, 90 90 91 - -- constraints 92 - primary key (did, language_code), 93 - check (length(language_code) = 2), 94 - foreign key (did) references profiles(did) on delete cascade 95 - ); 91 + -- constraints 92 + primary key (did, language_code), 93 + check (length(language_code) = 2), 94 + foreign key (did) references profiles(did) on delete cascade 95 + ); 96 96 97 - create table if not exists study_sessions ( 98 - -- id 99 - did text not null, 100 - rkey text not null, 97 + create table if not exists study_sessions ( 98 + -- id 99 + did text not null, 100 + rkey text not null, 101 101 102 - -- data 103 - activity_id integer not null, 104 - resource_id integer, 105 - description text, 106 - duration integer not null, 107 - language_code text not null, 108 - date text not null, 109 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 102 + -- data 103 + activity_id integer not null, 104 + resource_id integer, 105 + description text, 106 + duration integer not null, 107 + language_code text not null, 108 + date text not null, 109 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 110 110 111 - -- constraints 112 - check (length(language_code) = 2), 113 - foreign key (did) references profiles(did) on delete cascade, 114 - foreign key (activity_id) references activities(id) on delete restrict, 115 - foreign key (resource_id) references resources(id) on delete set null, 116 - primary key (did, rkey) 117 - ); 111 + -- constraints 112 + check (length(language_code) = 2), 113 + foreign key (did) references profiles(did) on delete cascade, 114 + foreign key (activity_id) references activities(id) on delete restrict, 115 + foreign key (resource_id) references resources(id) on delete set null, 116 + primary key (did, rkey) 117 + ); 118 118 119 - create table if not exists categories ( 120 - id integer primary key, -- Matches StudySessionCategory iota 121 - name text not null unique 122 - ); 119 + create table if not exists categories ( 120 + id integer primary key, -- Matches StudySessionCategory iota 121 + name text not null unique 122 + ); 123 123 124 - create table if not exists activities ( 125 - id integer primary key autoincrement, 124 + create table if not exists activities ( 125 + id integer primary key autoincrement, 126 126 127 - did text, 128 - rkey text, 127 + did text, 128 + rkey text, 129 129 130 - name text not null, 131 - description text, 132 - status integer not null default 0 check(status in (0, 1)), 133 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 130 + name text not null, 131 + description text, 132 + status integer not null default 0 check(status in (0, 1)), 133 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 134 134 135 - foreign key (did) references profiles(did) on delete cascade, 136 - unique (did, rkey) 137 - ); 135 + foreign key (did) references profiles(did) on delete cascade, 136 + unique (did, rkey) 137 + ); 138 138 139 - create table if not exists activity_categories ( 140 - activity_id integer not null, 141 - category_id integer not null, 139 + create table if not exists activity_categories ( 140 + activity_id integer not null, 141 + category_id integer not null, 142 142 143 - foreign key (activity_id) references activities(id) on delete cascade, 144 - foreign key (category_id) references categories(id) on delete cascade, 145 - primary key (activity_id, category_id) 146 - ); 143 + foreign key (activity_id) references activities(id) on delete cascade, 144 + foreign key (category_id) references categories(id) on delete cascade, 145 + primary key (activity_id, category_id) 146 + ); 147 147 148 - create table if not exists follows ( 149 - user_did text not null, 150 - subject_did text not null, 148 + create table if not exists follows ( 149 + user_did text not null, 150 + subject_did text not null, 151 151 152 - rkey text not null, 153 - followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 152 + rkey text not null, 153 + followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 154 154 155 - primary key (user_did, subject_did), 156 - check (user_did <> subject_did) 157 - ); 155 + primary key (user_did, subject_did), 156 + check (user_did <> subject_did) 157 + ); 158 158 159 - create table if not exists xp_events ( 160 - id integer primary key autoincrement, 159 + create table if not exists xp_events ( 160 + id integer primary key autoincrement, 161 161 162 - did text not null, 163 - session_rkey text not null, 164 - xp_gained integer not null, 165 - created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 162 + did text not null, 163 + session_rkey text not null, 164 + xp_gained integer not null, 165 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 166 166 167 - foreign key (did) references profiles (did), 168 - foreign key (did, session_rkey) references study_sessions (did, rkey), 169 - unique (did, session_rkey) 170 - ); 167 + foreign key (did) references profiles (did), 168 + foreign key (did, session_rkey) references study_sessions (did, rkey), 169 + unique (did, session_rkey) 170 + ); 171 171 172 - create table if not exists study_session_reactions ( 173 - id integer primary key autoincrement, 172 + create table if not exists study_session_reactions ( 173 + id integer primary key autoincrement, 174 174 175 - did text not null, 176 - rkey text not null, 175 + did text not null, 176 + rkey text not null, 177 177 178 - session_did text not null, 179 - session_rkey text not null, 180 - reaction_id integer not null, 181 - created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 178 + session_did text not null, 179 + session_rkey text not null, 180 + reaction_id integer not null, 181 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 182 182 183 - foreign key (did) references profiles (did), 184 - foreign key (session_did, session_rkey) references study_sessions (did, rkey), 185 - unique (did, session_did, session_rkey, reaction_id) 186 - ); 183 + foreign key (did) references profiles (did), 184 + foreign key (session_did, session_rkey) references study_sessions (did, rkey), 185 + unique (did, session_did, session_rkey, reaction_id) 186 + ); 187 187 188 - create table if not exists notifications ( 189 - id integer primary key autoincrement, 188 + create table if not exists notifications ( 189 + id integer primary key autoincrement, 190 190 191 - recipient_did text not null, 192 - actor_did text not null, 193 - subject_uri text not null, 191 + recipient_did text not null, 192 + actor_did text not null, 193 + subject_uri text not null, 194 194 195 - state text not null default 'unread' check(state in ('unread', 'read')), 196 - type text not null check(type in ('follow', 'reaction')), 195 + state text not null default 'unread' check(state in ('unread', 'read')), 196 + type text not null check(type in ('follow', 'reaction', 'comment')), 197 197 198 - created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 198 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 199 199 200 - foreign key (recipient_did) references profiles(did) on delete cascade, 201 - foreign key (actor_did) references profiles(did) on delete cascade 202 - ); 200 + foreign key (recipient_did) references profiles(did) on delete cascade, 201 + foreign key (actor_did) references profiles(did) on delete cascade 202 + ); 203 203 204 - create table if not exists resources ( 205 - id integer primary key autoincrement, 204 + create table if not exists resources ( 205 + id integer primary key autoincrement, 206 206 207 - did text not null, 208 - rkey text not null, 207 + did text not null, 208 + rkey text not null, 209 209 210 - title text not null, 211 - type text not null, 212 - author text not null, 213 - link text, 214 - description text not null, 215 - status integer not null default 0 check(status in (0, 1)), 216 - created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 210 + title text not null, 211 + type text not null, 212 + author text not null, 213 + link text, 214 + description text not null, 215 + status integer not null default 0 check(status in (0, 1)), 216 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 217 217 218 - foreign key (did) references profiles (did) on delete cascade, 219 - unique (did, rkey) 220 - ); 218 + foreign key (did) references profiles (did) on delete cascade, 219 + unique (did, rkey) 220 + ); 221 221 222 - create table if not exists _jetstream ( 223 - id integer primary key autoincrement, 224 - last_time_us integer not null 225 - ); 222 + create table if not exists comments ( 223 + id integer primary key autoincrement, 226 224 227 - create table if not exists migrations ( 228 - id integer primary key autoincrement, 229 - name text unique 230 - ); 231 - `) 225 + did text not null, 226 + rkey text not null, 227 + 228 + study_session_uri text not null, 229 + parent_comment_uri text, 230 + body text not null, 231 + is_deleted boolean not null default false, 232 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 233 + 234 + foreign key (did) references profiles(did) on delete cascade 235 + unique (did, rkey) 236 + ); 237 + 238 + create table if not exists _jetstream ( 239 + id integer primary key autoincrement, 240 + last_time_us integer not null 241 + ); 242 + 243 + create table if not exists migrations ( 244 + id integer primary key autoincrement, 245 + name text unique 246 + ); 247 + `) 232 248 if err != nil { 233 249 return nil, fmt.Errorf("failed to execute db create statement: %w", err) 234 250 }
+1
internal/server/views/new-study-session.templ
··· 500 500 stopAndLog() { 501 501 this.pause(); 502 502 const form = this.$root; 503 + this.timerState = 'stopped'; 503 504 504 505 let durationSeconds = form.querySelector('input[name="duration_seconds"]'); 505 506 if (!durationSeconds) {
+50
lexicons/feed/comment.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.yoten.feed.comment", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "description": "A declaration of a Yōten comment.", 10 + "key": "tid", 11 + "record": { 12 + "type": "object", 13 + "required": ["subject", "body", "createdAt"], 14 + "properties": { 15 + "body": { 16 + "type": "string", 17 + "minLength": 1, 18 + "maxLength": 256 19 + }, 20 + "subject": { 21 + "type": "string", 22 + "format": "at-uri", 23 + "description": "A reference to the study session being commented on." 24 + }, 25 + "reply": { 26 + "type": "object", 27 + "description": "Indicates that this comment is a reply to another comment.", 28 + "required": ["root", "parent"], 29 + "properties": { 30 + "root": { 31 + "type": "string", 32 + "format": "at-uri", 33 + "description": "A reference to the original study session (the root of the conversation)." 34 + }, 35 + "parent": { 36 + "type": "string", 37 + "format": "at-uri", 38 + "description": "A reference to the specific comment being replied to." 39 + } 40 + } 41 + }, 42 + "createdAt": { 43 + "type": "string", 44 + "format": "datetime" 45 + } 46 + } 47 + } 48 + } 49 + } 50 + }
+4
internal/clients/posthog/posthog.go
··· 19 19 ReactionRecordCreatedEvent string = "reaction-record-created" 20 20 ReactionRecordDeletedEvent string = "reaction-record-deleted" 21 21 22 + CommentRecordCreatedEvent string = "comment-record-created" 23 + CommentRecordDeletedEvent string = "comment-record-deleted" 24 + CommentRecordEditedEvent string = "comment-record-edited" 25 + 22 26 ActivityDefRecordFirstCreated string = "activity-def-record-first-created" 23 27 ActivityDefRecordCreatedEvent string = "activity-def-record-created" 24 28 ActivityDefRecordDeletedEvent string = "activity-def-record-deleted"
+91 -12
internal/consumer/ingester.go
··· 50 50 err = i.ingestFollow(e) 51 51 case yoten.FeedReactionNSID: 52 52 err = i.ingestReaction(e) 53 + case yoten.FeedCommentNSID: 54 + err = i.ingestComment(e) 53 55 } 54 56 } 55 57 if err != nil { ··· 117 119 118 120 119 121 122 + ddb, ok := i.Db.Execer.(*db.DB) 123 + if !ok { 124 + return fmt.Errorf("failed to index profile record: ddb not valid") 125 + } 120 126 127 + tx, err := ddb.Begin() 121 128 122 129 123 130 ··· 327 334 328 335 329 336 337 + ddb, ok := i.Db.Execer.(*db.DB) 338 + if !ok { 339 + return fmt.Errorf("failed to index activity def record: ddb not valid") 340 + } 330 341 342 + tx, err := ddb.Begin() 331 343 332 344 333 345 ··· 363 375 364 376 365 377 378 + ddb, ok := i.Db.Execer.(*db.DB) 379 + if !ok { 380 + return fmt.Errorf("failed to index activity def record: ddb not valid") 381 + } 366 382 383 + tx, err := ddb.Begin() 367 384 368 385 369 386 ··· 408 425 409 426 410 427 428 + ddb, ok := i.Db.Execer.(*db.DB) 429 + if !ok { 430 + return fmt.Errorf("failed to index reaction record: ddb not valid") 431 + } 411 432 433 + tx, err := ddb.Begin() 412 434 413 435 414 436 ··· 490 512 491 513 492 514 515 + ddb, ok := i.Db.Execer.(*db.DB) 516 + if !ok { 517 + return fmt.Errorf("failed to index resource record: ddb not valid") 518 + } 493 519 520 + tx, err := ddb.Begin() 494 521 495 522 496 523 ··· 534 561 535 562 536 563 564 + return nil 565 + } 537 566 567 + func (i *Ingester) ingestComment(e *models.Event) error { 568 + var err error 569 + did := e.Did 538 570 571 + switch e.Commit.Operation { 572 + case models.CommitOperationCreate, models.CommitOperationUpdate: 573 + raw := json.RawMessage(e.Commit.Record) 574 + record := yoten.FeedComment{} 575 + err = json.Unmarshal(raw, &record) 576 + if err != nil { 577 + return fmt.Errorf("invalid record: %w", err) 578 + } 539 579 580 + subject := record.Subject 581 + subjectUri, err := syntax.ParseATURI(subject) 582 + if err != nil { 583 + return fmt.Errorf("failed to parse study session at-uri: %w", err) 584 + } 585 + subjectDid, err := subjectUri.Authority().AsDID() 586 + if err != nil { 587 + return fmt.Errorf("failed to identify subject did: %w", err) 588 + } 540 589 590 + body := record.Body 591 + if len(strings.TrimSpace(body)) == 0 { 592 + return fmt.Errorf("invalid body: length cannot be 0") 593 + } 541 594 595 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 596 + if err != nil { 597 + return fmt.Errorf("invalid createdAt format: %w", err) 598 + } 542 599 600 + ddb, ok := i.Db.Execer.(*db.DB) 601 + if !ok { 602 + return fmt.Errorf("failed to index resource record: ddb not valid") 603 + } 543 604 605 + tx, err := ddb.Begin() 606 + if err != nil { 607 + return fmt.Errorf("failed to start transaction: %w", err) 608 + } 544 609 610 + // TODO: Parse reply 545 611 612 + comment := db.Comment{ 613 + Did: did, 614 + Rkey: e.Commit.RKey, 615 + StudySessionUri: subjectUri, 616 + Body: body, 617 + CreatedAt: createdAt, 618 + } 546 619 620 + log.Println("upserting comment from pds request") 621 + err = db.UpsertComment(i.Db, comment) 622 + if err != nil { 623 + tx.Rollback() 624 + return fmt.Errorf("failed to upsert comment record: %w", err) 625 + } 547 626 627 + err = db.CreateNotification(tx, subjectDid.String(), did, subjectUri.String(), db.NotificationTypeComment) 628 + if err != nil { 629 + log.Println("failed to create notification record:", err) 630 + } 548 631 549 - 550 - 551 - 552 - 553 - 554 - 555 - 556 - 557 - 558 - 559 - 560 - 632 + return tx.Commit() 633 + case models.CommitOperationDelete: 634 + log.Println("deleting comment from pds request") 635 + err = db.DeleteCommentByRkey(i.Db, did, e.Commit.RKey) 636 + } 637 + if err != nil { 638 + return fmt.Errorf("failed to %s follow record: %w", e.Commit.Operation, err) 639 + } 561 640 562 641 return nil 563 642 }
+3 -1
internal/server/app.go
··· 62 62 jc, err := consumer.NewJetstreamClient( 63 63 config.Jetstream.Endpoint, 64 64 "yoten", 65 - []string{yoten.ActorProfileNSID, 65 + []string{ 66 + yoten.ActorProfileNSID, 66 67 yoten.FeedSessionNSID, 67 68 yoten.FeedResourceNSID, 69 + yoten.FeedCommentNSID, 68 70 yoten.FeedReactionNSID, 69 71 yoten.ActivityDefNSID, 70 72 yoten.GraphFollowNSID,
+370
internal/server/handlers/comment.go
··· 1 + package handlers 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "strings" 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 + "github.com/go-chi/chi/v5" 13 + "github.com/posthog/posthog-go" 14 + "yoten.app/api/yoten" 15 + "yoten.app/internal/atproto" 16 + "yoten.app/internal/clients/bsky" 17 + ph "yoten.app/internal/clients/posthog" 18 + "yoten.app/internal/db" 19 + "yoten.app/internal/server/htmx" 20 + "yoten.app/internal/server/views/partials" 21 + "yoten.app/internal/types" 22 + "yoten.app/internal/utils" 23 + ) 24 + 25 + func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { 26 + client, err := h.Oauth.AuthorizedClient(r, w) 27 + if err != nil { 28 + log.Println("failed to get authorized client:", err) 29 + htmx.HxRedirect(w, "/login") 30 + return 31 + } 32 + 33 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 34 + if err != nil { 35 + log.Println("failed to get logged-in user:", err) 36 + htmx.HxRedirect(w, "/login") 37 + return 38 + } 39 + 40 + profile, err := db.GetProfile(h.Db, user.Did) 41 + if err != nil { 42 + log.Println("failed to get logged-in user:", err) 43 + htmx.HxRedirect(w, "/login") 44 + return 45 + } 46 + 47 + err = r.ParseForm() 48 + if err != nil { 49 + log.Println("invalid comment form:", err) 50 + htmx.HxError(w, http.StatusBadRequest, "Unable to process comment, please try again later.") 51 + return 52 + } 53 + 54 + commentBody := r.FormValue("comment") 55 + if len(strings.TrimSpace(commentBody)) == 0 { 56 + log.Println("invalid comment form: missing comment body") 57 + htmx.HxError(w, http.StatusBadRequest, "Comment cannot be empty.") 58 + return 59 + } 60 + 61 + studySessionUri := r.FormValue("study_session_uri") 62 + if len(studySessionUri) == 0 { 63 + log.Println("invalid comment form: missing study session Uri") 64 + htmx.HxError(w, http.StatusBadRequest, "Unable to create comment, please try again later.") 65 + return 66 + } 67 + 68 + newComment := db.Comment{ 69 + Rkey: atproto.TID(), 70 + Did: user.Did, 71 + StudySessionUri: syntax.ATURI(studySessionUri), 72 + Body: commentBody, 73 + CreatedAt: time.Now(), 74 + } 75 + 76 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 77 + Collection: yoten.FeedCommentNSID, 78 + Repo: newComment.Did, 79 + Rkey: newComment.Rkey, 80 + Record: &lexutil.LexiconTypeDecoder{ 81 + Val: &yoten.FeedComment{ 82 + LexiconTypeID: yoten.FeedCommentNSID, 83 + Body: newComment.Body, 84 + Subject: newComment.StudySessionUri.String(), 85 + CreatedAt: newComment.CreatedAt.Format(time.RFC3339), 86 + }, 87 + }, 88 + }) 89 + if err != nil { 90 + log.Println("failed to create comment record:", err) 91 + htmx.HxError(w, http.StatusInternalServerError, "Failed to create comment, try again later.") 92 + return 93 + } 94 + 95 + if !h.Config.Core.Dev { 96 + event := posthog.Capture{ 97 + DistinctId: user.Did, 98 + Event: ph.CommentRecordCreatedEvent, 99 + Properties: posthog.NewProperties(). 100 + Set("is_reply", newComment.ParentCommentUri != nil). 101 + Set("character_count", len(newComment.Body)). 102 + Set("study_session_uri", newComment.StudySessionUri.String()), 103 + } 104 + 105 + if newComment.ParentCommentUri != nil { 106 + event.Properties.Set("parent_comment_uri", *newComment.ParentCommentUri) 107 + } 108 + 109 + err = h.Posthog.Enqueue(event) 110 + if err != nil { 111 + log.Println("failed to enqueue posthog event:", err) 112 + } 113 + } 114 + 115 + partials.Comment(partials.CommentProps{ 116 + Comment: db.CommentFeedItem{ 117 + CommentWithBskyProfile: db.CommentWithBskyProfile{ 118 + Comment: newComment, 119 + ProfileLevel: profile.Level, 120 + ProfileDisplayName: profile.DisplayName, 121 + BskyProfile: user.BskyProfile, 122 + }, 123 + Replies: []db.CommentWithBskyProfile{}, 124 + }, 125 + DoesOwn: true, 126 + }).Render(r.Context(), w) 127 + } 128 + 129 + func (h *Handler) HandleDeleteComment(w http.ResponseWriter, r *http.Request) { 130 + user := h.Oauth.GetUser(r) 131 + if user == nil { 132 + log.Println("failed to get logged-in user") 133 + htmx.HxRedirect(w, "/login") 134 + return 135 + } 136 + client, err := h.Oauth.AuthorizedClient(r, w) 137 + if err != nil { 138 + log.Println("failed to get authorized client:", err) 139 + htmx.HxRedirect(w, "/login") 140 + return 141 + } 142 + 143 + switch r.Method { 144 + case http.MethodDelete: 145 + rkey := chi.URLParam(r, "rkey") 146 + comment, err := db.GetCommentByRkey(h.Db, user.Did, rkey) 147 + if err != nil { 148 + log.Println("failed to get comment from db:", err) 149 + htmx.HxError(w, http.StatusInternalServerError, "Failed to delete comment, try again later.") 150 + return 151 + } 152 + 153 + if user.Did != comment.Did { 154 + log.Printf("user '%s' does not own record '%s'", user.Did, rkey) 155 + htmx.HxError(w, http.StatusUnauthorized, "You do not have permissions to delete this comment.") 156 + return 157 + } 158 + 159 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 160 + Collection: yoten.FeedCommentNSID, 161 + Repo: user.Did, 162 + Rkey: comment.Rkey, 163 + }) 164 + if err != nil { 165 + log.Println("failed to delete comment from PDS:", err) 166 + htmx.HxError(w, http.StatusInternalServerError, "Failed to delete comment, try again later.") 167 + return 168 + } 169 + 170 + if !h.Config.Core.Dev { 171 + event := posthog.Capture{ 172 + DistinctId: user.Did, 173 + Event: ph.CommentRecordDeletedEvent, 174 + Properties: posthog.NewProperties(). 175 + Set("is_reply", comment.ParentCommentUri != nil). 176 + Set("character_count", len(comment.Body)). 177 + Set("study_session_uri", comment.StudySessionUri.String()), 178 + } 179 + 180 + if comment.ParentCommentUri != nil { 181 + event.Properties.Set("parent_comment_uri", *comment.ParentCommentUri) 182 + } 183 + 184 + err = h.Posthog.Enqueue(event) 185 + if err != nil { 186 + log.Println("failed to enqueue posthog event:", err) 187 + } 188 + } 189 + 190 + w.WriteHeader(http.StatusOK) 191 + } 192 + } 193 + 194 + func (h *Handler) HandleEditCommentPage(w http.ResponseWriter, r *http.Request) { 195 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 196 + if err != nil { 197 + log.Println("failed to get logged-in user:", err) 198 + htmx.HxRedirect(w, "/login") 199 + return 200 + } 201 + 202 + rkey := chi.URLParam(r, "rkey") 203 + comment, err := db.GetCommentByRkey(h.Db, user.Did, rkey) 204 + if err != nil { 205 + log.Println("failed to get comment from db:", err) 206 + htmx.HxError(w, http.StatusInternalServerError, "Failed to update comment, try again later.") 207 + return 208 + } 209 + 210 + if user.Did != comment.Did { 211 + log.Printf("user '%s' does not own record '%s'", user.Did, rkey) 212 + htmx.HxError(w, http.StatusUnauthorized, "You do not have permissions to edit this comment.") 213 + return 214 + } 215 + 216 + switch r.Method { 217 + case http.MethodGet: 218 + partials.EditComment(partials.EditCommentProps{Comment: comment}).Render(r.Context(), w) 219 + case http.MethodPost: 220 + client, err := h.Oauth.AuthorizedClient(r, w) 221 + if err != nil { 222 + log.Println("failed to get authorized client:", err) 223 + htmx.HxRedirect(w, "/login") 224 + return 225 + } 226 + 227 + profile, err := db.GetProfile(h.Db, user.Did) 228 + if err != nil { 229 + log.Println("failed to get logged-in user:", err) 230 + htmx.HxRedirect(w, "/login") 231 + return 232 + } 233 + 234 + err = r.ParseForm() 235 + if err != nil { 236 + log.Println("invalid comment form:", err) 237 + htmx.HxError(w, http.StatusBadRequest, "Unable to process comment, please try again later.") 238 + return 239 + } 240 + 241 + commentBody := r.FormValue("comment") 242 + if len(strings.TrimSpace(commentBody)) == 0 { 243 + log.Println("invalid comment form: missing comment body") 244 + htmx.HxError(w, http.StatusBadRequest, "Comment cannot be empty.") 245 + return 246 + } 247 + 248 + updatedComment := db.Comment{ 249 + Rkey: comment.Rkey, 250 + Did: comment.Did, 251 + StudySessionUri: comment.StudySessionUri, 252 + Body: commentBody, 253 + CreatedAt: comment.CreatedAt, 254 + } 255 + 256 + ex, _ := client.RepoGetRecord(r.Context(), "", yoten.FeedCommentNSID, user.Did, updatedComment.Rkey) 257 + var cid *string 258 + if ex != nil { 259 + cid = ex.Cid 260 + } 261 + 262 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 263 + Collection: yoten.FeedCommentNSID, 264 + Repo: updatedComment.Did, 265 + Rkey: updatedComment.Rkey, 266 + Record: &lexutil.LexiconTypeDecoder{ 267 + Val: &yoten.FeedComment{ 268 + LexiconTypeID: yoten.FeedCommentNSID, 269 + Body: updatedComment.Body, 270 + Subject: updatedComment.StudySessionUri.String(), 271 + CreatedAt: updatedComment.CreatedAt.Format(time.RFC3339), 272 + }, 273 + }, 274 + SwapRecord: cid, 275 + }) 276 + if err != nil { 277 + log.Println("failed to update study session record:", err) 278 + htmx.HxError(w, http.StatusInternalServerError, "Failed to update comment, try again later.") 279 + return 280 + } 281 + 282 + if !h.Config.Core.Dev { 283 + event := posthog.Capture{ 284 + DistinctId: user.Did, 285 + Event: ph.CommentRecordEditedEvent, 286 + Properties: posthog.NewProperties(). 287 + Set("is_reply", updatedComment.ParentCommentUri != nil). 288 + Set("character_count", len(updatedComment.Body)). 289 + Set("study_session_uri", updatedComment.StudySessionUri.String()), 290 + } 291 + 292 + if updatedComment.ParentCommentUri != nil { 293 + event.Properties.Set("parent_comment_uri", *updatedComment.ParentCommentUri) 294 + } 295 + 296 + err = h.Posthog.Enqueue(event) 297 + if err != nil { 298 + log.Println("failed to enqueue posthog event:", err) 299 + } 300 + } 301 + 302 + partials.Comment(partials.CommentProps{ 303 + Comment: db.CommentFeedItem{ 304 + CommentWithBskyProfile: db.CommentWithBskyProfile{ 305 + Comment: updatedComment, 306 + ProfileLevel: profile.Level, 307 + ProfileDisplayName: profile.DisplayName, 308 + BskyProfile: user.BskyProfile, 309 + }, 310 + // Replies are not needed to be populated as this response will 311 + // replace just the edited comment. 312 + Replies: []db.CommentWithBskyProfile{}, 313 + }, 314 + DoesOwn: true, 315 + }).Render(r.Context(), w) 316 + } 317 + } 318 + 319 + func (h *Handler) BuildCommentFeed(comments []db.CommentWithLocalProfile) ([]db.CommentFeedItem, error) { 320 + authorDids := utils.Map(comments, func(comment db.CommentWithLocalProfile) string { 321 + return comment.Did 322 + }) 323 + bskyProfiles, err := bsky.GetBskyProfiles(authorDids) 324 + if err != nil { 325 + return []db.CommentFeedItem{}, err 326 + } 327 + 328 + return assembleCommentFeed(comments, bskyProfiles), nil 329 + } 330 + 331 + func assembleCommentFeed(localComments []db.CommentWithLocalProfile, bskyProfiles map[string]types.BskyProfile) []db.CommentFeedItem { 332 + hydratedComments := make(map[string]db.CommentWithBskyProfile) 333 + repliesMap := make(map[string][]db.CommentWithBskyProfile) 334 + 335 + for _, lc := range localComments { 336 + hydrated := db.CommentWithBskyProfile{ 337 + Comment: lc.Comment, 338 + ProfileDisplayName: lc.ProfileDisplayName, 339 + ProfileLevel: lc.ProfileLevel, 340 + } 341 + if profile, ok := bskyProfiles[lc.Did]; ok { 342 + hydrated.BskyProfile = profile 343 + } 344 + hydratedComments[lc.CommentAt().String()] = hydrated 345 + } 346 + 347 + var topLevelComments []db.CommentWithBskyProfile 348 + for _, hydrated := range hydratedComments { 349 + if hydrated.ParentCommentUri == nil { 350 + topLevelComments = append(topLevelComments, hydrated) 351 + } else { 352 + parentURI := hydrated.ParentCommentUri.String() 353 + repliesMap[parentURI] = append(repliesMap[parentURI], hydrated) 354 + } 355 + } 356 + 357 + var feed []db.CommentFeedItem 358 + for _, topLevel := range topLevelComments { 359 + feedItem := db.CommentFeedItem{ 360 + CommentWithBskyProfile: topLevel, 361 + Replies: []db.CommentWithBskyProfile{}, 362 + } 363 + if replies, ok := repliesMap[topLevel.CommentAt().String()]; ok { 364 + feedItem.Replies = replies 365 + } 366 + feed = append(feed, feedItem) 367 + } 368 + 369 + return feed 370 + }
+112
internal/server/views/partials/comment.templ
··· 1 + package partials 2 + 3 + import ( 4 + "fmt" 5 + "yoten.app/internal/db" 6 + ) 7 + 8 + templ Reply(reply db.CommentWithBskyProfile) { 9 + {{ replyId := SanitiseHtmlId(fmt.Sprintf("reply-%s-%s", reply.Did, reply.Rkey)) }} 10 + <div id={ replyId } class="flex flex-col gap-3 pl-4 py-2 border-l-2 border-gray-200"> 11 + <div class="flex items-center gap-3"> 12 + if reply.BskyProfile.Avatar == "" { 13 + <div class="flex items-center justify-center w-10 h-10 rounded-full bg-primary"> 14 + <i class="w-7 h-7" data-lucide="user"></i> 15 + </div> 16 + } else { 17 + <img src={ reply.BskyProfile.Avatar } class="w-10 h-10 rounded-full"/> 18 + } 19 + <div> 20 + <div class="flex items-center gap-2"> 21 + <a href={ templ.URL(fmt.Sprintf("/@%s", reply.Did)) } class="font-semibold"> 22 + { reply.ProfileDisplayName } 23 + </a> 24 + <p class="pill pill-secondary px-2 py-0.5 h-fit items-center justify-center gap-1 w-fit flex"> 25 + <i class="w-3.5 h-3.5" data-lucide="star"></i> 26 + <span class="text-xs">{ reply.ProfileLevel }</span> 27 + </p> 28 + <span class="text-xs text-text-muted">{ reply.CreatedAt.Format("2006-01-02") }</span> 29 + </div> 30 + <p class="text-text-muted text-sm">&commat;{ reply.BskyProfile.Handle }</p> 31 + </div> 32 + </div> 33 + <p class="leading-relaxed"> 34 + { reply.Body } 35 + </p> 36 + </div> 37 + } 38 + 39 + templ Comment(params CommentProps) { 40 + {{ elementId := SanitiseHtmlId(fmt.Sprintf("comment-%s-%s", params.Comment.Did, params.Comment.Rkey)) }} 41 + <div id={ elementId } class="flex flex-col gap-3" x-init="lucide.createIcons()"> 42 + <div class="flex items-center justify-between"> 43 + <div class="flex items-center gap-3"> 44 + if params.Comment.BskyProfile.Avatar == "" { 45 + <div class="flex items-center justify-center w-10 h-10 rounded-full bg-primary"> 46 + <i class="w-7 h-7" data-lucide="user"></i> 47 + </div> 48 + } else { 49 + <img src={ params.Comment.BskyProfile.Avatar } class="w-10 h-10 rounded-full"/> 50 + } 51 + <div> 52 + <div class="flex items-center gap-2"> 53 + <a href={ templ.URL(fmt.Sprintf("/@%s", params.Comment.Did)) } class="font-semibold"> 54 + { params.Comment.ProfileDisplayName } 55 + </a> 56 + <p class="pill pill-secondary px-2 py-0.5 h-fit items-center justify-center gap-1 w-fit flex"> 57 + <i class="w-3.5 h-3.5" data-lucide="star"></i> 58 + <span class="text-xs">{ params.Comment.ProfileLevel }</span> 59 + </p> 60 + <span class="text-xs text-text-muted">{ params.Comment.CreatedAt.Format("2006-01-02") }</span> 61 + </div> 62 + <p class="text-text-muted text-sm">&commat;{ params.Comment.BskyProfile.Handle }</p> 63 + </div> 64 + </div> 65 + if params.DoesOwn { 66 + <details class="relative inline-block text-left"> 67 + <summary class="cursor-pointer list-none"> 68 + <div class="btn btn-muted p-2"> 69 + <i class="w-4 h-4 flex-shrink-0" data-lucide="ellipsis"></i> 70 + </div> 71 + </summary> 72 + <div class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-32 bg-bg-light border border-bg-dark"> 73 + <button 74 + class="btn hover:bg-bg group justify-start px-2" 75 + type="button" 76 + id="edit-button" 77 + hx-disabled-elt="#delete-button,#edit-button" 78 + hx-target={ "#" + elementId } 79 + hx-swap="outerHTML" 80 + hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", params.Comment.Rkey)) } 81 + > 82 + <i class="w-4 h-4" data-lucide="square-pen"></i> 83 + <span class="text-sm">Edit</span> 84 + <i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i> 85 + </button> 86 + <button 87 + class="btn text-red-600 hover:bg-bg group justify-start px-2" 88 + type="button" 89 + id="delete-button" 90 + hx-disabled-elt="#delete-button,#edit-button" 91 + hx-target={ "#" + elementId } 92 + hx-swap="outerHTML" 93 + hx-delete={ templ.URL(fmt.Sprintf("/comment/%s", params.Comment.Rkey)) } 94 + > 95 + <i class="w-4 h-4" data-lucide="trash-2"></i> 96 + <span class="text-sm">Delete</span> 97 + <i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i> 98 + </button> 99 + </div> 100 + </details> 101 + } 102 + </div> 103 + <p class="leading-relaxed break-words"> 104 + { params.Comment.Body } 105 + </p> 106 + <div class="flex flex-col mt-2"> 107 + for _, reply := range params.Comment.Replies { 108 + @Reply(reply) 109 + } 110 + </div> 111 + </div> 112 + }
+1 -1
internal/server/views/partials/activity.templ
··· 30 30 class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group" 31 31 type="button" 32 32 id="delete-button" 33 - hx-disabled-elt="delete-button,#edit-button" 33 + hx-disabled-elt="#delete-button,#edit-button" 34 34 hx-delete={ templ.URL(fmt.Sprintf("/activity/%s", params.Activity.Rkey)) } 35 35 > 36 36 <i class="w-4 h-4" data-lucide="trash-2"></i>
+1 -1
internal/server/views/partials/resource.templ
··· 75 75 class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group" 76 76 type="button" 77 77 id="delete-button" 78 - hx-disabled-elt="delete-button,#edit-button" 78 + hx-disabled-elt="#delete-button,#edit-button" 79 79 hx-delete={ templ.URL(fmt.Sprintf("/resource/%s", params.Resource.Rkey)) } 80 80 > 81 81 <i class="w-4 h-4" data-lucide="trash-2"></i>
+3 -3
internal/server/oauth/handler/handler.go
··· 263 263 } 264 264 }() 265 265 266 - error := r.FormValue("error") 266 + callbackErr := r.FormValue("error") 267 267 errorDescription := r.FormValue("error_description") 268 - if error != "" || errorDescription != "" { 269 - log.Printf("oauth callback error: %s, %s", error, errorDescription) 268 + if callbackErr != "" || errorDescription != "" { 269 + log.Printf("oauth callback error: %s, %s", callbackErr, errorDescription) 270 270 htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.") 271 271 return 272 272 }
+44
internal/server/views/partials/edit-comment.templ
··· 1 + package partials 2 + 3 + import "fmt" 4 + 5 + templ EditComment(params EditCommentProps) { 6 + {{ elementId := SanitiseHtmlId(fmt.Sprintf("comment-%s-%s", params.Comment.Did, params.Comment.Rkey)) }} 7 + <div id={ elementId } class="flex flex-col gap-3" x-init="lucide.createIcons()"> 8 + <form 9 + hx-post={ templ.SafeURL("/comment/edit/" + params.Comment.Rkey) } 10 + hx-target={ "#" + elementId } 11 + hx-swap="outerHTML" 12 + hx-disabled-elt="#update-comment-button,#cancel-comment-button" 13 + x-data="{ text: '' }" 14 + x-init="text = $el.querySelector('textarea').value" 15 + > 16 + <div class="mt-2"> 17 + <textarea 18 + x-model="text" 19 + id="comment" 20 + name="comment" 21 + placeholder="Share your thoughts about this study session..." 22 + class="input w-full" 23 + maxLength="256" 24 + rows="3" 25 + > 26 + { params.Comment.Body } 27 + </textarea> 28 + <div class="flex justify-between mt-2"> 29 + <div class="text-sm text-text-muted"> 30 + <span x-text="text.length"></span> / 256 31 + </div> 32 + <div class="flex items-center gap-2"> 33 + <button type="submit" id="update-comment-button" class="btn btn-primary w-fit"> 34 + Update Comment 35 + </button> 36 + <button id="cancel-comment-button" class="btn btn-muted w-fit"> 37 + Cancel 38 + </button> 39 + </div> 40 + </div> 41 + </div> 42 + </form> 43 + </div> 44 + }
+10
internal/db/utils.go
··· 1 1 package db 2 2 3 3 import ( 4 + "strings" 5 + 4 6 "golang.org/x/text/cases" 5 7 "golang.org/x/text/language" 6 8 ) ··· 14 16 titleStr := caser.String(str) 15 17 return titleStr 16 18 } 19 + 20 + // Generates `?, ?, ?` for SQL IN clauses. 21 + func GetPlaceholders(count int) string { 22 + if count < 1 { 23 + return "" 24 + } 25 + return strings.Repeat("?,", count-1) + "?" 26 + }
+31
internal/server/views/partials/comment-feed.templ
··· 1 + package partials 2 + 3 + import "fmt" 4 + 5 + templ CommentFeed(params CommentFeedProps) { 6 + for _, comment := range params.Feed { 7 + {{ 8 + isSelf := false 9 + if params.User != nil { 10 + isSelf = params.User.Did == comment.Did 11 + } 12 + }} 13 + @Comment(CommentProps{ 14 + Comment: comment, 15 + DoesOwn: isSelf, 16 + }) 17 + } 18 + if params.NextPage > 0 { 19 + <div 20 + id="next-feed-segment" 21 + hx-get={ templ.SafeURL(fmt.Sprintf("/%s/session/%s/feed?page=%d", params.StudySessionDid, 22 + params.StudySessionRkey, params.NextPage)) } 23 + hx-trigger="revealed" 24 + hx-swap="outerHTML" 25 + > 26 + <div class="flex justify-center py-4"> 27 + <i data-lucide="loader-circle" class="w-6 h-6 animate-spin text-text-muted"></i> 28 + </div> 29 + </div> 30 + } 31 + }
+1 -1
internal/server/views/partials/reactions.templ
··· 40 40 } 41 41 </div> 42 42 } 43 - <div class="inline-block text-left w-fit"> 43 + <div class="inline-block text-left w-fit" title="reactions"> 44 44 <button @click="open = !open" id="reaction-button" type="button" class="btn rounded-full hover:bg-bg py-1 px-2"> 45 45 <i class="w-5 h-5" data-lucide="smile-plus"></i> 46 46 </button>
+7
internal/db/notification.go
··· 12 12 const ( 13 13 NotificationTypeFollow NotificationType = "follow" 14 14 NotificationTypeReaction NotificationType = "reaction" 15 + NotificationTypeComment NotificationType = "comment" 15 16 ) 16 17 17 18 type NotificationState string ··· 31 32 RecipientDid string 32 33 ActorDid string 33 34 SubjectRkey string 35 + SubjectDid string 34 36 State NotificationState 35 37 Type NotificationType 36 38 CreatedAt time.Time ··· 105 107 return nil, fmt.Errorf("failed to parse at-uri: %w", err) 106 108 } 107 109 notification.SubjectRkey = subjectUri.RecordKey().String() 110 + subjectDid, err := subjectUri.Authority().AsDID() 111 + if err != nil { 112 + return nil, fmt.Errorf("failed to identify subject did: %w", err) 113 + } 114 + notification.SubjectDid = subjectDid.String() 108 115 109 116 notifications = append(notifications, notification) 110 117 }
+2 -2
internal/server/views/friends.templ
··· 11 11 <div class="container mx-auto max-w-2xl px-4 py-8"> 12 12 <div class="flex items-center justify-between mb-8"> 13 13 <div> 14 - <h1 class="text-3xl font-bold text-gray-900">Friends</h1> 15 - <p class="text-gray-600 mt-1">Connect with fellow language learners</p> 14 + <h1 class="text-3xl font-bold">Friends</h1> 15 + <p class="mt-1">Connect with fellow language learners</p> 16 16 </div> 17 17 </div> 18 18 <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
+17 -2
internal/server/views/partials/notification.templ
··· 13 13 <h1 class="font-semibold">New Follower</h1> 14 14 <p class="text-sm mt-1"> 15 15 <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDid) }> 16 - { params.Notification.ActorDid } 16 + &commat;{ params.Notification.ActorBskyHandle } 17 17 </a> started following you 18 18 </p> 19 19 </div> ··· 23 23 <p class="text-sm mt-1"> 24 24 <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDid) }> 25 25 &commat;{ params.Notification.ActorBskyHandle } 26 - </a> reacted to your study session 26 + </a> reacted to your 27 + <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.SubjectDid + "/session/" + params.Notification.SubjectRkey) }> 28 + study session 29 + </a> 30 + </p> 31 + </div> 32 + case db.NotificationTypeComment: 33 + <div> 34 + <h1 class="font-semibold">New Comment</h1> 35 + <p class="text-sm mt-1"> 36 + <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDid) }> 37 + &commat;{ params.Notification.ActorBskyHandle } 38 + </a> commented on your 39 + <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.SubjectDid + "/session/" + params.Notification.SubjectRkey) }> 40 + study session 41 + </a> 27 42 </p> 28 43 </div> 29 44 default:

History

3 rounds 0 comments
sign up or login to add to the discussion
8 commits
expand
feat: add study session page
feat: add comment lexicon and db struct
feat: add / delete comments
fix: hx id elements missing #
refactor: renames + deletion of unused code
feat(comments): allow user to edit comment
feat: fetch study session comments
feat: add comment notifications
expand 0 comments
pull request successfully merged
9 commits
expand
feat: add study session page
fix: follow status
feat: add comment lexicon and db struct
feat: add / delete comments
fix: hx id elements missing #
refactor: renames + deletion of unused code
feat(comments): allow user to edit comment
feat: fetch study session comments
feat: add comment notifications
expand 0 comments
10 commits
expand
feat: add study session page
fix: add @ to profile handle
fix: follow status
feat: add comment lexicon and db struct
feat: add / delete comments
fix: hx id elements missing #
refactor: renames + deletion of unused code
feat(comments): allow user to edit comment
feat: fetch study session comments
feat: add comment notifications
expand 0 comments