+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
}
+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">
+28
-8
internal/db/follow.go
+28
-8
internal/db/follow.go
···
1
1
package db
2
2
3
3
import (
4
+
"database/sql"
4
5
"fmt"
6
+
"log"
5
7
"strings"
6
8
"time"
7
9
)
···
76
78
return IsSelf
77
79
}
78
80
79
-
var follows, isFollowed bool
80
81
query := `
81
-
select
82
-
exists(select 1 from follows where user_did = ? and subject_did = ?),
83
-
exists(select 1 from follows where user_did = ? and subject_did = ?)
84
-
`
85
-
err := e.QueryRow(query, userDid, subjectDid, subjectDid, userDid).Scan(&follows, &isFollowed)
82
+
SELECT
83
+
-- Count of rows where the user follows the subject
84
+
COUNT(CASE WHEN user_did = ? AND subject_did = ? THEN 1 END),
85
+
-- Count of rows where the subject follows the user
86
+
COUNT(CASE WHEN user_did = ? AND subject_did = ? THEN 1 END)
87
+
FROM
88
+
follows
89
+
WHERE
90
+
(user_did = ? AND subject_did = ?) OR (user_did = ? AND subject_did = ?);
91
+
`
92
+
93
+
var userFollowsSubject, subjectFollowsUser int
94
+
err := e.QueryRow(
95
+
query,
96
+
userDid, subjectDid,
97
+
subjectDid, userDid,
98
+
userDid, subjectDid,
99
+
subjectDid, userDid,
100
+
).Scan(&userFollowsSubject, &subjectFollowsUser)
101
+
86
102
if err != nil {
103
+
if err == sql.ErrNoRows {
104
+
return IsNotFollowing
105
+
}
106
+
log.Printf("failed to query follow status: %v", err)
87
107
return IsNotFollowing
88
108
}
89
109
90
-
if follows && isFollowed {
110
+
if userFollowsSubject > 0 && subjectFollowsUser > 0 {
91
111
return IsMutual
92
-
} else if follows {
112
+
} else if userFollowsSubject > 0 {
93
113
return IsFollowing
94
114
} else {
95
115
return IsNotFollowing
+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
+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"
+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,
+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
}
+2
internal/server/handlers/router.go
+2
internal/server/handlers/router.go
···
89
89
r.Route("/comment", func(r chi.Router) {
90
90
r.Use(middleware.AuthMiddleware(h.Oauth))
91
91
r.Post("/new", h.HandleNewComment)
92
+
r.Get("/edit/{rkey}", h.HandleEditCommentPage)
93
+
r.Post("/edit/{rkey}", h.HandleEditCommentPage)
92
94
r.Delete("/{rkey}", h.HandleDeleteComment)
93
95
})
94
96
+128
internal/db/comment.go
+128
internal/db/comment.go
···
3
3
import (
4
4
"database/sql"
5
5
"fmt"
6
+
"sort"
6
7
"time"
7
8
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
···
22
23
BskyProfile types.BskyProfile
23
24
}
24
25
26
+
type CommentWithLocalProfile struct {
27
+
Comment
28
+
ProfileLevel int
29
+
ProfileDisplayName string
30
+
}
31
+
25
32
type Comment struct {
26
33
ID int
27
34
Did string
···
119
126
120
127
return comment, nil
121
128
}
129
+
130
+
func GetCommentsForSession(e Execer, studySessionUri string, limit, offset int) ([]CommentWithLocalProfile, error) {
131
+
topLevelCommentsQuery := `
132
+
select
133
+
c.id, c.did, c.rkey, c.study_session_uri, c.parent_comment_uri,
134
+
c.body, c.is_deleted, c.created_at,
135
+
p.display_name, p.level
136
+
from comments c
137
+
join profiles p on c.did = p.did
138
+
where c.study_session_uri = ? and c.parent_comment_uri is null
139
+
order by c.created_at asc
140
+
limit ? offset ?;
141
+
`
142
+
rows, err := e.Query(topLevelCommentsQuery, studySessionUri, limit, offset)
143
+
if err != nil {
144
+
return nil, fmt.Errorf("failed to query top-level comments: %w", err)
145
+
}
146
+
defer rows.Close()
147
+
148
+
allCommentsMap := make(map[string]CommentWithLocalProfile)
149
+
var topLevelCommentUris []string
150
+
151
+
for rows.Next() {
152
+
comment, err := scanCommentWithLocalProfile(rows)
153
+
if err != nil {
154
+
return nil, err
155
+
}
156
+
allCommentsMap[comment.CommentAt().String()] = comment
157
+
topLevelCommentUris = append(topLevelCommentUris, comment.CommentAt().String())
158
+
}
159
+
if err = rows.Err(); err != nil {
160
+
return nil, fmt.Errorf("error iterating top-level comment rows: %w", err)
161
+
}
162
+
rows.Close()
163
+
164
+
if len(topLevelCommentUris) == 0 {
165
+
return []CommentWithLocalProfile{}, nil
166
+
}
167
+
168
+
repliesQuery := `
169
+
select
170
+
c.id, c.did, c.rkey, c.study_session_uri, c.parent_comment_uri,
171
+
c.body, c.is_deleted, c.created_at,
172
+
p.display_name, p.level
173
+
from comments c
174
+
join profiles p on c.did = p.did
175
+
where c.study_session_uri = ? and c.parent_comment_uri in (` + GetPlaceholders(len(topLevelCommentUris)) + `);
176
+
`
177
+
args := make([]any, len(topLevelCommentUris)+1)
178
+
args[0] = studySessionUri
179
+
for i, uri := range topLevelCommentUris {
180
+
args[i+1] = uri
181
+
}
182
+
183
+
replyRows, err := e.Query(repliesQuery, args...)
184
+
if err != nil {
185
+
return nil, fmt.Errorf("failed to query replies: %w", err)
186
+
}
187
+
defer replyRows.Close()
188
+
189
+
for replyRows.Next() {
190
+
reply, err := scanCommentWithLocalProfile(replyRows)
191
+
if err != nil {
192
+
return nil, err
193
+
}
194
+
allCommentsMap[reply.CommentAt().String()] = reply
195
+
}
196
+
if err = replyRows.Err(); err != nil {
197
+
return nil, fmt.Errorf("error iterating reply rows: %w", err)
198
+
}
199
+
200
+
finalComments := make([]CommentWithLocalProfile, 0, len(allCommentsMap))
201
+
for _, comment := range allCommentsMap {
202
+
finalComments = append(finalComments, comment)
203
+
}
204
+
205
+
sort.Slice(finalComments, func(i, j int) bool {
206
+
return finalComments[i].CreatedAt.Before(finalComments[j].CreatedAt)
207
+
})
208
+
209
+
return finalComments, nil
210
+
}
211
+
212
+
func scanCommentWithLocalProfile(rows *sql.Rows) (CommentWithLocalProfile, error) {
213
+
var comment CommentWithLocalProfile
214
+
var parentUri sql.NullString
215
+
var studySessionUriStr string
216
+
var createdAtStr string
217
+
218
+
err := rows.Scan(
219
+
&comment.ID, &comment.Did, &comment.Rkey, &studySessionUriStr,
220
+
&parentUri, &comment.Body, &comment.IsDeleted, &createdAtStr,
221
+
&comment.ProfileDisplayName, &comment.ProfileLevel,
222
+
)
223
+
if err != nil {
224
+
return CommentWithLocalProfile{}, fmt.Errorf("failed to scan comment row: %w", err)
225
+
}
226
+
227
+
comment.CreatedAt, err = time.Parse(time.RFC3339, createdAtStr)
228
+
if err != nil {
229
+
return CommentWithLocalProfile{}, fmt.Errorf("failed to parse created at string '%s': %w", createdAtStr, err)
230
+
}
231
+
232
+
parsedStudySessionUri, err := syntax.ParseATURI(studySessionUriStr)
233
+
if err != nil {
234
+
return CommentWithLocalProfile{}, fmt.Errorf("failed to parse at-uri: %w", err)
235
+
}
236
+
comment.StudySessionUri = parsedStudySessionUri
237
+
238
+
if parentUri.Valid {
239
+
parsedParentUri, err := syntax.ParseATURI(parentUri.String)
240
+
if err != nil {
241
+
return CommentWithLocalProfile{}, fmt.Errorf("failed to parse at-uri: %w", err)
242
+
}
243
+
comment.ParentCommentUri = &parsedParentUri
244
+
}
245
+
246
+
comment.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
247
+
248
+
return comment, nil
249
+
}
+10
internal/db/utils.go
+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
+
}
+36
-35
internal/server/views/partials/comment.templ
+36
-35
internal/server/views/partials/comment.templ
···
62
62
<p class="text-text-muted text-sm">@{ params.Comment.BskyProfile.Handle }</p>
63
63
</div>
64
64
</div>
65
-
// TODO: Only show on comments you own
66
-
<details class="relative inline-block text-left">
67
-
<summary class="cursor-pointer list-none">
68
-
<div class="btn btn-muted p-2">
69
-
<i class="w-4 h-4 flex-shrink-0" data-lucide="ellipsis"></i>
65
+
if params.DoesOwn {
66
+
<details class="relative inline-block text-left">
67
+
<summary class="cursor-pointer list-none">
68
+
<div class="btn btn-muted p-2">
69
+
<i class="w-4 h-4 flex-shrink-0" data-lucide="ellipsis"></i>
70
+
</div>
71
+
</summary>
72
+
<div class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-32 bg-bg-light border border-bg-dark">
73
+
<button
74
+
class="btn hover:bg-bg group justify-start px-2"
75
+
type="button"
76
+
id="edit-button"
77
+
hx-disabled-elt="#delete-button,#edit-button"
78
+
hx-target={ "#" + elementId }
79
+
hx-swap="outerHTML"
80
+
hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", params.Comment.Rkey)) }
81
+
>
82
+
<i class="w-4 h-4" data-lucide="square-pen"></i>
83
+
<span class="text-sm">Edit</span>
84
+
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
85
+
</button>
86
+
<button
87
+
class="btn text-red-600 hover:bg-bg group justify-start px-2"
88
+
type="button"
89
+
id="delete-button"
90
+
hx-disabled-elt="#delete-button,#edit-button"
91
+
hx-target={ "#" + elementId }
92
+
hx-swap="outerHTML"
93
+
hx-delete={ templ.URL(fmt.Sprintf("/comment/%s", params.Comment.Rkey)) }
94
+
>
95
+
<i class="w-4 h-4" data-lucide="trash-2"></i>
96
+
<span class="text-sm">Delete</span>
97
+
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
98
+
</button>
70
99
</div>
71
-
</summary>
72
-
<div class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-32 bg-bg-light border border-bg-dark">
73
-
<button
74
-
class="text-base cursor-pointer flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
75
-
type="button"
76
-
id="edit-button"
77
-
hx-disabled-elt="#delete-button,#edit-button"
78
-
hx-target={ "#" + elementId }
79
-
hx-swap="outerSelf"
80
-
hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", params.Comment.Rkey)) }
81
-
>
82
-
<i class="w-4 h-4" data-lucide="square-pen"></i>
83
-
Edit
84
-
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
85
-
</button>
86
-
<button
87
-
class="text-base text-red-600 cursor-pointer flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
88
-
type="button"
89
-
id="delete-button"
90
-
hx-disabled-elt="#delete-button,#edit-button"
91
-
hx-target={ "#" + elementId }
92
-
hx-swap="outerSelf"
93
-
hx-delete={ templ.URL(fmt.Sprintf("/comment/%s", params.Comment.Rkey)) }
94
-
>
95
-
<i class="w-4 h-4" data-lucide="trash-2"></i>
96
-
Delete
97
-
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
98
-
</button>
99
-
</div>
100
-
</details>
100
+
</details>
101
+
}
101
102
</div>
102
103
<p class="leading-relaxed break-words">
103
104
{ params.Comment.Body }
+1
-1
internal/server/views/partials/reactions.templ
+1
-1
internal/server/views/partials/reactions.templ
···
40
40
}
41
41
</div>
42
42
}
43
-
<div class="inline-block text-left w-fit">
43
+
<div class="inline-block text-left w-fit" title="reactions">
44
44
<button @click="open = !open" id="reaction-button" type="button" class="btn rounded-full hover:bg-bg py-1 px-2">
45
45
<i class="w-5 h-5" data-lucide="smile-plus"></i>
46
46
</button>
+1
-1
internal/server/views/partials/study-session.templ
+1
-1
internal/server/views/partials/study-session.templ
+3
-1
internal/server/views/study-session.templ
+3
-1
internal/server/views/study-session.templ
···
15
15
StudySession: params.StudySession,
16
16
})
17
17
@partials.Discussion(partials.DiscussionProps{
18
-
StudySessionUri: params.StudySession.StudySessionAt().String(),
18
+
StudySessionDid: params.StudySession.Did,
19
+
StudySessionRkey: params.StudySession.Rkey,
20
+
StudySessionUri: params.StudySession.StudySessionAt().String(),
19
21
})
20
22
</div>
21
23
}
+11
-1
internal/consumer/ingester.go
+11
-1
internal/consumer/ingester.go
···
582
582
if err != nil {
583
583
return fmt.Errorf("failed to parse study session at-uri: %w", err)
584
584
}
585
+
subjectDid, err := subjectUri.Authority().AsDID()
586
+
if err != nil {
587
+
return fmt.Errorf("failed to identify subject did: %w", err)
588
+
}
585
589
586
590
body := record.Body
587
-
if len(body) == 0 {
591
+
if len(strings.TrimSpace(body)) == 0 {
588
592
return fmt.Errorf("invalid body: length cannot be 0")
589
593
}
590
594
···
619
623
tx.Rollback()
620
624
return fmt.Errorf("failed to upsert comment record: %w", err)
621
625
}
626
+
627
+
err = db.CreateNotification(tx, subjectDid.String(), did, subjectUri.String(), db.NotificationTypeComment)
628
+
if err != nil {
629
+
log.Println("failed to create notification record:", err)
630
+
}
631
+
622
632
return tx.Commit()
623
633
case models.CommitOperationDelete:
624
634
log.Println("deleting comment from pds request")
+7
internal/db/notification.go
+7
internal/db/notification.go
···
12
12
const (
13
13
NotificationTypeFollow NotificationType = "follow"
14
14
NotificationTypeReaction NotificationType = "reaction"
15
+
NotificationTypeComment NotificationType = "comment"
15
16
)
16
17
17
18
type NotificationState string
···
31
32
RecipientDid string
32
33
ActorDid string
33
34
SubjectRkey string
35
+
SubjectDid string
34
36
State NotificationState
35
37
Type NotificationType
36
38
CreatedAt time.Time
···
105
107
return nil, fmt.Errorf("failed to parse at-uri: %w", err)
106
108
}
107
109
notification.SubjectRkey = subjectUri.RecordKey().String()
110
+
subjectDid, err := subjectUri.Authority().AsDID()
111
+
if err != nil {
112
+
return nil, fmt.Errorf("failed to identify subject did: %w", err)
113
+
}
114
+
notification.SubjectDid = subjectDid.String()
108
115
109
116
notifications = append(notifications, notification)
110
117
}
+1
-1
internal/server/handlers/comment.go
+1
-1
internal/server/handlers/comment.go
···
95
95
if !h.Config.Core.Dev {
96
96
event := posthog.Capture{
97
97
DistinctId: user.Did,
98
-
Event: ph.CommentRecordDeletedEvent,
98
+
Event: ph.CommentRecordCreatedEvent,
99
99
Properties: posthog.NewProperties().
100
100
Set("is_reply", newComment.ParentCommentUri != nil).
101
101
Set("character_count", len(newComment.Body)).