+16
-4
internal/server/handlers/router.go
+16
-4
internal/server/handlers/router.go
···
83
84
85
86
+
r.Delete("/{rkey}", h.HandleDeleteResource)
87
+
})
88
89
+
r.Route("/comment", func(r chi.Router) {
90
+
r.Use(middleware.AuthMiddleware(h.Oauth))
91
+
r.Post("/new", h.HandleNewComment)
92
+
r.Get("/edit/{rkey}", h.HandleEditCommentPage)
93
+
r.Post("/edit/{rkey}", h.HandleEditCommentPage)
94
+
r.Delete("/{rkey}", h.HandleDeleteComment)
95
+
})
96
97
+
r.Route("/activity", func(r chi.Router) {
98
+
r.Use(middleware.AuthMiddleware(h.Oauth))
99
+
r.Get("/new", h.HandleNewActivityPage)
100
101
102
···
134
135
136
137
r.Route("/{user}", func(r chi.Router) {
138
r.Get("/", h.HandleProfilePage)
139
r.Get("/feed", h.HandleProfileFeed)
140
+
r.Route("/session/{rkey}", func(r chi.Router) {
141
+
r.Get("/", h.HandleStudySessionPage)
142
+
r.Get("/feed", h.HandleStudySessionPageCommentFeed)
143
+
})
144
})
145
})
146
+123
internal/server/handlers/study-session.go
+123
internal/server/handlers/study-session.go
···
9
"time"
10
11
comatproto "github.com/bluesky-social/indigo/api/atproto"
12
+
"github.com/bluesky-social/indigo/atproto/identity"
13
+
"github.com/bluesky-social/indigo/atproto/syntax"
14
lexutil "github.com/bluesky-social/indigo/lex/util"
15
"github.com/go-chi/chi/v5"
16
"github.com/posthog/posthog-go"
···
631
632
633
return nil
634
+
}
635
+
636
+
func (h *Handler) HandleStudySessionPage(w http.ResponseWriter, r *http.Request) {
637
+
user, _ := bsky.GetUserWithBskyProfile(h.Oauth, r)
638
+
didOrHandle := chi.URLParam(r, "user")
639
+
if didOrHandle == "" {
640
+
http.Error(w, "Bad request", http.StatusBadRequest)
641
+
return
642
+
}
643
+
644
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
645
+
if !ok {
646
+
w.WriteHeader(http.StatusNotFound)
647
+
views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w)
648
+
return
649
+
}
650
+
rkey := chi.URLParam(r, "rkey")
651
+
652
+
studySession, err := db.GetStudySessionByRkey(h.Db, ident.DID.String(), rkey)
653
+
if err != nil {
654
+
log.Println("failed to retrieve study session:", err)
655
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve study session, try again later.")
656
+
return
657
+
}
658
+
659
+
bskyProfile, err := bsky.GetBskyProfile(ident.DID.String())
660
+
if err != nil {
661
+
log.Println("failed to retrieve bsky profile for study session:", err)
662
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve bsky profile, try again later.")
663
+
return
664
+
}
665
+
666
+
profile, err := db.GetProfile(h.Db, ident.DID.String())
667
+
if err != nil {
668
+
log.Println("failed to retrieve profile for study session:", err)
669
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve profile, try again later.")
670
+
return
671
+
}
672
+
673
+
isSelf := false
674
+
if user != nil {
675
+
isSelf = user.Did == studySession.Did
676
+
}
677
+
678
+
views.StudySessionPage(views.StudySessionPageParams{
679
+
User: user,
680
+
DoesOwn: isSelf,
681
+
StudySession: db.StudySessionFeedItem{
682
+
StudySession: *studySession,
683
+
ProfileDisplayName: profile.DisplayName,
684
+
ProfileLevel: profile.Level,
685
+
BskyProfile: bskyProfile,
686
+
},
687
+
}).Render(r.Context(), w)
688
+
}
689
+
690
+
func (h *Handler) HandleStudySessionPageCommentFeed(w http.ResponseWriter, r *http.Request) {
691
+
user, _ := bsky.GetUserWithBskyProfile(h.Oauth, r)
692
+
693
+
didOrHandle := chi.URLParam(r, "user")
694
+
if didOrHandle == "" {
695
+
http.Error(w, "Bad request", http.StatusBadRequest)
696
+
return
697
+
}
698
+
699
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
700
+
if !ok {
701
+
w.WriteHeader(http.StatusNotFound)
702
+
views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w)
703
+
return
704
+
}
705
+
rkey := chi.URLParam(r, "rkey")
706
+
studySessionUri := syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", ident.DID.String(), yoten.FeedSessionNSID, rkey))
707
+
708
+
pageStr := r.URL.Query().Get("page")
709
+
if pageStr == "" {
710
+
pageStr = "1"
711
+
}
712
+
page, err := strconv.ParseInt(pageStr, 10, 64)
713
+
if err != nil {
714
+
log.Println("failed to parse page value:", err)
715
+
page = 1
716
+
}
717
+
if page == 0 {
718
+
page = 1
719
+
}
720
+
721
+
const pageSize = 2
722
+
offset := (page - 1) * pageSize
723
+
724
+
commentFeed, err := db.GetCommentsForSession(h.Db, studySessionUri.String(), pageSize+1, int(offset))
725
+
if err != nil {
726
+
log.Println("failed to get comment feed:", err)
727
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to get comment feed, try again later.")
728
+
return
729
+
}
730
+
731
+
commentFeed = utils.Filter(commentFeed, func(cwlp db.CommentWithLocalProfile) bool {
732
+
return !cwlp.IsDeleted
733
+
})
734
+
735
+
nextPage := 0
736
+
if len(commentFeed) > pageSize {
737
+
nextPage = int(page + 1)
738
+
commentFeed = commentFeed[:pageSize]
739
+
}
740
+
741
+
populatedCommentFeed, err := h.BuildCommentFeed(commentFeed)
742
+
if err != nil {
743
+
log.Println("failed to populate comment feed:", err)
744
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to get comment feed, try again later.")
745
+
return
746
+
}
747
+
748
+
partials.CommentFeed(partials.CommentFeedProps{
749
+
Feed: populatedCommentFeed,
750
+
NextPage: nextPage,
751
+
User: user,
752
+
StudySessionDid: ident.DID.String(),
753
+
StudySessionRkey: rkey,
754
+
}).Render(r.Context(), w)
755
}
+52
internal/server/views/partials/discussion.templ
+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
Feed []db.NotificationWithBskyHandle
219
NextPage int
220
}
221
+
222
+
type DiscussionProps struct {
223
+
StudySessionUri string
224
+
StudySessionDid string
225
+
StudySessionRkey string
226
+
}
227
+
228
+
type CommentProps struct {
229
+
Comment db.CommentFeedItem
230
+
DoesOwn bool
231
+
}
232
+
233
+
type EditCommentProps struct {
234
+
Comment db.Comment
235
+
}
236
+
237
+
type CommentFeedProps struct {
238
+
// The current logged in user
239
+
User *types.User
240
+
Feed []db.CommentFeedItem
241
+
NextPage int
242
+
StudySessionDid string
243
+
StudySessionRkey string
244
+
}
+19
-13
internal/server/views/partials/study-session.templ
+19
-13
internal/server/views/partials/study-session.templ
···
52
53
54
55
56
57
···
60
61
62
63
-
64
-
65
-
66
-
67
-
68
-
69
-
70
templ StudySession(params StudySessionProps) {
71
{{ elementId := SanitiseHtmlId(fmt.Sprintf("study-session-%s-%s", params.StudySession.Did, params.StudySession.Rkey)) }}
72
<div id={ elementId } class="card relative" x-data="{ open: false }" :class="{ 'z-20': open }">
73
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
74
<div class="flex items-center justify-between">
···
148
}
149
<hr class="border-gray"/>
150
<div class="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
151
-
@NewReactions(NewReactionsProps{
152
-
User: params.User,
153
-
SessionDid: params.StudySession.Did,
154
-
SessionRkey: params.StudySession.Rkey,
155
-
ReactionEvents: params.StudySession.Reactions,
156
-
})
157
<div class="flex flex-col sm:flex-row sm:items-center gap-2 text-sm text-text-muted">
158
<div class="flex items-center gap-1">
159
<i class="w-4 h-4" data-lucide="clock"></i>
···
52
53
54
55
+
class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
56
+
type="button"
57
+
id="delete-button"
58
+
hx-disabled-elt="#delete-button,#edit-button"
59
+
hx-delete={ templ.URL(fmt.Sprintf("/session/%s", params.StudySession.Rkey)) }
60
+
>
61
+
<i class="w-4 h-4" data-lucide="trash-2"></i>
62
63
64
···
67
68
69
70
templ StudySession(params StudySessionProps) {
71
{{ elementId := SanitiseHtmlId(fmt.Sprintf("study-session-%s-%s", params.StudySession.Did, params.StudySession.Rkey)) }}
72
+
{{ studySessionUrl := templ.SafeURL("/" + params.StudySession.Did + "/session/" + params.StudySession.Rkey) }}
73
<div id={ elementId } class="card relative" x-data="{ open: false }" :class="{ 'z-20': open }">
74
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
75
<div class="flex items-center justify-between">
···
149
}
150
<hr class="border-gray"/>
151
<div class="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
152
+
<div class="flex items-center gap-1">
153
+
@NewReactions(NewReactionsProps{
154
+
User: params.User,
155
+
SessionDid: params.StudySession.Did,
156
+
SessionRkey: params.StudySession.Rkey,
157
+
ReactionEvents: params.StudySession.Reactions,
158
+
})
159
+
<a href={ studySessionUrl } title="comments">
160
+
<i class="w-5 h-5" data-lucide="message-square-share"></i>
161
+
</a>
162
+
</div>
163
<div class="flex flex-col sm:flex-row sm:items-center gap-2 text-sm text-text-muted">
164
<div class="flex items-center gap-1">
165
<i class="w-4 h-4" data-lucide="clock"></i>
+24
internal/server/views/study-session.templ
+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
+376
api/yoten/cbor_gen.go
+376
api/yoten/cbor_gen.go
···
648
649
return nil
650
}
651
+
func (t *FeedComment) MarshalCBOR(w io.Writer) error {
652
+
if t == nil {
653
+
_, err := w.Write(cbg.CborNull)
654
+
return err
655
+
}
656
+
657
+
cw := cbg.NewCborWriter(w)
658
+
fieldCount := 5
659
+
660
+
if t.Reply == nil {
661
+
fieldCount--
662
+
}
663
+
664
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
665
+
return err
666
+
}
667
+
668
+
// t.Body (string) (string)
669
+
if len("body") > 1000000 {
670
+
return xerrors.Errorf("Value in field \"body\" was too long")
671
+
}
672
+
673
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil {
674
+
return err
675
+
}
676
+
if _, err := cw.WriteString(string("body")); err != nil {
677
+
return err
678
+
}
679
+
680
+
if len(t.Body) > 1000000 {
681
+
return xerrors.Errorf("Value in field t.Body was too long")
682
+
}
683
+
684
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil {
685
+
return err
686
+
}
687
+
if _, err := cw.WriteString(string(t.Body)); err != nil {
688
+
return err
689
+
}
690
+
691
+
// t.LexiconTypeID (string) (string)
692
+
if len("$type") > 1000000 {
693
+
return xerrors.Errorf("Value in field \"$type\" was too long")
694
+
}
695
+
696
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
697
+
return err
698
+
}
699
+
if _, err := cw.WriteString(string("$type")); err != nil {
700
+
return err
701
+
}
702
+
703
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("app.yoten.feed.comment"))); err != nil {
704
+
return err
705
+
}
706
+
if _, err := cw.WriteString(string("app.yoten.feed.comment")); err != nil {
707
+
return err
708
+
}
709
+
710
+
// t.Reply (yoten.FeedComment_Reply) (struct)
711
+
if t.Reply != nil {
712
+
713
+
if len("reply") > 1000000 {
714
+
return xerrors.Errorf("Value in field \"reply\" was too long")
715
+
}
716
+
717
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("reply"))); err != nil {
718
+
return err
719
+
}
720
+
if _, err := cw.WriteString(string("reply")); err != nil {
721
+
return err
722
+
}
723
+
724
+
if err := t.Reply.MarshalCBOR(cw); err != nil {
725
+
return err
726
+
}
727
+
}
728
+
729
+
// t.Subject (string) (string)
730
+
if len("subject") > 1000000 {
731
+
return xerrors.Errorf("Value in field \"subject\" was too long")
732
+
}
733
+
734
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
735
+
return err
736
+
}
737
+
if _, err := cw.WriteString(string("subject")); err != nil {
738
+
return err
739
+
}
740
+
741
+
if len(t.Subject) > 1000000 {
742
+
return xerrors.Errorf("Value in field t.Subject was too long")
743
+
}
744
+
745
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
746
+
return err
747
+
}
748
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
749
+
return err
750
+
}
751
+
752
+
// t.CreatedAt (string) (string)
753
+
if len("createdAt") > 1000000 {
754
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
755
+
}
756
+
757
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
758
+
return err
759
+
}
760
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
761
+
return err
762
+
}
763
+
764
+
if len(t.CreatedAt) > 1000000 {
765
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
766
+
}
767
+
768
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
769
+
return err
770
+
}
771
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
772
+
return err
773
+
}
774
+
return nil
775
+
}
776
+
777
+
func (t *FeedComment) UnmarshalCBOR(r io.Reader) (err error) {
778
+
*t = FeedComment{}
779
+
780
+
cr := cbg.NewCborReader(r)
781
+
782
+
maj, extra, err := cr.ReadHeader()
783
+
if err != nil {
784
+
return err
785
+
}
786
+
defer func() {
787
+
if err == io.EOF {
788
+
err = io.ErrUnexpectedEOF
789
+
}
790
+
}()
791
+
792
+
if maj != cbg.MajMap {
793
+
return fmt.Errorf("cbor input should be of type map")
794
+
}
795
+
796
+
if extra > cbg.MaxLength {
797
+
return fmt.Errorf("FeedComment: map struct too large (%d)", extra)
798
+
}
799
+
800
+
n := extra
801
+
802
+
nameBuf := make([]byte, 9)
803
+
for i := uint64(0); i < n; i++ {
804
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
805
+
if err != nil {
806
+
return err
807
+
}
808
+
809
+
if !ok {
810
+
// Field doesn't exist on this type, so ignore it
811
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
812
+
return err
813
+
}
814
+
continue
815
+
}
816
+
817
+
switch string(nameBuf[:nameLen]) {
818
+
// t.Body (string) (string)
819
+
case "body":
820
+
821
+
{
822
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
823
+
if err != nil {
824
+
return err
825
+
}
826
+
827
+
t.Body = string(sval)
828
+
}
829
+
// t.LexiconTypeID (string) (string)
830
+
case "$type":
831
+
832
+
{
833
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
834
+
if err != nil {
835
+
return err
836
+
}
837
+
838
+
t.LexiconTypeID = string(sval)
839
+
}
840
+
// t.Reply (yoten.FeedComment_Reply) (struct)
841
+
case "reply":
842
+
843
+
{
844
+
845
+
b, err := cr.ReadByte()
846
+
if err != nil {
847
+
return err
848
+
}
849
+
if b != cbg.CborNull[0] {
850
+
if err := cr.UnreadByte(); err != nil {
851
+
return err
852
+
}
853
+
t.Reply = new(FeedComment_Reply)
854
+
if err := t.Reply.UnmarshalCBOR(cr); err != nil {
855
+
return xerrors.Errorf("unmarshaling t.Reply pointer: %w", err)
856
+
}
857
+
}
858
+
859
+
}
860
+
// t.Subject (string) (string)
861
+
case "subject":
862
+
863
+
{
864
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
865
+
if err != nil {
866
+
return err
867
+
}
868
+
869
+
t.Subject = string(sval)
870
+
}
871
+
// t.CreatedAt (string) (string)
872
+
case "createdAt":
873
+
874
+
{
875
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
876
+
if err != nil {
877
+
return err
878
+
}
879
+
880
+
t.CreatedAt = string(sval)
881
+
}
882
+
883
+
default:
884
+
// Field doesn't exist on this type, so ignore it
885
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
886
+
return err
887
+
}
888
+
}
889
+
}
890
+
891
+
return nil
892
+
}
893
+
func (t *FeedComment_Reply) MarshalCBOR(w io.Writer) error {
894
+
if t == nil {
895
+
_, err := w.Write(cbg.CborNull)
896
+
return err
897
+
}
898
+
899
+
cw := cbg.NewCborWriter(w)
900
+
901
+
if _, err := cw.Write([]byte{162}); err != nil {
902
+
return err
903
+
}
904
+
905
+
// t.Root (string) (string)
906
+
if len("root") > 1000000 {
907
+
return xerrors.Errorf("Value in field \"root\" was too long")
908
+
}
909
+
910
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("root"))); err != nil {
911
+
return err
912
+
}
913
+
if _, err := cw.WriteString(string("root")); err != nil {
914
+
return err
915
+
}
916
+
917
+
if len(t.Root) > 1000000 {
918
+
return xerrors.Errorf("Value in field t.Root was too long")
919
+
}
920
+
921
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Root))); err != nil {
922
+
return err
923
+
}
924
+
if _, err := cw.WriteString(string(t.Root)); err != nil {
925
+
return err
926
+
}
927
+
928
+
// t.Parent (string) (string)
929
+
if len("parent") > 1000000 {
930
+
return xerrors.Errorf("Value in field \"parent\" was too long")
931
+
}
932
+
933
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("parent"))); err != nil {
934
+
return err
935
+
}
936
+
if _, err := cw.WriteString(string("parent")); err != nil {
937
+
return err
938
+
}
939
+
940
+
if len(t.Parent) > 1000000 {
941
+
return xerrors.Errorf("Value in field t.Parent was too long")
942
+
}
943
+
944
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Parent))); err != nil {
945
+
return err
946
+
}
947
+
if _, err := cw.WriteString(string(t.Parent)); err != nil {
948
+
return err
949
+
}
950
+
return nil
951
+
}
952
+
953
+
func (t *FeedComment_Reply) UnmarshalCBOR(r io.Reader) (err error) {
954
+
*t = FeedComment_Reply{}
955
+
956
+
cr := cbg.NewCborReader(r)
957
+
958
+
maj, extra, err := cr.ReadHeader()
959
+
if err != nil {
960
+
return err
961
+
}
962
+
defer func() {
963
+
if err == io.EOF {
964
+
err = io.ErrUnexpectedEOF
965
+
}
966
+
}()
967
+
968
+
if maj != cbg.MajMap {
969
+
return fmt.Errorf("cbor input should be of type map")
970
+
}
971
+
972
+
if extra > cbg.MaxLength {
973
+
return fmt.Errorf("FeedComment_Reply: map struct too large (%d)", extra)
974
+
}
975
+
976
+
n := extra
977
+
978
+
nameBuf := make([]byte, 6)
979
+
for i := uint64(0); i < n; i++ {
980
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
981
+
if err != nil {
982
+
return err
983
+
}
984
+
985
+
if !ok {
986
+
// Field doesn't exist on this type, so ignore it
987
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
988
+
return err
989
+
}
990
+
continue
991
+
}
992
+
993
+
switch string(nameBuf[:nameLen]) {
994
+
// t.Root (string) (string)
995
+
case "root":
996
+
997
+
{
998
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
999
+
if err != nil {
1000
+
return err
1001
+
}
1002
+
1003
+
t.Root = string(sval)
1004
+
}
1005
+
// t.Parent (string) (string)
1006
+
case "parent":
1007
+
1008
+
{
1009
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
1010
+
if err != nil {
1011
+
return err
1012
+
}
1013
+
1014
+
t.Parent = string(sval)
1015
+
}
1016
+
1017
+
default:
1018
+
// Field doesn't exist on this type, so ignore it
1019
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
1020
+
return err
1021
+
}
1022
+
}
1023
+
}
1024
+
1025
+
return nil
1026
+
}
1027
func (t *FeedReaction) MarshalCBOR(w io.Writer) error {
1028
if t == nil {
1029
_, err := w.Write(cbg.CborNull)
+35
api/yoten/feedcomment.go
+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
return nil, fmt.Errorf("failed to open db: %w", err)
30
}
31
_, err = db.Exec(`
32
-
pragma journal_mode = WAL;
33
-
pragma synchronous = normal;
34
-
pragma foreign_keys = on;
35
-
pragma temp_store = memory;
36
-
pragma mmap_size = 30000000000;
37
-
pragma page_size = 32768;
38
-
pragma auto_vacuum = incremental;
39
-
pragma busy_timeout = 5000;
40
41
-
create table if not exists oauth_requests (
42
-
id integer primary key autoincrement,
43
-
auth_server_iss text not null,
44
-
state text not null,
45
-
did text not null,
46
-
handle text not null,
47
-
pds_url text not null,
48
-
pkce_verifier text not null,
49
-
dpop_auth_server_nonce text not null,
50
-
dpop_private_jwk text not null
51
-
);
52
53
-
create table if not exists oauth_sessions (
54
-
id integer primary key autoincrement,
55
-
did text not null,
56
-
handle text not null,
57
-
pds_url text not null,
58
-
auth_server_iss text not null,
59
-
access_jwt text not null,
60
-
refresh_jwt text not null,
61
-
dpop_pds_nonce text,
62
-
dpop_auth_server_nonce text not null,
63
-
dpop_private_jwk text not null,
64
-
expiry text not null
65
-
);
66
67
-
create table if not exists profiles (
68
-
-- id
69
-
id integer primary key autoincrement,
70
-
did text not null,
71
72
-
-- data
73
-
display_name text not null,
74
-
description text,
75
-
location text,
76
-
xp integer not null default 0, -- total accumulated xp
77
-
level integer not null default 0,
78
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
79
80
-
-- constraints
81
-
unique(did)
82
-
);
83
84
-
create table if not exists profile_languages (
85
-
-- id
86
-
did text not null,
87
88
-
-- data
89
-
language_code text not null,
90
91
-
-- constraints
92
-
primary key (did, language_code),
93
-
check (length(language_code) = 2),
94
-
foreign key (did) references profiles(did) on delete cascade
95
-
);
96
97
-
create table if not exists study_sessions (
98
-
-- id
99
-
did text not null,
100
-
rkey text not null,
101
102
-
-- data
103
-
activity_id integer not null,
104
-
resource_id integer,
105
-
description text,
106
-
duration integer not null,
107
-
language_code text not null,
108
-
date text not null,
109
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
110
111
-
-- constraints
112
-
check (length(language_code) = 2),
113
-
foreign key (did) references profiles(did) on delete cascade,
114
-
foreign key (activity_id) references activities(id) on delete restrict,
115
-
foreign key (resource_id) references resources(id) on delete set null,
116
-
primary key (did, rkey)
117
-
);
118
119
-
create table if not exists categories (
120
-
id integer primary key, -- Matches StudySessionCategory iota
121
-
name text not null unique
122
-
);
123
124
-
create table if not exists activities (
125
-
id integer primary key autoincrement,
126
127
-
did text,
128
-
rkey text,
129
130
-
name text not null,
131
-
description text,
132
-
status integer not null default 0 check(status in (0, 1)),
133
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
134
135
-
foreign key (did) references profiles(did) on delete cascade,
136
-
unique (did, rkey)
137
-
);
138
139
-
create table if not exists activity_categories (
140
-
activity_id integer not null,
141
-
category_id integer not null,
142
143
-
foreign key (activity_id) references activities(id) on delete cascade,
144
-
foreign key (category_id) references categories(id) on delete cascade,
145
-
primary key (activity_id, category_id)
146
-
);
147
148
-
create table if not exists follows (
149
-
user_did text not null,
150
-
subject_did text not null,
151
152
-
rkey text not null,
153
-
followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
154
155
-
primary key (user_did, subject_did),
156
-
check (user_did <> subject_did)
157
-
);
158
159
-
create table if not exists xp_events (
160
-
id integer primary key autoincrement,
161
162
-
did text not null,
163
-
session_rkey text not null,
164
-
xp_gained integer not null,
165
-
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
166
167
-
foreign key (did) references profiles (did),
168
-
foreign key (did, session_rkey) references study_sessions (did, rkey),
169
-
unique (did, session_rkey)
170
-
);
171
172
-
create table if not exists study_session_reactions (
173
-
id integer primary key autoincrement,
174
175
-
did text not null,
176
-
rkey text not null,
177
178
-
session_did text not null,
179
-
session_rkey text not null,
180
-
reaction_id integer not null,
181
-
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
182
183
-
foreign key (did) references profiles (did),
184
-
foreign key (session_did, session_rkey) references study_sessions (did, rkey),
185
-
unique (did, session_did, session_rkey, reaction_id)
186
-
);
187
188
-
create table if not exists notifications (
189
-
id integer primary key autoincrement,
190
191
-
recipient_did text not null,
192
-
actor_did text not null,
193
-
subject_uri text not null,
194
195
-
state text not null default 'unread' check(state in ('unread', 'read')),
196
-
type text not null check(type in ('follow', 'reaction')),
197
198
-
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
199
200
-
foreign key (recipient_did) references profiles(did) on delete cascade,
201
-
foreign key (actor_did) references profiles(did) on delete cascade
202
-
);
203
204
-
create table if not exists resources (
205
-
id integer primary key autoincrement,
206
207
-
did text not null,
208
-
rkey text not null,
209
210
-
title text not null,
211
-
type text not null,
212
-
author text not null,
213
-
link text,
214
-
description text not null,
215
-
status integer not null default 0 check(status in (0, 1)),
216
-
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
217
218
-
foreign key (did) references profiles (did) on delete cascade,
219
-
unique (did, rkey)
220
-
);
221
222
-
create table if not exists _jetstream (
223
-
id integer primary key autoincrement,
224
-
last_time_us integer not null
225
-
);
226
227
-
create table if not exists migrations (
228
-
id integer primary key autoincrement,
229
-
name text unique
230
-
);
231
-
`)
232
if err != nil {
233
return nil, fmt.Errorf("failed to execute db create statement: %w", err)
234
}
···
29
return nil, fmt.Errorf("failed to open db: %w", err)
30
}
31
_, err = db.Exec(`
32
+
pragma journal_mode = WAL;
33
+
pragma synchronous = normal;
34
+
pragma foreign_keys = on;
35
+
pragma temp_store = memory;
36
+
pragma mmap_size = 30000000000;
37
+
pragma page_size = 32768;
38
+
pragma auto_vacuum = incremental;
39
+
pragma busy_timeout = 5000;
40
41
+
create table if not exists oauth_requests (
42
+
id integer primary key autoincrement,
43
+
auth_server_iss text not null,
44
+
state text not null,
45
+
did text not null,
46
+
handle text not null,
47
+
pds_url text not null,
48
+
pkce_verifier text not null,
49
+
dpop_auth_server_nonce text not null,
50
+
dpop_private_jwk text not null
51
+
);
52
53
+
create table if not exists oauth_sessions (
54
+
id integer primary key autoincrement,
55
+
did text not null,
56
+
handle text not null,
57
+
pds_url text not null,
58
+
auth_server_iss text not null,
59
+
access_jwt text not null,
60
+
refresh_jwt text not null,
61
+
dpop_pds_nonce text,
62
+
dpop_auth_server_nonce text not null,
63
+
dpop_private_jwk text not null,
64
+
expiry text not null
65
+
);
66
67
+
create table if not exists profiles (
68
+
-- id
69
+
id integer primary key autoincrement,
70
+
did text not null,
71
72
+
-- data
73
+
display_name text not null,
74
+
description text,
75
+
location text,
76
+
xp integer not null default 0, -- total accumulated xp
77
+
level integer not null default 0,
78
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
79
80
+
-- constraints
81
+
unique(did)
82
+
);
83
84
+
create table if not exists profile_languages (
85
+
-- id
86
+
did text not null,
87
88
+
-- data
89
+
language_code text not null,
90
91
+
-- constraints
92
+
primary key (did, language_code),
93
+
check (length(language_code) = 2),
94
+
foreign key (did) references profiles(did) on delete cascade
95
+
);
96
97
+
create table if not exists study_sessions (
98
+
-- id
99
+
did text not null,
100
+
rkey text not null,
101
102
+
-- data
103
+
activity_id integer not null,
104
+
resource_id integer,
105
+
description text,
106
+
duration integer not null,
107
+
language_code text not null,
108
+
date text not null,
109
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
110
111
+
-- constraints
112
+
check (length(language_code) = 2),
113
+
foreign key (did) references profiles(did) on delete cascade,
114
+
foreign key (activity_id) references activities(id) on delete restrict,
115
+
foreign key (resource_id) references resources(id) on delete set null,
116
+
primary key (did, rkey)
117
+
);
118
119
+
create table if not exists categories (
120
+
id integer primary key, -- Matches StudySessionCategory iota
121
+
name text not null unique
122
+
);
123
124
+
create table if not exists activities (
125
+
id integer primary key autoincrement,
126
127
+
did text,
128
+
rkey text,
129
130
+
name text not null,
131
+
description text,
132
+
status integer not null default 0 check(status in (0, 1)),
133
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
134
135
+
foreign key (did) references profiles(did) on delete cascade,
136
+
unique (did, rkey)
137
+
);
138
139
+
create table if not exists activity_categories (
140
+
activity_id integer not null,
141
+
category_id integer not null,
142
143
+
foreign key (activity_id) references activities(id) on delete cascade,
144
+
foreign key (category_id) references categories(id) on delete cascade,
145
+
primary key (activity_id, category_id)
146
+
);
147
148
+
create table if not exists follows (
149
+
user_did text not null,
150
+
subject_did text not null,
151
152
+
rkey text not null,
153
+
followed_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
154
155
+
primary key (user_did, subject_did),
156
+
check (user_did <> subject_did)
157
+
);
158
159
+
create table if not exists xp_events (
160
+
id integer primary key autoincrement,
161
162
+
did text not null,
163
+
session_rkey text not null,
164
+
xp_gained integer not null,
165
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
166
167
+
foreign key (did) references profiles (did),
168
+
foreign key (did, session_rkey) references study_sessions (did, rkey),
169
+
unique (did, session_rkey)
170
+
);
171
172
+
create table if not exists study_session_reactions (
173
+
id integer primary key autoincrement,
174
175
+
did text not null,
176
+
rkey text not null,
177
178
+
session_did text not null,
179
+
session_rkey text not null,
180
+
reaction_id integer not null,
181
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
182
183
+
foreign key (did) references profiles (did),
184
+
foreign key (session_did, session_rkey) references study_sessions (did, rkey),
185
+
unique (did, session_did, session_rkey, reaction_id)
186
+
);
187
188
+
create table if not exists notifications (
189
+
id integer primary key autoincrement,
190
191
+
recipient_did text not null,
192
+
actor_did text not null,
193
+
subject_uri text not null,
194
195
+
state text not null default 'unread' check(state in ('unread', 'read')),
196
+
type text not null check(type in ('follow', 'reaction', 'comment')),
197
198
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
199
200
+
foreign key (recipient_did) references profiles(did) on delete cascade,
201
+
foreign key (actor_did) references profiles(did) on delete cascade
202
+
);
203
204
+
create table if not exists resources (
205
+
id integer primary key autoincrement,
206
207
+
did text not null,
208
+
rkey text not null,
209
210
+
title text not null,
211
+
type text not null,
212
+
author text not null,
213
+
link text,
214
+
description text not null,
215
+
status integer not null default 0 check(status in (0, 1)),
216
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
217
218
+
foreign key (did) references profiles (did) on delete cascade,
219
+
unique (did, rkey)
220
+
);
221
222
+
create table if not exists comments (
223
+
id integer primary key autoincrement,
224
225
+
did text not null,
226
+
rkey text not null,
227
+
228
+
study_session_uri text not null,
229
+
parent_comment_uri text,
230
+
body text not null,
231
+
is_deleted boolean not null default false,
232
+
created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
233
+
234
+
foreign key (did) references profiles(did) on delete cascade
235
+
unique (did, rkey)
236
+
);
237
+
238
+
create table if not exists _jetstream (
239
+
id integer primary key autoincrement,
240
+
last_time_us integer not null
241
+
);
242
+
243
+
create table if not exists migrations (
244
+
id integer primary key autoincrement,
245
+
name text unique
246
+
);
247
+
`)
248
if err != nil {
249
return nil, fmt.Errorf("failed to execute db create statement: %w", err)
250
}
+1
internal/server/views/new-study-session.templ
+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
ReactionRecordCreatedEvent string = "reaction-record-created"
20
ReactionRecordDeletedEvent string = "reaction-record-deleted"
21
22
ActivityDefRecordFirstCreated string = "activity-def-record-first-created"
23
ActivityDefRecordCreatedEvent string = "activity-def-record-created"
24
ActivityDefRecordDeletedEvent string = "activity-def-record-deleted"
···
19
ReactionRecordCreatedEvent string = "reaction-record-created"
20
ReactionRecordDeletedEvent string = "reaction-record-deleted"
21
22
+
CommentRecordCreatedEvent string = "comment-record-created"
23
+
CommentRecordDeletedEvent string = "comment-record-deleted"
24
+
CommentRecordEditedEvent string = "comment-record-edited"
25
+
26
ActivityDefRecordFirstCreated string = "activity-def-record-first-created"
27
ActivityDefRecordCreatedEvent string = "activity-def-record-created"
28
ActivityDefRecordDeletedEvent string = "activity-def-record-deleted"
+91
-12
internal/consumer/ingester.go
+91
-12
internal/consumer/ingester.go
···
50
err = i.ingestFollow(e)
51
case yoten.FeedReactionNSID:
52
err = i.ingestReaction(e)
53
}
54
}
55
if err != nil {
···
117
118
119
120
121
122
123
···
327
328
329
330
331
332
333
···
363
364
365
366
367
368
369
···
408
409
410
411
412
413
414
···
490
491
492
493
494
495
496
···
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
-
550
-
551
-
552
-
553
-
554
-
555
-
556
-
557
-
558
-
559
-
560
-
561
562
return nil
563
}
···
50
err = i.ingestFollow(e)
51
case yoten.FeedReactionNSID:
52
err = i.ingestReaction(e)
53
+
case yoten.FeedCommentNSID:
54
+
err = i.ingestComment(e)
55
}
56
}
57
if err != nil {
···
119
120
121
122
+
ddb, ok := i.Db.Execer.(*db.DB)
123
+
if !ok {
124
+
return fmt.Errorf("failed to index profile record: ddb not valid")
125
+
}
126
127
+
tx, err := ddb.Begin()
128
129
130
···
334
335
336
337
+
ddb, ok := i.Db.Execer.(*db.DB)
338
+
if !ok {
339
+
return fmt.Errorf("failed to index activity def record: ddb not valid")
340
+
}
341
342
+
tx, err := ddb.Begin()
343
344
345
···
375
376
377
378
+
ddb, ok := i.Db.Execer.(*db.DB)
379
+
if !ok {
380
+
return fmt.Errorf("failed to index activity def record: ddb not valid")
381
+
}
382
383
+
tx, err := ddb.Begin()
384
385
386
···
425
426
427
428
+
ddb, ok := i.Db.Execer.(*db.DB)
429
+
if !ok {
430
+
return fmt.Errorf("failed to index reaction record: ddb not valid")
431
+
}
432
433
+
tx, err := ddb.Begin()
434
435
436
···
512
513
514
515
+
ddb, ok := i.Db.Execer.(*db.DB)
516
+
if !ok {
517
+
return fmt.Errorf("failed to index resource record: ddb not valid")
518
+
}
519
520
+
tx, err := ddb.Begin()
521
522
523
···
561
562
563
564
+
return nil
565
+
}
566
567
+
func (i *Ingester) ingestComment(e *models.Event) error {
568
+
var err error
569
+
did := e.Did
570
571
+
switch e.Commit.Operation {
572
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
573
+
raw := json.RawMessage(e.Commit.Record)
574
+
record := yoten.FeedComment{}
575
+
err = json.Unmarshal(raw, &record)
576
+
if err != nil {
577
+
return fmt.Errorf("invalid record: %w", err)
578
+
}
579
580
+
subject := record.Subject
581
+
subjectUri, err := syntax.ParseATURI(subject)
582
+
if err != nil {
583
+
return fmt.Errorf("failed to parse study session at-uri: %w", err)
584
+
}
585
+
subjectDid, err := subjectUri.Authority().AsDID()
586
+
if err != nil {
587
+
return fmt.Errorf("failed to identify subject did: %w", err)
588
+
}
589
590
+
body := record.Body
591
+
if len(strings.TrimSpace(body)) == 0 {
592
+
return fmt.Errorf("invalid body: length cannot be 0")
593
+
}
594
595
+
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
596
+
if err != nil {
597
+
return fmt.Errorf("invalid createdAt format: %w", err)
598
+
}
599
600
+
ddb, ok := i.Db.Execer.(*db.DB)
601
+
if !ok {
602
+
return fmt.Errorf("failed to index resource record: ddb not valid")
603
+
}
604
605
+
tx, err := ddb.Begin()
606
+
if err != nil {
607
+
return fmt.Errorf("failed to start transaction: %w", err)
608
+
}
609
610
+
// TODO: Parse reply
611
612
+
comment := db.Comment{
613
+
Did: did,
614
+
Rkey: e.Commit.RKey,
615
+
StudySessionUri: subjectUri,
616
+
Body: body,
617
+
CreatedAt: createdAt,
618
+
}
619
620
+
log.Println("upserting comment from pds request")
621
+
err = db.UpsertComment(i.Db, comment)
622
+
if err != nil {
623
+
tx.Rollback()
624
+
return fmt.Errorf("failed to upsert comment record: %w", err)
625
+
}
626
627
+
err = db.CreateNotification(tx, subjectDid.String(), did, subjectUri.String(), db.NotificationTypeComment)
628
+
if err != nil {
629
+
log.Println("failed to create notification record:", err)
630
+
}
631
632
+
return tx.Commit()
633
+
case models.CommitOperationDelete:
634
+
log.Println("deleting comment from pds request")
635
+
err = db.DeleteCommentByRkey(i.Db, did, e.Commit.RKey)
636
+
}
637
+
if err != nil {
638
+
return fmt.Errorf("failed to %s follow record: %w", e.Commit.Operation, err)
639
+
}
640
641
return nil
642
}
+3
-1
internal/server/app.go
+3
-1
internal/server/app.go
+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
class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
31
type="button"
32
id="delete-button"
33
-
hx-disabled-elt="delete-button,#edit-button"
34
hx-delete={ templ.URL(fmt.Sprintf("/activity/%s", params.Activity.Rkey)) }
35
>
36
<i class="w-4 h-4" data-lucide="trash-2"></i>
···
30
class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
31
type="button"
32
id="delete-button"
33
+
hx-disabled-elt="#delete-button,#edit-button"
34
hx-delete={ templ.URL(fmt.Sprintf("/activity/%s", params.Activity.Rkey)) }
35
>
36
<i class="w-4 h-4" data-lucide="trash-2"></i>
+1
-1
internal/server/views/partials/resource.templ
+1
-1
internal/server/views/partials/resource.templ
···
75
class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
76
type="button"
77
id="delete-button"
78
-
hx-disabled-elt="delete-button,#edit-button"
79
hx-delete={ templ.URL(fmt.Sprintf("/resource/%s", params.Resource.Rkey)) }
80
>
81
<i class="w-4 h-4" data-lucide="trash-2"></i>
···
75
class="text-base text-red-600 flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
76
type="button"
77
id="delete-button"
78
+
hx-disabled-elt="#delete-button,#edit-button"
79
hx-delete={ templ.URL(fmt.Sprintf("/resource/%s", params.Resource.Rkey)) }
80
>
81
<i class="w-4 h-4" data-lucide="trash-2"></i>
+3
-3
internal/server/oauth/handler/handler.go
+3
-3
internal/server/oauth/handler/handler.go
···
263
}
264
}()
265
266
-
error := r.FormValue("error")
267
errorDescription := r.FormValue("error_description")
268
-
if error != "" || errorDescription != "" {
269
-
log.Printf("oauth callback error: %s, %s", error, errorDescription)
270
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
271
return
272
}
···
263
}
264
}()
265
266
+
callbackErr := r.FormValue("error")
267
errorDescription := r.FormValue("error_description")
268
+
if callbackErr != "" || errorDescription != "" {
269
+
log.Printf("oauth callback error: %s, %s", callbackErr, errorDescription)
270
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
271
return
272
}
+44
internal/server/views/partials/edit-comment.templ
+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
package db
2
3
import (
4
+
"strings"
5
+
6
"golang.org/x/text/cases"
7
"golang.org/x/text/language"
8
)
···
16
titleStr := caser.String(str)
17
return titleStr
18
}
19
+
20
+
// Generates `?, ?, ?` for SQL IN clauses.
21
+
func GetPlaceholders(count int) string {
22
+
if count < 1 {
23
+
return ""
24
+
}
25
+
return strings.Repeat("?,", count-1) + "?"
26
+
}
+31
internal/server/views/partials/comment-feed.templ
+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
+7
internal/db/notification.go
+7
internal/db/notification.go
···
12
const (
13
NotificationTypeFollow NotificationType = "follow"
14
NotificationTypeReaction NotificationType = "reaction"
15
)
16
17
type NotificationState string
···
31
RecipientDid string
32
ActorDid string
33
SubjectRkey string
34
State NotificationState
35
Type NotificationType
36
CreatedAt time.Time
···
105
return nil, fmt.Errorf("failed to parse at-uri: %w", err)
106
}
107
notification.SubjectRkey = subjectUri.RecordKey().String()
108
109
notifications = append(notifications, notification)
110
}
···
12
const (
13
NotificationTypeFollow NotificationType = "follow"
14
NotificationTypeReaction NotificationType = "reaction"
15
+
NotificationTypeComment NotificationType = "comment"
16
)
17
18
type NotificationState string
···
32
RecipientDid string
33
ActorDid string
34
SubjectRkey string
35
+
SubjectDid string
36
State NotificationState
37
Type NotificationType
38
CreatedAt time.Time
···
107
return nil, fmt.Errorf("failed to parse at-uri: %w", err)
108
}
109
notification.SubjectRkey = subjectUri.RecordKey().String()
110
+
subjectDid, err := subjectUri.Authority().AsDID()
111
+
if err != nil {
112
+
return nil, fmt.Errorf("failed to identify subject did: %w", err)
113
+
}
114
+
notification.SubjectDid = subjectDid.String()
115
116
notifications = append(notifications, notification)
117
}
+2
-2
internal/server/views/friends.templ
+2
-2
internal/server/views/friends.templ
···
11
<div class="container mx-auto max-w-2xl px-4 py-8">
12
<div class="flex items-center justify-between mb-8">
13
<div>
14
-
<h1 class="text-3xl font-bold text-gray-900">Friends</h1>
15
-
<p class="text-gray-600 mt-1">Connect with fellow language learners</p>
16
</div>
17
</div>
18
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
···
11
<div class="container mx-auto max-w-2xl px-4 py-8">
12
<div class="flex items-center justify-between mb-8">
13
<div>
14
+
<h1 class="text-3xl font-bold">Friends</h1>
15
+
<p class="mt-1">Connect with fellow language learners</p>
16
</div>
17
</div>
18
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
+17
-2
internal/server/views/partials/notification.templ
+17
-2
internal/server/views/partials/notification.templ
···
13
<h1 class="font-semibold">New Follower</h1>
14
<p class="text-sm mt-1">
15
<a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDid) }>
16
-
{ params.Notification.ActorDid }
17
</a> started following you
18
</p>
19
</div>
···
23
<p class="text-sm mt-1">
24
<a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDid) }>
25
@{ params.Notification.ActorBskyHandle }
26
-
</a> reacted to your study session
27
</p>
28
</div>
29
default:
···
13
<h1 class="font-semibold">New Follower</h1>
14
<p class="text-sm mt-1">
15
<a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDid) }>
16
+
@{ params.Notification.ActorBskyHandle }
17
</a> started following you
18
</p>
19
</div>
···
23
<p class="text-sm mt-1">
24
<a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDid) }>
25
@{ params.Notification.ActorBskyHandle }
26
+
</a> reacted to your
27
+
<a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.SubjectDid + "/session/" + params.Notification.SubjectRkey) }>
28
+
study session
29
+
</a>
30
+
</p>
31
+
</div>
32
+
case db.NotificationTypeComment:
33
+
<div>
34
+
<h1 class="font-semibold">New Comment</h1>
35
+
<p class="text-sm mt-1">
36
+
<a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.ActorDid) }>
37
+
@{ params.Notification.ActorBskyHandle }
38
+
</a> commented on your
39
+
<a class="hover:underline" href={ templ.SafeURL("/" + params.Notification.SubjectDid + "/session/" + params.Notification.SubjectRkey) }>
40
+
study session
41
+
</a>
42
</p>
43
</div>
44
default:
History
3 rounds
0 comments
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