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 84 85 86 87 88 89 90 ··· 122 123 124 125 - 126 - 127 - 128 - 129 r.Route("/{user}", func(r chi.Router) { 130 r.Get("/", h.HandleProfilePage) 131 r.Get("/feed", h.HandleProfileFeed) 132 }) 133 }) 134
··· 83 84 85 86 + r.Delete("/{rkey}", h.HandleDeleteResource) 87 + }) 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 + }) 96 97 + r.Route("/activity", func(r chi.Router) { 98 + r.Use(middleware.AuthMiddleware(h.Oauth)) 99 + r.Get("/new", h.HandleNewActivityPage) 100 101 102 ··· 134 135 136 137 r.Route("/{user}", func(r chi.Router) { 138 r.Get("/", h.HandleProfilePage) 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 + }) 144 }) 145 }) 146
+123
internal/server/handlers/study-session.go
··· 9 "time" 10 11 comatproto "github.com/bluesky-social/indigo/api/atproto" 12 lexutil "github.com/bluesky-social/indigo/lex/util" 13 "github.com/go-chi/chi/v5" 14 "github.com/posthog/posthog-go" ··· 629 630 631 return nil 632 }
··· 9 "time" 10 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" 14 lexutil "github.com/bluesky-social/indigo/lex/util" 15 "github.com/go-chi/chi/v5" 16 "github.com/posthog/posthog-go" ··· 631 632 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) 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 Feed []db.NotificationWithBskyHandle 219 NextPage int 220 }
··· 218 Feed []db.NotificationWithBskyHandle 219 NextPage int 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 53 54 55 56 57 ··· 60 61 62 63 - 64 - 65 - 66 - 67 - 68 - 69 - 70 templ StudySession(params StudySessionProps) { 71 {{ elementId := SanitiseHtmlId(fmt.Sprintf("study-session-%s-%s", params.StudySession.Did, params.StudySession.Rkey)) }} 72 <div id={ elementId } class="card relative" x-data="{ open: false }" :class="{ 'z-20': open }"> 73 <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> 74 <div class="flex items-center justify-between"> ··· 148 } 149 <hr class="border-gray"/> 150 <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 - }) 157 <div class="flex flex-col sm:flex-row sm:items-center gap-2 text-sm text-text-muted"> 158 <div class="flex items-center gap-1"> 159 <i class="w-4 h-4" data-lucide="clock"></i>
··· 52 53 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> 62 63 64 ··· 67 68 69 70 templ StudySession(params StudySessionProps) { 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) }} 73 <div id={ elementId } class="card relative" x-data="{ open: false }" :class="{ 'z-20': open }"> 74 <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> 75 <div class="flex items-center justify-between"> ··· 149 } 150 <hr class="border-gray"/> 151 <div class="flex flex-col sm:flex-row justify-between sm:items-center gap-4"> 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> 163 <div class="flex flex-col sm:flex-row sm:items-center gap-2 text-sm text-text-muted"> 164 <div class="flex items-center gap-1"> 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 // The current logged in user. 124 User *types.User 125 Notifications []db.NotificationWithBskyHandle 126 - ActiveTab string 127 }
··· 123 // The current logged in user. 124 User *types.User 125 Notifications []db.NotificationWithBskyHandle 126 + } 127 + 128 + type StudySessionPageParams struct { 129 + // The current logged in user. 130 + User *types.User 131 + StudySession db.StudySessionFeedItem 132 + DoesOwn bool 133 }
+376
api/yoten/cbor_gen.go
··· 648 649 return nil 650 } 651 func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 652 if t == nil { 653 _, err := w.Write(cbg.CborNull)
··· 648 649 return nil 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 + } 1027 func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 1028 if t == nil { 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 yoten.ActivityDef{}, 29 yoten.GraphFollow{}, 30 yoten.FeedReaction{}, 31 } 32 33 for name, rt := range AllLexTypes() {
··· 28 yoten.ActivityDef{}, 29 yoten.GraphFollow{}, 30 yoten.FeedReaction{}, 31 + yoten.FeedComment{}, 32 + yoten.FeedComment_Reply{}, 33 } 34 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 return nil, fmt.Errorf("failed to open db: %w", err) 30 } 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; 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 - ); 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 - ); 66 67 - create table if not exists profiles ( 68 - -- id 69 - id integer primary key autoincrement, 70 - did text not null, 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')), 79 80 - -- constraints 81 - unique(did) 82 - ); 83 84 - create table if not exists profile_languages ( 85 - -- id 86 - did text not null, 87 88 - -- data 89 - language_code text not null, 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 - ); 96 97 - create table if not exists study_sessions ( 98 - -- id 99 - did text not null, 100 - rkey text not null, 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')), 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 - ); 118 119 - create table if not exists categories ( 120 - id integer primary key, -- Matches StudySessionCategory iota 121 - name text not null unique 122 - ); 123 124 - create table if not exists activities ( 125 - id integer primary key autoincrement, 126 127 - did text, 128 - rkey text, 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')), 134 135 - foreign key (did) references profiles(did) on delete cascade, 136 - unique (did, rkey) 137 - ); 138 139 - create table if not exists activity_categories ( 140 - activity_id integer not null, 141 - category_id integer not null, 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 - ); 147 148 - create table if not exists follows ( 149 - user_did text not null, 150 - subject_did text not null, 151 152 - rkey text not null, 153 - followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 154 155 - primary key (user_did, subject_did), 156 - check (user_did <> subject_did) 157 - ); 158 159 - create table if not exists xp_events ( 160 - id integer primary key autoincrement, 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')), 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 - ); 171 172 - create table if not exists study_session_reactions ( 173 - id integer primary key autoincrement, 174 175 - did text not null, 176 - rkey text not null, 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')), 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 - ); 187 188 - create table if not exists notifications ( 189 - id integer primary key autoincrement, 190 191 - recipient_did text not null, 192 - actor_did text not null, 193 - subject_uri text not null, 194 195 - state text not null default 'unread' check(state in ('unread', 'read')), 196 - type text not null check(type in ('follow', 'reaction')), 197 198 - created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 199 200 - foreign key (recipient_did) references profiles(did) on delete cascade, 201 - foreign key (actor_did) references profiles(did) on delete cascade 202 - ); 203 204 - create table if not exists resources ( 205 - id integer primary key autoincrement, 206 207 - did text not null, 208 - rkey text not null, 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')), 217 218 - foreign key (did) references profiles (did) on delete cascade, 219 - unique (did, rkey) 220 - ); 221 222 - create table if not exists _jetstream ( 223 - id integer primary key autoincrement, 224 - last_time_us integer not null 225 - ); 226 227 - create table if not exists migrations ( 228 - id integer primary key autoincrement, 229 - name text unique 230 - ); 231 - `) 232 if err != nil { 233 return nil, fmt.Errorf("failed to execute db create statement: %w", err) 234 }
··· 29 return nil, fmt.Errorf("failed to open db: %w", err) 30 } 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; 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 + ); 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 + ); 66 67 + create table if not exists profiles ( 68 + -- id 69 + id integer primary key autoincrement, 70 + did text not null, 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')), 79 80 + -- constraints 81 + unique(did) 82 + ); 83 84 + create table if not exists profile_languages ( 85 + -- id 86 + did text not null, 87 88 + -- data 89 + language_code text not null, 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 + ); 96 97 + create table if not exists study_sessions ( 98 + -- id 99 + did text not null, 100 + rkey text not null, 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')), 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 + ); 118 119 + create table if not exists categories ( 120 + id integer primary key, -- Matches StudySessionCategory iota 121 + name text not null unique 122 + ); 123 124 + create table if not exists activities ( 125 + id integer primary key autoincrement, 126 127 + did text, 128 + rkey text, 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')), 134 135 + foreign key (did) references profiles(did) on delete cascade, 136 + unique (did, rkey) 137 + ); 138 139 + create table if not exists activity_categories ( 140 + activity_id integer not null, 141 + category_id integer not null, 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 + ); 147 148 + create table if not exists follows ( 149 + user_did text not null, 150 + subject_did text not null, 151 152 + rkey text not null, 153 + followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 154 155 + primary key (user_did, subject_did), 156 + check (user_did <> subject_did) 157 + ); 158 159 + create table if not exists xp_events ( 160 + id integer primary key autoincrement, 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')), 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 + ); 171 172 + create table if not exists study_session_reactions ( 173 + id integer primary key autoincrement, 174 175 + did text not null, 176 + rkey text not null, 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')), 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 + ); 187 188 + create table if not exists notifications ( 189 + id integer primary key autoincrement, 190 191 + recipient_did text not null, 192 + actor_did text not null, 193 + subject_uri text not null, 194 195 + state text not null default 'unread' check(state in ('unread', 'read')), 196 + type text not null check(type in ('follow', 'reaction', 'comment')), 197 198 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 199 200 + foreign key (recipient_did) references profiles(did) on delete cascade, 201 + foreign key (actor_did) references profiles(did) on delete cascade 202 + ); 203 204 + create table if not exists resources ( 205 + id integer primary key autoincrement, 206 207 + did text not null, 208 + rkey text not null, 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')), 217 218 + foreign key (did) references profiles (did) on delete cascade, 219 + unique (did, rkey) 220 + ); 221 222 + create table if not exists comments ( 223 + id integer primary key autoincrement, 224 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 + `) 248 if err != nil { 249 return nil, fmt.Errorf("failed to execute db create statement: %w", err) 250 }
+1
internal/server/views/new-study-session.templ
··· 500 stopAndLog() { 501 this.pause(); 502 const form = this.$root; 503 504 let durationSeconds = form.querySelector('input[name="duration_seconds"]'); 505 if (!durationSeconds) {
··· 500 stopAndLog() { 501 this.pause(); 502 const form = this.$root; 503 + this.timerState = 'stopped'; 504 505 let durationSeconds = form.querySelector('input[name="duration_seconds"]'); 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 ReactionRecordCreatedEvent string = "reaction-record-created" 20 ReactionRecordDeletedEvent string = "reaction-record-deleted" 21 22 ActivityDefRecordFirstCreated string = "activity-def-record-first-created" 23 ActivityDefRecordCreatedEvent string = "activity-def-record-created" 24 ActivityDefRecordDeletedEvent string = "activity-def-record-deleted"
··· 19 ReactionRecordCreatedEvent string = "reaction-record-created" 20 ReactionRecordDeletedEvent string = "reaction-record-deleted" 21 22 + CommentRecordCreatedEvent string = "comment-record-created" 23 + CommentRecordDeletedEvent string = "comment-record-deleted" 24 + CommentRecordEditedEvent string = "comment-record-edited" 25 + 26 ActivityDefRecordFirstCreated string = "activity-def-record-first-created" 27 ActivityDefRecordCreatedEvent string = "activity-def-record-created" 28 ActivityDefRecordDeletedEvent string = "activity-def-record-deleted"
+91 -12
internal/consumer/ingester.go
··· 50 err = i.ingestFollow(e) 51 case yoten.FeedReactionNSID: 52 err = i.ingestReaction(e) 53 } 54 } 55 if err != nil { ··· 117 118 119 120 121 122 123 ··· 327 328 329 330 331 332 333 ··· 363 364 365 366 367 368 369 ··· 408 409 410 411 412 413 414 ··· 490 491 492 493 494 495 496 ··· 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 - 550 - 551 - 552 - 553 - 554 - 555 - 556 - 557 - 558 - 559 - 560 - 561 562 return nil 563 }
··· 50 err = i.ingestFollow(e) 51 case yoten.FeedReactionNSID: 52 err = i.ingestReaction(e) 53 + case yoten.FeedCommentNSID: 54 + err = i.ingestComment(e) 55 } 56 } 57 if err != nil { ··· 119 120 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 + } 126 127 + tx, err := ddb.Begin() 128 129 130 ··· 334 335 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 + } 341 342 + tx, err := ddb.Begin() 343 344 345 ··· 375 376 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 + } 382 383 + tx, err := ddb.Begin() 384 385 386 ··· 425 426 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 + } 432 433 + tx, err := ddb.Begin() 434 435 436 ··· 512 513 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 + } 519 520 + tx, err := ddb.Begin() 521 522 523 ··· 561 562 563 564 + return nil 565 + } 566 567 + func (i *Ingester) ingestComment(e *models.Event) error { 568 + var err error 569 + did := e.Did 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 + } 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 + } 589 590 + body := record.Body 591 + if len(strings.TrimSpace(body)) == 0 { 592 + return fmt.Errorf("invalid body: length cannot be 0") 593 + } 594 595 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 596 + if err != nil { 597 + return fmt.Errorf("invalid createdAt format: %w", err) 598 + } 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 + } 604 605 + tx, err := ddb.Begin() 606 + if err != nil { 607 + return fmt.Errorf("failed to start transaction: %w", err) 608 + } 609 610 + // TODO: Parse reply 611 612 + comment := db.Comment{ 613 + Did: did, 614 + Rkey: e.Commit.RKey, 615 + StudySessionUri: subjectUri, 616 + Body: body, 617 + CreatedAt: createdAt, 618 + } 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 + } 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 + } 631 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 + } 640 641 return nil 642 }
+3 -1
internal/server/app.go
··· 62 jc, err := consumer.NewJetstreamClient( 63 config.Jetstream.Endpoint, 64 "yoten", 65 - []string{yoten.ActorProfileNSID, 66 yoten.FeedSessionNSID, 67 yoten.FeedResourceNSID, 68 yoten.FeedReactionNSID, 69 yoten.ActivityDefNSID, 70 yoten.GraphFollowNSID,
··· 62 jc, err := consumer.NewJetstreamClient( 63 config.Jetstream.Endpoint, 64 "yoten", 65 + []string{ 66 + yoten.ActorProfileNSID, 67 yoten.FeedSessionNSID, 68 yoten.FeedResourceNSID, 69 + yoten.FeedCommentNSID, 70 yoten.FeedReactionNSID, 71 yoten.ActivityDefNSID, 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 class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group" 31 type="button" 32 id="delete-button" 33 - hx-disabled-elt="delete-button,#edit-button" 34 hx-delete={ templ.URL(fmt.Sprintf("/activity/%s", params.Activity.Rkey)) } 35 > 36 <i class="w-4 h-4" data-lucide="trash-2"></i>
··· 30 class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group" 31 type="button" 32 id="delete-button" 33 + hx-disabled-elt="#delete-button,#edit-button" 34 hx-delete={ templ.URL(fmt.Sprintf("/activity/%s", params.Activity.Rkey)) } 35 > 36 <i class="w-4 h-4" data-lucide="trash-2"></i>
+1 -1
internal/server/views/partials/resource.templ
··· 75 class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group" 76 type="button" 77 id="delete-button" 78 - hx-disabled-elt="delete-button,#edit-button" 79 hx-delete={ templ.URL(fmt.Sprintf("/resource/%s", params.Resource.Rkey)) } 80 > 81 <i class="w-4 h-4" data-lucide="trash-2"></i>
··· 75 class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group" 76 type="button" 77 id="delete-button" 78 + hx-disabled-elt="#delete-button,#edit-button" 79 hx-delete={ templ.URL(fmt.Sprintf("/resource/%s", params.Resource.Rkey)) } 80 > 81 <i class="w-4 h-4" data-lucide="trash-2"></i>
+3 -3
internal/server/oauth/handler/handler.go
··· 263 } 264 }() 265 266 - error := r.FormValue("error") 267 errorDescription := r.FormValue("error_description") 268 - if error != "" || errorDescription != "" { 269 - log.Printf("oauth callback error: %s, %s", error, errorDescription) 270 htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.") 271 return 272 }
··· 263 } 264 }() 265 266 + callbackErr := r.FormValue("error") 267 errorDescription := r.FormValue("error_description") 268 + if callbackErr != "" || errorDescription != "" { 269 + log.Printf("oauth callback error: %s, %s", callbackErr, errorDescription) 270 htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.") 271 return 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 package db 2 3 import ( 4 "golang.org/x/text/cases" 5 "golang.org/x/text/language" 6 ) ··· 14 titleStr := caser.String(str) 15 return titleStr 16 }
··· 1 package db 2 3 import ( 4 + "strings" 5 + 6 "golang.org/x/text/cases" 7 "golang.org/x/text/language" 8 ) ··· 16 titleStr := caser.String(str) 17 return titleStr 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 } 41 </div> 42 } 43 - <div class="inline-block text-left w-fit"> 44 <button @click="open = !open" id="reaction-button" type="button" class="btn rounded-full hover:bg-bg py-1 px-2"> 45 <i class="w-5 h-5" data-lucide="smile-plus"></i> 46 </button>
··· 40 } 41 </div> 42 } 43 + <div class="inline-block text-left w-fit" title="reactions"> 44 <button @click="open = !open" id="reaction-button" type="button" class="btn rounded-full hover:bg-bg py-1 px-2"> 45 <i class="w-5 h-5" data-lucide="smile-plus"></i> 46 </button>
+7
internal/db/notification.go
··· 12 const ( 13 NotificationTypeFollow NotificationType = "follow" 14 NotificationTypeReaction NotificationType = "reaction" 15 ) 16 17 type NotificationState string ··· 31 RecipientDid string 32 ActorDid string 33 SubjectRkey string 34 State NotificationState 35 Type NotificationType 36 CreatedAt time.Time ··· 105 return nil, fmt.Errorf("failed to parse at-uri: %w", err) 106 } 107 notification.SubjectRkey = subjectUri.RecordKey().String() 108 109 notifications = append(notifications, notification) 110 }
··· 12 const ( 13 NotificationTypeFollow NotificationType = "follow" 14 NotificationTypeReaction NotificationType = "reaction" 15 + NotificationTypeComment NotificationType = "comment" 16 ) 17 18 type NotificationState string ··· 32 RecipientDid string 33 ActorDid string 34 SubjectRkey string 35 + SubjectDid string 36 State NotificationState 37 Type NotificationType 38 CreatedAt time.Time ··· 107 return nil, fmt.Errorf("failed to parse at-uri: %w", err) 108 } 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() 115 116 notifications = append(notifications, notification) 117 }
+2 -2
internal/server/views/friends.templ
··· 11 <div class="container mx-auto max-w-2xl px-4 py-8"> 12 <div class="flex items-center justify-between mb-8"> 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> 16 </div> 17 </div> 18 <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
··· 11 <div class="container mx-auto max-w-2xl px-4 py-8"> 12 <div class="flex items-center justify-between mb-8"> 13 <div> 14 + <h1 class="text-3xl font-bold">Friends</h1> 15 + <p class="mt-1">Connect with fellow language learners</p> 16 </div> 17 </div> 18 <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
+17 -2
internal/server/views/partials/notification.templ
··· 13 <h1 class="font-semibold">New Follower</h1> 14 <p class="text-sm mt-1"> 15 <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDid) }> 16 - { params.Notification.ActorDid } 17 </a> started following you 18 </p> 19 </div> ··· 23 <p class="text-sm mt-1"> 24 <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDid) }> 25 &commat;{ params.Notification.ActorBskyHandle } 26 - </a> reacted to your study session 27 </p> 28 </div> 29 default:
··· 13 <h1 class="font-semibold">New Follower</h1> 14 <p class="text-sm mt-1"> 15 <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDid) }> 16 + &commat;{ params.Notification.ActorBskyHandle } 17 </a> started following you 18 </p> 19 </div> ··· 23 <p class="text-sm mt-1"> 24 <a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDid) }> 25 &commat;{ params.Notification.ActorBskyHandle } 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> 42 </p> 43 </div> 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