+16
-4
internal/server/handlers/router.go
+16
-4
internal/server/handlers/router.go
···
83
83
84
84
85
85
86
+
r.Delete("/{rkey}", h.HandleDeleteResource)
87
+
})
86
88
89
+
r.Route("/comment", func(r chi.Router) {
90
+
r.Use(middleware.AuthMiddleware(h.Oauth))
91
+
r.Post("/new", h.HandleNewComment)
92
+
r.Get("/edit/{rkey}", h.HandleEditCommentPage)
93
+
r.Post("/edit/{rkey}", h.HandleEditCommentPage)
94
+
r.Delete("/{rkey}", h.HandleDeleteComment)
95
+
})
87
96
97
+
r.Route("/activity", func(r chi.Router) {
98
+
r.Use(middleware.AuthMiddleware(h.Oauth))
99
+
r.Get("/new", h.HandleNewActivityPage)
88
100
89
101
90
102
···
122
134
123
135
124
136
125
-
126
-
127
-
128
-
129
137
r.Route("/{user}", func(r chi.Router) {
130
138
r.Get("/", h.HandleProfilePage)
131
139
r.Get("/feed", h.HandleProfileFeed)
140
+
r.Route("/session/{rkey}", func(r chi.Router) {
141
+
r.Get("/", h.HandleStudySessionPage)
142
+
r.Get("/feed", h.HandleStudySessionPageCommentFeed)
143
+
})
132
144
})
133
145
})
134
146
+123
internal/server/handlers/study-session.go
+123
internal/server/handlers/study-session.go
···
9
9
"time"
10
10
11
11
comatproto "github.com/bluesky-social/indigo/api/atproto"
12
+
"github.com/bluesky-social/indigo/atproto/identity"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
12
14
lexutil "github.com/bluesky-social/indigo/lex/util"
13
15
"github.com/go-chi/chi/v5"
14
16
"github.com/posthog/posthog-go"
···
629
631
630
632
631
633
return nil
634
+
}
635
+
636
+
func (h *Handler) HandleStudySessionPage(w http.ResponseWriter, r *http.Request) {
637
+
user, _ := bsky.GetUserWithBskyProfile(h.Oauth, r)
638
+
didOrHandle := chi.URLParam(r, "user")
639
+
if didOrHandle == "" {
640
+
http.Error(w, "Bad request", http.StatusBadRequest)
641
+
return
642
+
}
643
+
644
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
645
+
if !ok {
646
+
w.WriteHeader(http.StatusNotFound)
647
+
views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w)
648
+
return
649
+
}
650
+
rkey := chi.URLParam(r, "rkey")
651
+
652
+
studySession, err := db.GetStudySessionByRkey(h.Db, ident.DID.String(), rkey)
653
+
if err != nil {
654
+
log.Println("failed to retrieve study session:", err)
655
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve study session, try again later.")
656
+
return
657
+
}
658
+
659
+
bskyProfile, err := bsky.GetBskyProfile(ident.DID.String())
660
+
if err != nil {
661
+
log.Println("failed to retrieve bsky profile for study session:", err)
662
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve bsky profile, try again later.")
663
+
return
664
+
}
665
+
666
+
profile, err := db.GetProfile(h.Db, ident.DID.String())
667
+
if err != nil {
668
+
log.Println("failed to retrieve profile for study session:", err)
669
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve profile, try again later.")
670
+
return
671
+
}
672
+
673
+
isSelf := false
674
+
if user != nil {
675
+
isSelf = user.Did == studySession.Did
676
+
}
677
+
678
+
views.StudySessionPage(views.StudySessionPageParams{
679
+
User: user,
680
+
DoesOwn: isSelf,
681
+
StudySession: db.StudySessionFeedItem{
682
+
StudySession: *studySession,
683
+
ProfileDisplayName: profile.DisplayName,
684
+
ProfileLevel: profile.Level,
685
+
BskyProfile: bskyProfile,
686
+
},
687
+
}).Render(r.Context(), w)
688
+
}
689
+
690
+
func (h *Handler) HandleStudySessionPageCommentFeed(w http.ResponseWriter, r *http.Request) {
691
+
user, _ := bsky.GetUserWithBskyProfile(h.Oauth, r)
692
+
693
+
didOrHandle := chi.URLParam(r, "user")
694
+
if didOrHandle == "" {
695
+
http.Error(w, "Bad request", http.StatusBadRequest)
696
+
return
697
+
}
698
+
699
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
700
+
if !ok {
701
+
w.WriteHeader(http.StatusNotFound)
702
+
views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w)
703
+
return
704
+
}
705
+
rkey := chi.URLParam(r, "rkey")
706
+
studySessionUri := syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", ident.DID.String(), yoten.FeedSessionNSID, rkey))
707
+
708
+
pageStr := r.URL.Query().Get("page")
709
+
if pageStr == "" {
710
+
pageStr = "1"
711
+
}
712
+
page, err := strconv.ParseInt(pageStr, 10, 64)
713
+
if err != nil {
714
+
log.Println("failed to parse page value:", err)
715
+
page = 1
716
+
}
717
+
if page == 0 {
718
+
page = 1
719
+
}
720
+
721
+
const pageSize = 2
722
+
offset := (page - 1) * pageSize
723
+
724
+
commentFeed, err := db.GetCommentsForSession(h.Db, studySessionUri.String(), pageSize+1, int(offset))
725
+
if err != nil {
726
+
log.Println("failed to get comment feed:", err)
727
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to get comment feed, try again later.")
728
+
return
729
+
}
730
+
731
+
commentFeed = utils.Filter(commentFeed, func(cwlp db.CommentWithLocalProfile) bool {
732
+
return !cwlp.IsDeleted
733
+
})
734
+
735
+
nextPage := 0
736
+
if len(commentFeed) > pageSize {
737
+
nextPage = int(page + 1)
738
+
commentFeed = commentFeed[:pageSize]
739
+
}
740
+
741
+
populatedCommentFeed, err := h.BuildCommentFeed(commentFeed)
742
+
if err != nil {
743
+
log.Println("failed to populate comment feed:", err)
744
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to get comment feed, try again later.")
745
+
return
746
+
}
747
+
748
+
partials.CommentFeed(partials.CommentFeedProps{
749
+
Feed: populatedCommentFeed,
750
+
NextPage: nextPage,
751
+
User: user,
752
+
StudySessionDid: ident.DID.String(),
753
+
StudySessionRkey: rkey,
754
+
}).Render(r.Context(), w)
632
755
}
+52
internal/server/views/partials/discussion.templ
+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
+24
internal/server/views/partials/partials.go
···
218
218
Feed []db.NotificationWithBskyHandle
219
219
NextPage int
220
220
}
221
+
222
+
type DiscussionProps struct {
223
+
StudySessionUri string
224
+
StudySessionDid string
225
+
StudySessionRkey string
226
+
}
227
+
228
+
type CommentProps struct {
229
+
Comment db.CommentFeedItem
230
+
DoesOwn bool
231
+
}
232
+
233
+
type EditCommentProps struct {
234
+
Comment db.Comment
235
+
}
236
+
237
+
type CommentFeedProps struct {
238
+
// The current logged in user
239
+
User *types.User
240
+
Feed []db.CommentFeedItem
241
+
NextPage int
242
+
StudySessionDid string
243
+
StudySessionRkey string
244
+
}
+19
-13
internal/server/views/partials/study-session.templ
+19
-13
internal/server/views/partials/study-session.templ
···
52
52
53
53
54
54
55
+
class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
56
+
type="button"
57
+
id="delete-button"
58
+
hx-disabled-elt="#delete-button,#edit-button"
59
+
hx-delete={ templ.URL(fmt.Sprintf("/session/%s", params.StudySession.Rkey)) }
60
+
>
61
+
<i class="w-4 h-4" data-lucide="trash-2"></i>
55
62
56
63
57
64
···
60
67
61
68
62
69
63
-
64
-
65
-
66
-
67
-
68
-
69
-
70
70
templ StudySession(params StudySessionProps) {
71
71
{{ elementId := SanitiseHtmlId(fmt.Sprintf("study-session-%s-%s", params.StudySession.Did, params.StudySession.Rkey)) }}
72
+
{{ studySessionUrl := templ.SafeURL("/" + params.StudySession.Did + "/session/" + params.StudySession.Rkey) }}
72
73
<div id={ elementId } class="card relative" x-data="{ open: false }" :class="{ 'z-20': open }">
73
74
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
74
75
<div class="flex items-center justify-between">
···
148
149
}
149
150
<hr class="border-gray"/>
150
151
<div class="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
151
-
@NewReactions(NewReactionsProps{
152
-
User: params.User,
153
-
SessionDid: params.StudySession.Did,
154
-
SessionRkey: params.StudySession.Rkey,
155
-
ReactionEvents: params.StudySession.Reactions,
156
-
})
152
+
<div class="flex items-center gap-1">
153
+
@NewReactions(NewReactionsProps{
154
+
User: params.User,
155
+
SessionDid: params.StudySession.Did,
156
+
SessionRkey: params.StudySession.Rkey,
157
+
ReactionEvents: params.StudySession.Reactions,
158
+
})
159
+
<a href={ studySessionUrl } title="comments">
160
+
<i class="w-5 h-5" data-lucide="message-square-share"></i>
161
+
</a>
162
+
</div>
157
163
<div class="flex flex-col sm:flex-row sm:items-center gap-2 text-sm text-text-muted">
158
164
<div class="flex items-center gap-1">
159
165
<i class="w-4 h-4" data-lucide="clock"></i>
+24
internal/server/views/study-session.templ
+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
+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
+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
+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
+2
cmd/gen.go
+249
internal/db/comment.go
+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
+177
-161
internal/db/db.go
···
29
29
return nil, fmt.Errorf("failed to open db: %w", err)
30
30
}
31
31
_, err = db.Exec(`
32
-
pragma journal_mode = WAL;
33
-
pragma synchronous = normal;
34
-
pragma foreign_keys = on;
35
-
pragma temp_store = memory;
36
-
pragma mmap_size = 30000000000;
37
-
pragma page_size = 32768;
38
-
pragma auto_vacuum = incremental;
39
-
pragma busy_timeout = 5000;
32
+
pragma journal_mode = WAL;
33
+
pragma synchronous = normal;
34
+
pragma foreign_keys = on;
35
+
pragma temp_store = memory;
36
+
pragma mmap_size = 30000000000;
37
+
pragma page_size = 32768;
38
+
pragma auto_vacuum = incremental;
39
+
pragma busy_timeout = 5000;
40
40
41
-
create table if not exists oauth_requests (
42
-
id integer primary key autoincrement,
43
-
auth_server_iss text not null,
44
-
state text not null,
45
-
did text not null,
46
-
handle text not null,
47
-
pds_url text not null,
48
-
pkce_verifier text not null,
49
-
dpop_auth_server_nonce text not null,
50
-
dpop_private_jwk text not null
51
-
);
41
+
create table if not exists oauth_requests (
42
+
id integer primary key autoincrement,
43
+
auth_server_iss text not null,
44
+
state text not null,
45
+
did text not null,
46
+
handle text not null,
47
+
pds_url text not null,
48
+
pkce_verifier text not null,
49
+
dpop_auth_server_nonce text not null,
50
+
dpop_private_jwk text not null
51
+
);
52
52
53
-
create table if not exists oauth_sessions (
54
-
id integer primary key autoincrement,
55
-
did text not null,
56
-
handle text not null,
57
-
pds_url text not null,
58
-
auth_server_iss text not null,
59
-
access_jwt text not null,
60
-
refresh_jwt text not null,
61
-
dpop_pds_nonce text,
62
-
dpop_auth_server_nonce text not null,
63
-
dpop_private_jwk text not null,
64
-
expiry text not null
65
-
);
53
+
create table if not exists oauth_sessions (
54
+
id integer primary key autoincrement,
55
+
did text not null,
56
+
handle text not null,
57
+
pds_url text not null,
58
+
auth_server_iss text not null,
59
+
access_jwt text not null,
60
+
refresh_jwt text not null,
61
+
dpop_pds_nonce text,
62
+
dpop_auth_server_nonce text not null,
63
+
dpop_private_jwk text not null,
64
+
expiry text not null
65
+
);
66
66
67
-
create table if not exists profiles (
68
-
-- id
69
-
id integer primary key autoincrement,
70
-
did text not null,
67
+
create table if not exists profiles (
68
+
-- id
69
+
id integer primary key autoincrement,
70
+
did text not null,
71
71
72
-
-- data
73
-
display_name text not null,
74
-
description text,
75
-
location text,
76
-
xp integer not null default 0, -- total accumulated xp
77
-
level integer not null default 0,
78
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
72
+
-- data
73
+
display_name text not null,
74
+
description text,
75
+
location text,
76
+
xp integer not null default 0, -- total accumulated xp
77
+
level integer not null default 0,
78
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
79
79
80
-
-- constraints
81
-
unique(did)
82
-
);
80
+
-- constraints
81
+
unique(did)
82
+
);
83
83
84
-
create table if not exists profile_languages (
85
-
-- id
86
-
did text not null,
84
+
create table if not exists profile_languages (
85
+
-- id
86
+
did text not null,
87
87
88
-
-- data
89
-
language_code text not null,
88
+
-- data
89
+
language_code text not null,
90
90
91
-
-- constraints
92
-
primary key (did, language_code),
93
-
check (length(language_code) = 2),
94
-
foreign key (did) references profiles(did) on delete cascade
95
-
);
91
+
-- constraints
92
+
primary key (did, language_code),
93
+
check (length(language_code) = 2),
94
+
foreign key (did) references profiles(did) on delete cascade
95
+
);
96
96
97
-
create table if not exists study_sessions (
98
-
-- id
99
-
did text not null,
100
-
rkey text not null,
97
+
create table if not exists study_sessions (
98
+
-- id
99
+
did text not null,
100
+
rkey text not null,
101
101
102
-
-- data
103
-
activity_id integer not null,
104
-
resource_id integer,
105
-
description text,
106
-
duration integer not null,
107
-
language_code text not null,
108
-
date text not null,
109
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
102
+
-- data
103
+
activity_id integer not null,
104
+
resource_id integer,
105
+
description text,
106
+
duration integer not null,
107
+
language_code text not null,
108
+
date text not null,
109
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
110
110
111
-
-- constraints
112
-
check (length(language_code) = 2),
113
-
foreign key (did) references profiles(did) on delete cascade,
114
-
foreign key (activity_id) references activities(id) on delete restrict,
115
-
foreign key (resource_id) references resources(id) on delete set null,
116
-
primary key (did, rkey)
117
-
);
111
+
-- constraints
112
+
check (length(language_code) = 2),
113
+
foreign key (did) references profiles(did) on delete cascade,
114
+
foreign key (activity_id) references activities(id) on delete restrict,
115
+
foreign key (resource_id) references resources(id) on delete set null,
116
+
primary key (did, rkey)
117
+
);
118
118
119
-
create table if not exists categories (
120
-
id integer primary key, -- Matches StudySessionCategory iota
121
-
name text not null unique
122
-
);
119
+
create table if not exists categories (
120
+
id integer primary key, -- Matches StudySessionCategory iota
121
+
name text not null unique
122
+
);
123
123
124
-
create table if not exists activities (
125
-
id integer primary key autoincrement,
124
+
create table if not exists activities (
125
+
id integer primary key autoincrement,
126
126
127
-
did text,
128
-
rkey text,
127
+
did text,
128
+
rkey text,
129
129
130
-
name text not null,
131
-
description text,
132
-
status integer not null default 0 check(status in (0, 1)),
133
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
130
+
name text not null,
131
+
description text,
132
+
status integer not null default 0 check(status in (0, 1)),
133
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
134
134
135
-
foreign key (did) references profiles(did) on delete cascade,
136
-
unique (did, rkey)
137
-
);
135
+
foreign key (did) references profiles(did) on delete cascade,
136
+
unique (did, rkey)
137
+
);
138
138
139
-
create table if not exists activity_categories (
140
-
activity_id integer not null,
141
-
category_id integer not null,
139
+
create table if not exists activity_categories (
140
+
activity_id integer not null,
141
+
category_id integer not null,
142
142
143
-
foreign key (activity_id) references activities(id) on delete cascade,
144
-
foreign key (category_id) references categories(id) on delete cascade,
145
-
primary key (activity_id, category_id)
146
-
);
143
+
foreign key (activity_id) references activities(id) on delete cascade,
144
+
foreign key (category_id) references categories(id) on delete cascade,
145
+
primary key (activity_id, category_id)
146
+
);
147
147
148
-
create table if not exists follows (
149
-
user_did text not null,
150
-
subject_did text not null,
148
+
create table if not exists follows (
149
+
user_did text not null,
150
+
subject_did text not null,
151
151
152
-
rkey text not null,
153
-
followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
152
+
rkey text not null,
153
+
followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
154
154
155
-
primary key (user_did, subject_did),
156
-
check (user_did <> subject_did)
157
-
);
155
+
primary key (user_did, subject_did),
156
+
check (user_did <> subject_did)
157
+
);
158
158
159
-
create table if not exists xp_events (
160
-
id integer primary key autoincrement,
159
+
create table if not exists xp_events (
160
+
id integer primary key autoincrement,
161
161
162
-
did text not null,
163
-
session_rkey text not null,
164
-
xp_gained integer not null,
165
-
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
162
+
did text not null,
163
+
session_rkey text not null,
164
+
xp_gained integer not null,
165
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
166
166
167
-
foreign key (did) references profiles (did),
168
-
foreign key (did, session_rkey) references study_sessions (did, rkey),
169
-
unique (did, session_rkey)
170
-
);
167
+
foreign key (did) references profiles (did),
168
+
foreign key (did, session_rkey) references study_sessions (did, rkey),
169
+
unique (did, session_rkey)
170
+
);
171
171
172
-
create table if not exists study_session_reactions (
173
-
id integer primary key autoincrement,
172
+
create table if not exists study_session_reactions (
173
+
id integer primary key autoincrement,
174
174
175
-
did text not null,
176
-
rkey text not null,
175
+
did text not null,
176
+
rkey text not null,
177
177
178
-
session_did text not null,
179
-
session_rkey text not null,
180
-
reaction_id integer not null,
181
-
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
178
+
session_did text not null,
179
+
session_rkey text not null,
180
+
reaction_id integer not null,
181
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
182
182
183
-
foreign key (did) references profiles (did),
184
-
foreign key (session_did, session_rkey) references study_sessions (did, rkey),
185
-
unique (did, session_did, session_rkey, reaction_id)
186
-
);
183
+
foreign key (did) references profiles (did),
184
+
foreign key (session_did, session_rkey) references study_sessions (did, rkey),
185
+
unique (did, session_did, session_rkey, reaction_id)
186
+
);
187
187
188
-
create table if not exists notifications (
189
-
id integer primary key autoincrement,
188
+
create table if not exists notifications (
189
+
id integer primary key autoincrement,
190
190
191
-
recipient_did text not null,
192
-
actor_did text not null,
193
-
subject_uri text not null,
191
+
recipient_did text not null,
192
+
actor_did text not null,
193
+
subject_uri text not null,
194
194
195
-
state text not null default 'unread' check(state in ('unread', 'read')),
196
-
type text not null check(type in ('follow', 'reaction')),
195
+
state text not null default 'unread' check(state in ('unread', 'read')),
196
+
type text not null check(type in ('follow', 'reaction', 'comment')),
197
197
198
-
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
198
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
199
199
200
-
foreign key (recipient_did) references profiles(did) on delete cascade,
201
-
foreign key (actor_did) references profiles(did) on delete cascade
202
-
);
200
+
foreign key (recipient_did) references profiles(did) on delete cascade,
201
+
foreign key (actor_did) references profiles(did) on delete cascade
202
+
);
203
203
204
-
create table if not exists resources (
205
-
id integer primary key autoincrement,
204
+
create table if not exists resources (
205
+
id integer primary key autoincrement,
206
206
207
-
did text not null,
208
-
rkey text not null,
207
+
did text not null,
208
+
rkey text not null,
209
209
210
-
title text not null,
211
-
type text not null,
212
-
author text not null,
213
-
link text,
214
-
description text not null,
215
-
status integer not null default 0 check(status in (0, 1)),
216
-
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
210
+
title text not null,
211
+
type text not null,
212
+
author text not null,
213
+
link text,
214
+
description text not null,
215
+
status integer not null default 0 check(status in (0, 1)),
216
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
217
217
218
-
foreign key (did) references profiles (did) on delete cascade,
219
-
unique (did, rkey)
220
-
);
218
+
foreign key (did) references profiles (did) on delete cascade,
219
+
unique (did, rkey)
220
+
);
221
221
222
-
create table if not exists _jetstream (
223
-
id integer primary key autoincrement,
224
-
last_time_us integer not null
225
-
);
222
+
create table if not exists comments (
223
+
id integer primary key autoincrement,
226
224
227
-
create table if not exists migrations (
228
-
id integer primary key autoincrement,
229
-
name text unique
230
-
);
231
-
`)
225
+
did text not null,
226
+
rkey text not null,
227
+
228
+
study_session_uri text not null,
229
+
parent_comment_uri text,
230
+
body text not null,
231
+
is_deleted boolean not null default false,
232
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
233
+
234
+
foreign key (did) references profiles(did) on delete cascade
235
+
unique (did, rkey)
236
+
);
237
+
238
+
create table if not exists _jetstream (
239
+
id integer primary key autoincrement,
240
+
last_time_us integer not null
241
+
);
242
+
243
+
create table if not exists migrations (
244
+
id integer primary key autoincrement,
245
+
name text unique
246
+
);
247
+
`)
232
248
if err != nil {
233
249
return nil, fmt.Errorf("failed to execute db create statement: %w", err)
234
250
}
+1
internal/server/views/new-study-session.templ
+1
internal/server/views/new-study-session.templ
+50
lexicons/feed/comment.json
+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
+4
internal/clients/posthog/posthog.go
···
19
19
ReactionRecordCreatedEvent string = "reaction-record-created"
20
20
ReactionRecordDeletedEvent string = "reaction-record-deleted"
21
21
22
+
CommentRecordCreatedEvent string = "comment-record-created"
23
+
CommentRecordDeletedEvent string = "comment-record-deleted"
24
+
CommentRecordEditedEvent string = "comment-record-edited"
25
+
22
26
ActivityDefRecordFirstCreated string = "activity-def-record-first-created"
23
27
ActivityDefRecordCreatedEvent string = "activity-def-record-created"
24
28
ActivityDefRecordDeletedEvent string = "activity-def-record-deleted"
+91
-12
internal/consumer/ingester.go
+91
-12
internal/consumer/ingester.go
···
50
50
err = i.ingestFollow(e)
51
51
case yoten.FeedReactionNSID:
52
52
err = i.ingestReaction(e)
53
+
case yoten.FeedCommentNSID:
54
+
err = i.ingestComment(e)
53
55
}
54
56
}
55
57
if err != nil {
···
117
119
118
120
119
121
122
+
ddb, ok := i.Db.Execer.(*db.DB)
123
+
if !ok {
124
+
return fmt.Errorf("failed to index profile record: ddb not valid")
125
+
}
120
126
127
+
tx, err := ddb.Begin()
121
128
122
129
123
130
···
327
334
328
335
329
336
337
+
ddb, ok := i.Db.Execer.(*db.DB)
338
+
if !ok {
339
+
return fmt.Errorf("failed to index activity def record: ddb not valid")
340
+
}
330
341
342
+
tx, err := ddb.Begin()
331
343
332
344
333
345
···
363
375
364
376
365
377
378
+
ddb, ok := i.Db.Execer.(*db.DB)
379
+
if !ok {
380
+
return fmt.Errorf("failed to index activity def record: ddb not valid")
381
+
}
366
382
383
+
tx, err := ddb.Begin()
367
384
368
385
369
386
···
408
425
409
426
410
427
428
+
ddb, ok := i.Db.Execer.(*db.DB)
429
+
if !ok {
430
+
return fmt.Errorf("failed to index reaction record: ddb not valid")
431
+
}
411
432
433
+
tx, err := ddb.Begin()
412
434
413
435
414
436
···
490
512
491
513
492
514
515
+
ddb, ok := i.Db.Execer.(*db.DB)
516
+
if !ok {
517
+
return fmt.Errorf("failed to index resource record: ddb not valid")
518
+
}
493
519
520
+
tx, err := ddb.Begin()
494
521
495
522
496
523
···
534
561
535
562
536
563
564
+
return nil
565
+
}
537
566
567
+
func (i *Ingester) ingestComment(e *models.Event) error {
568
+
var err error
569
+
did := e.Did
538
570
571
+
switch e.Commit.Operation {
572
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
573
+
raw := json.RawMessage(e.Commit.Record)
574
+
record := yoten.FeedComment{}
575
+
err = json.Unmarshal(raw, &record)
576
+
if err != nil {
577
+
return fmt.Errorf("invalid record: %w", err)
578
+
}
539
579
580
+
subject := record.Subject
581
+
subjectUri, err := syntax.ParseATURI(subject)
582
+
if err != nil {
583
+
return fmt.Errorf("failed to parse study session at-uri: %w", err)
584
+
}
585
+
subjectDid, err := subjectUri.Authority().AsDID()
586
+
if err != nil {
587
+
return fmt.Errorf("failed to identify subject did: %w", err)
588
+
}
540
589
590
+
body := record.Body
591
+
if len(strings.TrimSpace(body)) == 0 {
592
+
return fmt.Errorf("invalid body: length cannot be 0")
593
+
}
541
594
595
+
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
596
+
if err != nil {
597
+
return fmt.Errorf("invalid createdAt format: %w", err)
598
+
}
542
599
600
+
ddb, ok := i.Db.Execer.(*db.DB)
601
+
if !ok {
602
+
return fmt.Errorf("failed to index resource record: ddb not valid")
603
+
}
543
604
605
+
tx, err := ddb.Begin()
606
+
if err != nil {
607
+
return fmt.Errorf("failed to start transaction: %w", err)
608
+
}
544
609
610
+
// TODO: Parse reply
545
611
612
+
comment := db.Comment{
613
+
Did: did,
614
+
Rkey: e.Commit.RKey,
615
+
StudySessionUri: subjectUri,
616
+
Body: body,
617
+
CreatedAt: createdAt,
618
+
}
546
619
620
+
log.Println("upserting comment from pds request")
621
+
err = db.UpsertComment(i.Db, comment)
622
+
if err != nil {
623
+
tx.Rollback()
624
+
return fmt.Errorf("failed to upsert comment record: %w", err)
625
+
}
547
626
627
+
err = db.CreateNotification(tx, subjectDid.String(), did, subjectUri.String(), db.NotificationTypeComment)
628
+
if err != nil {
629
+
log.Println("failed to create notification record:", err)
630
+
}
548
631
549
-
550
-
551
-
552
-
553
-
554
-
555
-
556
-
557
-
558
-
559
-
560
-
632
+
return tx.Commit()
633
+
case models.CommitOperationDelete:
634
+
log.Println("deleting comment from pds request")
635
+
err = db.DeleteCommentByRkey(i.Db, did, e.Commit.RKey)
636
+
}
637
+
if err != nil {
638
+
return fmt.Errorf("failed to %s follow record: %w", e.Commit.Operation, err)
639
+
}
561
640
562
641
return nil
563
642
}
+3
-1
internal/server/app.go
+3
-1
internal/server/app.go
···
62
62
jc, err := consumer.NewJetstreamClient(
63
63
config.Jetstream.Endpoint,
64
64
"yoten",
65
-
[]string{yoten.ActorProfileNSID,
65
+
[]string{
66
+
yoten.ActorProfileNSID,
66
67
yoten.FeedSessionNSID,
67
68
yoten.FeedResourceNSID,
69
+
yoten.FeedCommentNSID,
68
70
yoten.FeedReactionNSID,
69
71
yoten.ActivityDefNSID,
70
72
yoten.GraphFollowNSID,
+370
internal/server/handlers/comment.go
+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
+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">@{ 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">@{ 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
+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
+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
+3
-3
internal/server/oauth/handler/handler.go
···
263
263
}
264
264
}()
265
265
266
-
error := r.FormValue("error")
266
+
callbackErr := r.FormValue("error")
267
267
errorDescription := r.FormValue("error_description")
268
-
if error != "" || errorDescription != "" {
269
-
log.Printf("oauth callback error: %s, %s", error, errorDescription)
268
+
if callbackErr != "" || errorDescription != "" {
269
+
log.Printf("oauth callback error: %s, %s", callbackErr, errorDescription)
270
270
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
271
271
return
272
272
}
+44
internal/server/views/partials/edit-comment.templ
+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
+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
+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
+1
-1
internal/server/views/partials/reactions.templ
···
40
40
}
41
41
</div>
42
42
}
43
-
<div class="inline-block text-left w-fit">
43
+
<div class="inline-block text-left w-fit" title="reactions">
44
44
<button @click="open = !open" id="reaction-button" type="button" class="btn rounded-full hover:bg-bg py-1 px-2">
45
45
<i class="w-5 h-5" data-lucide="smile-plus"></i>
46
46
</button>
+7
internal/db/notification.go
+7
internal/db/notification.go
···
12
12
const (
13
13
NotificationTypeFollow NotificationType = "follow"
14
14
NotificationTypeReaction NotificationType = "reaction"
15
+
NotificationTypeComment NotificationType = "comment"
15
16
)
16
17
17
18
type NotificationState string
···
31
32
RecipientDid string
32
33
ActorDid string
33
34
SubjectRkey string
35
+
SubjectDid string
34
36
State NotificationState
35
37
Type NotificationType
36
38
CreatedAt time.Time
···
105
107
return nil, fmt.Errorf("failed to parse at-uri: %w", err)
106
108
}
107
109
notification.SubjectRkey = subjectUri.RecordKey().String()
110
+
subjectDid, err := subjectUri.Authority().AsDID()
111
+
if err != nil {
112
+
return nil, fmt.Errorf("failed to identify subject did: %w", err)
113
+
}
114
+
notification.SubjectDid = subjectDid.String()
108
115
109
116
notifications = append(notifications, notification)
110
117
}
+2
-2
internal/server/views/friends.templ
+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
+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
+
@{ 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
@{ 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
+
@{ params.Notification.ActorBskyHandle }
38
+
</a> commented on your
39
+
<a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.SubjectDid + "/session/" + params.Notification.SubjectRkey) }>
40
+
study session
41
+
</a>
27
42
</p>
28
43
</div>
29
44
default:
History
3 rounds
0 comments
brookjeynes.dev
submitted
#2
8 commits
expand
collapse
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
brookjeynes.dev
submitted
#1
9 commits
expand
collapse
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
brookjeynes.dev
submitted
#0
10 commits
expand
collapse
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