feat: add study session comments #1

merged
opened by brookjeynes.dev targeting master from bj/2025-09-05/feat/study-session-comments
+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() {
+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"
+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,
+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 }
+2
internal/server/handlers/router.go
··· 89 89 r.Route("/comment", func(r chi.Router) { 90 90 r.Use(middleware.AuthMiddleware(h.Oauth)) 91 91 r.Post("/new", h.HandleNewComment) 92 + r.Get("/edit/{rkey}", h.HandleEditCommentPage) 93 + r.Post("/edit/{rkey}", h.HandleEditCommentPage) 92 94 r.Delete("/{rkey}", h.HandleDeleteComment) 93 95 }) 94 96
+128
internal/db/comment.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 + "sort" 6 7 "time" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/syntax" ··· 22 23 BskyProfile types.BskyProfile 23 24 } 24 25 26 + type CommentWithLocalProfile struct { 27 + Comment 28 + ProfileLevel int 29 + ProfileDisplayName string 30 + } 31 + 25 32 type Comment struct { 26 33 ID int 27 34 Did string ··· 119 126 120 127 return comment, nil 121 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 + }
+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 + }
+36 -35
internal/server/views/partials/comment.templ
··· 62 62 <p class="text-text-muted text-sm">&commat;{ params.Comment.BskyProfile.Handle }</p> 63 63 </div> 64 64 </div> 65 - // TODO: Only show on comments you own 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> 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> 70 99 </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="text-base cursor-pointer flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group" 75 - type="button" 76 - id="edit-button" 77 - hx-disabled-elt="#delete-button,#edit-button" 78 - hx-target={ "#" + elementId } 79 - hx-swap="outerSelf" 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 - Edit 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="text-base text-red-600 cursor-pointer flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group" 88 - type="button" 89 - id="delete-button" 90 - hx-disabled-elt="#delete-button,#edit-button" 91 - hx-target={ "#" + elementId } 92 - hx-swap="outerSelf" 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 - Delete 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> 100 + </details> 101 + } 101 102 </div> 102 103 <p class="leading-relaxed break-words"> 103 104 { params.Comment.Body }
+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>
+1 -1
internal/server/views/partials/study-session.templ
··· 156 156 SessionRkey: params.StudySession.Rkey, 157 157 ReactionEvents: params.StudySession.Reactions, 158 158 }) 159 - <a href={ studySessionUrl }> 159 + <a href={ studySessionUrl } title="comments"> 160 160 <i class="w-5 h-5" data-lucide="message-square-share"></i> 161 161 </a> 162 162 </div>
+3 -1
internal/server/views/study-session.templ
··· 15 15 StudySession: params.StudySession, 16 16 }) 17 17 @partials.Discussion(partials.DiscussionProps{ 18 - StudySessionUri: params.StudySession.StudySessionAt().String(), 18 + StudySessionDid: params.StudySession.Did, 19 + StudySessionRkey: params.StudySession.Rkey, 20 + StudySessionUri: params.StudySession.StudySessionAt().String(), 19 21 }) 20 22 </div> 21 23 }
+11 -1
internal/consumer/ingester.go
··· 582 582 if err != nil { 583 583 return fmt.Errorf("failed to parse study session at-uri: %w", err) 584 584 } 585 + subjectDid, err := subjectUri.Authority().AsDID() 586 + if err != nil { 587 + return fmt.Errorf("failed to identify subject did: %w", err) 588 + } 585 589 586 590 body := record.Body 587 - if len(body) == 0 { 591 + if len(strings.TrimSpace(body)) == 0 { 588 592 return fmt.Errorf("invalid body: length cannot be 0") 589 593 } 590 594 ··· 619 623 tx.Rollback() 620 624 return fmt.Errorf("failed to upsert comment record: %w", err) 621 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 + 622 632 return tx.Commit() 623 633 case models.CommitOperationDelete: 624 634 log.Println("deleting comment from pds request")
+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 }
+1 -1
internal/server/handlers/comment.go
··· 95 95 if !h.Config.Core.Dev { 96 96 event := posthog.Capture{ 97 97 DistinctId: user.Did, 98 - Event: ph.CommentRecordDeletedEvent, 98 + Event: ph.CommentRecordCreatedEvent, 99 99 Properties: posthog.NewProperties(). 100 100 Set("is_reply", newComment.ParentCommentUri != nil). 101 101 Set("character_count", len(newComment.Body)).
+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: