From d7a676f11b6ad947f1ba24f6f49e22c6152ad025 Mon Sep 17 00:00:00 2001 From: brookjeynes Date: Sun, 28 Sep 2025 17:34:23 +1000 Subject: [PATCH] feat: handle replies --- internal/consumer/ingester.go | 21 ++- internal/db/comment.go | 10 +- internal/server/handlers/comment.go | 135 +++++++++++++----- internal/server/handlers/router.go | 2 + internal/server/handlers/study-session.go | 27 +++- .../server/views/partials/comment-feed.templ | 1 + internal/server/views/partials/comment.templ | 60 +++----- .../server/views/partials/discussion.templ | 9 +- .../server/views/partials/edit-comment.templ | 13 +- .../server/views/partials/new-reply.templ | 39 +++++ internal/server/views/partials/partials.go | 14 ++ internal/server/views/partials/reply.templ | 76 ++++++++++ internal/server/views/study-session.templ | 1 + migrations/update_notification_type.sql | 26 ++++ 14 files changed, 341 insertions(+), 93 deletions(-) create mode 100644 internal/server/views/partials/new-reply.templ create mode 100644 internal/server/views/partials/reply.templ create mode 100644 migrations/update_notification_type.sql diff --git a/internal/consumer/ingester.go b/internal/consumer/ingester.go index 6c96e6e..48b7fb8 100644 --- a/internal/consumer/ingester.go +++ b/internal/consumer/ingester.go @@ -607,14 +607,23 @@ func (i *Ingester) ingestComment(e *models.Event) error { return fmt.Errorf("failed to start transaction: %w", err) } - // TODO: Parse reply + var parentCommentUri *syntax.ATURI = nil + reply := record.Reply + if reply != nil { + parentUri, err := syntax.ParseATURI(reply.Parent) + if err != nil { + return fmt.Errorf("failed to parse parent at-uri: %w", err) + } + parentCommentUri = &parentUri + } comment := db.Comment{ - Did: did, - Rkey: e.Commit.RKey, - StudySessionUri: subjectUri, - Body: body, - CreatedAt: createdAt, + Did: did, + Rkey: e.Commit.RKey, + StudySessionUri: subjectUri, + ParentCommentUri: parentCommentUri, + Body: body, + CreatedAt: createdAt, } log.Println("upserting comment from pds request") diff --git a/internal/db/comment.go b/internal/db/comment.go index c61c0a2..f936ba5 100644 --- a/internal/db/comment.go +++ b/internal/db/comment.go @@ -104,7 +104,7 @@ func GetCommentByRkey(e Execer, did string, rkey string) (Comment, error) { err := e.QueryRow(` select id, did, rkey, study_session_uri, parent_comment_uri, body, is_deleted, created_at from comments - where did is ? and rkey = ?`, + where did = ? and rkey = ?`, did, rkey, ).Scan(&comment.ID, &comment.Did, &comment.Rkey, &studySessionUriStr, &parentCommentUri, &comment.Body, &comment.IsDeleted, &createdAtStr) if err != nil { @@ -124,6 +124,14 @@ func GetCommentByRkey(e Execer, did string, rkey string) (Comment, error) { return Comment{}, fmt.Errorf("failed to parse study session at-uri: %w", err) } + if parentCommentUri.Valid { + parsedParentUri, err := syntax.ParseATURI(parentCommentUri.String) + if err != nil { + return Comment{}, fmt.Errorf("failed to parse at-uri: %w", err) + } + comment.ParentCommentUri = &parsedParentUri + } + return comment, nil } diff --git a/internal/server/handlers/comment.go b/internal/server/handlers/comment.go index 539e075..b87c2aa 100644 --- a/internal/server/handlers/comment.go +++ b/internal/server/handlers/comment.go @@ -65,12 +65,24 @@ func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { return } + var reply *yoten.FeedComment_Reply = nil + var parentCommentUri *string = nil + parentCommentUriStr := r.FormValue("parent_uri") + if len(parentCommentUriStr) != 0 { + parentCommentUri = &parentCommentUriStr + reply = &yoten.FeedComment_Reply{ + Parent: parentCommentUriStr, + Root: studySessionUri, + } + } + newComment := db.Comment{ - Rkey: atproto.TID(), - Did: user.Did, - StudySessionUri: syntax.ATURI(studySessionUri), - Body: commentBody, - CreatedAt: time.Now(), + Rkey: atproto.TID(), + Did: user.Did, + ParentCommentUri: (*syntax.ATURI)(parentCommentUri), + StudySessionUri: syntax.ATURI(studySessionUri), + Body: commentBody, + CreatedAt: time.Now(), } _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ @@ -82,6 +94,7 @@ func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { LexiconTypeID: yoten.FeedCommentNSID, Body: newComment.Body, Subject: newComment.StudySessionUri.String(), + Reply: reply, CreatedAt: newComment.CreatedAt.Format(time.RFC3339), }, }, @@ -112,18 +125,31 @@ func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { } } - partials.Comment(partials.CommentProps{ - Comment: db.CommentFeedItem{ - CommentWithBskyProfile: db.CommentWithBskyProfile{ + if newComment.ParentCommentUri == nil { + partials.Comment(partials.CommentProps{ + User: user, + Comment: db.CommentFeedItem{ + CommentWithBskyProfile: db.CommentWithBskyProfile{ + Comment: newComment, + ProfileLevel: profile.Level, + ProfileDisplayName: profile.DisplayName, + BskyProfile: user.BskyProfile, + }, + Replies: []db.CommentWithBskyProfile{}, + }, + DoesOwn: true, + }).Render(r.Context(), w) + } else { + partials.Reply(partials.ReplyProps{ + Reply: db.CommentWithBskyProfile{ Comment: newComment, ProfileLevel: profile.Level, ProfileDisplayName: profile.DisplayName, BskyProfile: user.BskyProfile, }, - Replies: []db.CommentWithBskyProfile{}, - }, - DoesOwn: true, - }).Render(r.Context(), w) + DoesOwn: true, + }).Render(r.Context(), w) + } } func (h *Handler) HandleDeleteComment(w http.ResponseWriter, r *http.Request) { @@ -224,13 +250,6 @@ func (h *Handler) HandleEditCommentPage(w http.ResponseWriter, r *http.Request) return } - profile, err := db.GetProfile(h.Db, user.Did) - if err != nil { - log.Println("failed to get logged-in user:", err) - htmx.HxRedirect(w, "/login") - return - } - err = r.ParseForm() if err != nil { log.Println("invalid comment form:", err) @@ -246,11 +265,20 @@ func (h *Handler) HandleEditCommentPage(w http.ResponseWriter, r *http.Request) } updatedComment := db.Comment{ - Rkey: comment.Rkey, - Did: comment.Did, - StudySessionUri: comment.StudySessionUri, - Body: commentBody, - CreatedAt: comment.CreatedAt, + Rkey: comment.Rkey, + Did: comment.Did, + StudySessionUri: comment.StudySessionUri, + ParentCommentUri: comment.ParentCommentUri, + Body: commentBody, + CreatedAt: comment.CreatedAt, + } + + var reply *yoten.FeedComment_Reply = nil + if comment.ParentCommentUri != nil { + reply = &yoten.FeedComment_Reply{ + Parent: comment.ParentCommentUri.String(), + Root: comment.StudySessionUri.String(), + } } ex, _ := client.RepoGetRecord(r.Context(), "", yoten.FeedCommentNSID, user.Did, updatedComment.Rkey) @@ -268,6 +296,7 @@ func (h *Handler) HandleEditCommentPage(w http.ResponseWriter, r *http.Request) LexiconTypeID: yoten.FeedCommentNSID, Body: updatedComment.Body, Subject: updatedComment.StudySessionUri.String(), + Reply: reply, CreatedAt: updatedComment.CreatedAt.Format(time.RFC3339), }, }, @@ -299,20 +328,8 @@ func (h *Handler) HandleEditCommentPage(w http.ResponseWriter, r *http.Request) } } - partials.Comment(partials.CommentProps{ - Comment: db.CommentFeedItem{ - CommentWithBskyProfile: db.CommentWithBskyProfile{ - Comment: updatedComment, - ProfileLevel: profile.Level, - ProfileDisplayName: profile.DisplayName, - BskyProfile: user.BskyProfile, - }, - // Replies are not needed to be populated as this response will - // replace just the edited comment. - Replies: []db.CommentWithBskyProfile{}, - }, - DoesOwn: true, - }).Render(r.Context(), w) + w.WriteHeader(http.StatusOK) + w.Write([]byte(updatedComment.Body)) } } @@ -368,3 +385,45 @@ func assembleCommentFeed(localComments []db.CommentWithLocalProfile, bskyProfile return feed } + +func (h *Handler) HandleReply(w http.ResponseWriter, r *http.Request) { + user := h.Oauth.GetUser(r) + if user == nil { + log.Println("failed to get logged-in user") + htmx.HxRedirect(w, "/login") + return + } + + studySessionUri := r.URL.Query().Get("root") + parentCommentUri := r.URL.Query().Get("parent") + if len(studySessionUri) == 0 || len(parentCommentUri) == 0 { + log.Println("invalid reply form: study session uri or parent comment uri is empty") + htmx.HxError(w, http.StatusBadRequest, "Unable to process comment, please try again later.") + return + } + + partials.NewReply(partials.NewReplyProps{ + StudySessionUri: studySessionUri, + ParentUri: parentCommentUri, + }).Render(r.Context(), w) +} + +func (h *Handler) HandleCancelCommentEdit(w http.ResponseWriter, r *http.Request) { + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) + if err != nil { + log.Println("failed to get logged-in user:", err) + htmx.HxRedirect(w, "/login") + return + } + + rkey := chi.URLParam(r, "rkey") + comment, err := db.GetCommentByRkey(h.Db, user.Did, rkey) + if err != nil { + log.Println("failed to get comment from db:", err) + htmx.HxError(w, http.StatusInternalServerError, "Failed to update comment, try again later.") + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(comment.Body)) +} diff --git a/internal/server/handlers/router.go b/internal/server/handlers/router.go index b874a58..e2ad8d1 100644 --- a/internal/server/handlers/router.go +++ b/internal/server/handlers/router.go @@ -88,6 +88,8 @@ func (h *Handler) StandardRouter(mw *middleware.Middleware) http.Handler { r.Route("/comment", func(r chi.Router) { r.Use(middleware.AuthMiddleware(h.Oauth)) + r.Get("/cancel/{rkey}", h.HandleCancelCommentEdit) + r.Get("/reply", h.HandleReply) r.Post("/new", h.HandleNewComment) r.Get("/edit/{rkey}", h.HandleEditCommentPage) r.Post("/edit/{rkey}", h.HandleEditCommentPage) diff --git a/internal/server/handlers/study-session.go b/internal/server/handlers/study-session.go index 884139e..a2615f6 100644 --- a/internal/server/handlers/study-session.go +++ b/internal/server/handlers/study-session.go @@ -718,7 +718,7 @@ func (h *Handler) HandleStudySessionPageCommentFeed(w http.ResponseWriter, r *ht page = 1 } - const pageSize = 2 + const pageSize = 10 offset := (page - 1) * pageSize commentFeed, err := db.GetCommentsForSession(h.Db, studySessionUri.String(), pageSize+1, int(offset)) @@ -732,13 +732,32 @@ func (h *Handler) HandleStudySessionPageCommentFeed(w http.ResponseWriter, r *ht return !cwlp.IsDeleted }) + topLevelComments := utils.Filter(commentFeed, func(cwlp db.CommentWithLocalProfile) bool { + return cwlp.ParentCommentUri == nil + }) + nextPage := 0 - if len(commentFeed) > pageSize { + if len(topLevelComments) > pageSize { nextPage = int(page + 1) - commentFeed = commentFeed[:pageSize] + topLevelComments = topLevelComments[:pageSize] + } + + topLevelURIs := make(map[string]struct{}) + for _, tlc := range topLevelComments { + topLevelURIs[tlc.CommentAt().String()] = struct{}{} } - populatedCommentFeed, err := h.BuildCommentFeed(commentFeed) + finalFeed := utils.Filter(commentFeed, func(cwlp db.CommentWithLocalProfile) bool { + if cwlp.ParentCommentUri == nil { + _, ok := topLevelURIs[cwlp.CommentAt().String()] + return ok + } else { + _, ok := topLevelURIs[cwlp.ParentCommentUri.String()] + return ok + } + }) + + populatedCommentFeed, err := h.BuildCommentFeed(finalFeed) if err != nil { log.Println("failed to populate comment feed:", err) htmx.HxError(w, http.StatusInternalServerError, "Failed to get comment feed, try again later.") diff --git a/internal/server/views/partials/comment-feed.templ b/internal/server/views/partials/comment-feed.templ index 308096b..40b3ef9 100644 --- a/internal/server/views/partials/comment-feed.templ +++ b/internal/server/views/partials/comment-feed.templ @@ -11,6 +11,7 @@ if params.User != nil { } }} @Comment(CommentProps{ + User: params.User, Comment: comment, DoesOwn: isSelf, }) diff --git a/internal/server/views/partials/comment.templ b/internal/server/views/partials/comment.templ index e8c2740..8d667de 100644 --- a/internal/server/views/partials/comment.templ +++ b/internal/server/views/partials/comment.templ @@ -1,40 +1,6 @@ package partials -import ( - "fmt" - "yoten.app/internal/db" -) - -templ Reply(reply db.CommentWithBskyProfile) { - {{ replyId := SanitiseHtmlId(fmt.Sprintf("reply-%s-%s", reply.Did, reply.Rkey)) }} -
-
- if reply.BskyProfile.Avatar == "" { -
- -
- } else { - - } -
-
- - { reply.ProfileDisplayName } - -

- - { reply.ProfileLevel } -

- { reply.CreatedAt.Format("2006-01-02") } -
-

@{ reply.BskyProfile.Handle }

-
-
-

- { reply.Body } -

-
-} +import "fmt" templ Comment(params CommentProps) { {{ elementId := SanitiseHtmlId(fmt.Sprintf("comment-%s-%s", params.Comment.Did, params.Comment.Rkey)) }} @@ -75,8 +41,8 @@ templ Comment(params CommentProps) { type="button" id="edit-button" hx-disabled-elt="#delete-button,#edit-button" - hx-target={ "#" + elementId } - hx-swap="outerHTML" + hx-target={ fmt.Sprintf("#comment-body-%s-%s", SanitiseHtmlId(params.Comment.Did), SanitiseHtmlId(params.Comment.Rkey)) } + hx-swap="innerHTML" hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", params.Comment.Rkey)) } > @@ -100,12 +66,28 @@ templ Comment(params CommentProps) { } -

+

{ params.Comment.Body }

+
for _, reply := range params.Comment.Replies { - @Reply(reply) + {{ isSelf := params.User != nil && params.User.Did == reply.Did }} + @Reply(ReplyProps{ + Reply: reply, + DoesOwn: isSelf, + }) }
diff --git a/internal/server/views/partials/discussion.templ b/internal/server/views/partials/discussion.templ index 23c28dc..805e520 100644 --- a/internal/server/views/partials/discussion.templ +++ b/internal/server/views/partials/discussion.templ @@ -31,7 +31,14 @@ templ Discussion(params DiscussionProps) {
/ 256
- diff --git a/internal/server/views/partials/edit-comment.templ b/internal/server/views/partials/edit-comment.templ index 80d7383..1095d1b 100644 --- a/internal/server/views/partials/edit-comment.templ +++ b/internal/server/views/partials/edit-comment.templ @@ -3,11 +3,9 @@ package partials import "fmt" templ EditComment(params EditCommentProps) { - {{ elementId := SanitiseHtmlId(fmt.Sprintf("comment-%s-%s", params.Comment.Did, params.Comment.Rkey)) }} -
+
Update Comment -
diff --git a/internal/server/views/partials/new-reply.templ b/internal/server/views/partials/new-reply.templ new file mode 100644 index 0000000..9187ebf --- /dev/null +++ b/internal/server/views/partials/new-reply.templ @@ -0,0 +1,39 @@ +package partials + +templ NewReply(params NewReplyProps) { +
+ + + +
+ +
+
+ / 256 +
+
+ + +
+
+
+ +
+} diff --git a/internal/server/views/partials/partials.go b/internal/server/views/partials/partials.go index bdb92c4..17bce1f 100644 --- a/internal/server/views/partials/partials.go +++ b/internal/server/views/partials/partials.go @@ -220,12 +220,16 @@ type NotificationFeedProps struct { } type DiscussionProps struct { + // The current logged in user + User *types.User StudySessionUri string StudySessionDid string StudySessionRkey string } type CommentProps struct { + // The current logged in user + User *types.User Comment db.CommentFeedItem DoesOwn bool } @@ -242,3 +246,13 @@ type CommentFeedProps struct { StudySessionDid string StudySessionRkey string } + +type NewReplyProps struct { + StudySessionUri string + ParentUri string +} + +type ReplyProps struct { + Reply db.CommentWithBskyProfile + DoesOwn bool +} diff --git a/internal/server/views/partials/reply.templ b/internal/server/views/partials/reply.templ new file mode 100644 index 0000000..79ad023 --- /dev/null +++ b/internal/server/views/partials/reply.templ @@ -0,0 +1,76 @@ +package partials + +import "fmt" + +templ Reply(props ReplyProps) { + {{ replyId := SanitiseHtmlId(fmt.Sprintf("reply-%s-%s", props.Reply.Did, props.Reply.Rkey)) }} +
+
+
+ if props.Reply.BskyProfile.Avatar == "" { +
+ +
+ } else { + + } +
+
+ + { props.Reply.ProfileDisplayName } + +

+ + { props.Reply.ProfileLevel } +

+ { props.Reply.CreatedAt.Format("2006-01-02") } +
+

@{ props.Reply.BskyProfile.Handle }

+
+
+ if props.DoesOwn { +
+ +
+ +
+
+
+ + +
+
+ } +
+

+ { props.Reply.Body } +

+
+} diff --git a/internal/server/views/study-session.templ b/internal/server/views/study-session.templ index 2f47c21..56673e4 100644 --- a/internal/server/views/study-session.templ +++ b/internal/server/views/study-session.templ @@ -15,6 +15,7 @@ templ StudySessionPage(params StudySessionPageParams) { StudySession: params.StudySession, }) @partials.Discussion(partials.DiscussionProps{ + User: params.User, StudySessionDid: params.StudySession.Did, StudySessionRkey: params.StudySession.Rkey, StudySessionUri: params.StudySession.StudySessionAt().String(), diff --git a/migrations/update_notification_type.sql b/migrations/update_notification_type.sql new file mode 100644 index 0000000..42d0c0e --- /dev/null +++ b/migrations/update_notification_type.sql @@ -0,0 +1,26 @@ +-- This script should be used and updated whenever a new notification type +-- constraint needs to be added. + +BEGIN TRANSACTION; + +ALTER TABLE notifications RENAME TO notifications_old; + +CREATE TABLE notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient_did TEXT NOT NULL, + actor_did TEXT NOT NULL, + subject_uri TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'unread' CHECK(state IN ('unread', 'read')), + type TEXT NOT NULL CHECK(type IN ('follow', 'reaction', 'comment')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + FOREIGN KEY (recipient_did) REFERENCES profiles(did) ON DELETE CASCADE, + FOREIGN KEY (actor_did) REFERENCES profiles(did) ON DELETE CASCADE +); + +INSERT INTO notifications (id, recipient_did, actor_did, subject_uri, state, type, created_at) +SELECT id, recipient_did, actor_did, subject_uri, state, type, created_at +FROM notifications_old; + +DROP TABLE notifications_old; + +COMMIT; -- 2.43.0 From de1d829e166b0ec64fde0c92774f0442d46edb9c Mon Sep 17 00:00:00 2001 From: brookjeynes Date: Sun, 28 Sep 2025 19:24:32 +1000 Subject: [PATCH] feat: fix cancel button from reply --- internal/server/views/partials/comment-feed.templ | 3 +-- internal/server/views/partials/new-reply.templ | 9 +++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/server/views/partials/comment-feed.templ b/internal/server/views/partials/comment-feed.templ index 40b3ef9..39c8475 100644 --- a/internal/server/views/partials/comment-feed.templ +++ b/internal/server/views/partials/comment-feed.templ @@ -19,8 +19,7 @@ if params.User != nil { if params.NextPage > 0 {
diff --git a/internal/server/views/partials/new-reply.templ b/internal/server/views/partials/new-reply.templ index 9187ebf..74a8579 100644 --- a/internal/server/views/partials/new-reply.templ +++ b/internal/server/views/partials/new-reply.templ @@ -1,7 +1,7 @@ package partials templ NewReply(params NewReplyProps) { -
+
Reply -
-- 2.43.0 From a49edabb89898489d6fc4ffebc147521386b2c5b Mon Sep 17 00:00:00 2001 From: brookjeynes Date: Tue, 30 Sep 2025 15:07:26 +1000 Subject: [PATCH] feat: notify people on replies --- internal/consumer/ingester.go | 17 ++++++++++++++--- internal/db/notification.go | 1 + migrations/update_notification_type.sql | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/consumer/ingester.go b/internal/consumer/ingester.go index 48b7fb8..e0ba271 100644 --- a/internal/consumer/ingester.go +++ b/internal/consumer/ingester.go @@ -633,9 +633,20 @@ func (i *Ingester) ingestComment(e *models.Event) error { return fmt.Errorf("failed to upsert comment record: %w", err) } - err = db.CreateNotification(tx, subjectDid.String(), did, subjectUri.String(), db.NotificationTypeComment) - if err != nil { - log.Println("failed to create notification record:", err) + // Create a comment if not commenting on self post. + if subjectDid.String() != did { + err = db.CreateNotification(tx, subjectDid.String(), did, subjectUri.String(), db.NotificationTypeComment) + if err != nil { + log.Println("failed to create notification record:", err) + } + } + + // Notify comment creator of reply if not replying to their own comment. + if comment.ParentCommentUri != nil && comment.ParentCommentUri.Authority().String() != did { + err = db.CreateNotification(tx, comment.ParentCommentUri.Authority().String(), did, parentCommentUri.String(), db.NotificationTypeReply) + if err != nil { + log.Println("failed to create notification record:", err) + } } return tx.Commit() diff --git a/internal/db/notification.go b/internal/db/notification.go index e8922ee..4f2031f 100644 --- a/internal/db/notification.go +++ b/internal/db/notification.go @@ -13,6 +13,7 @@ const ( NotificationTypeFollow NotificationType = "follow" NotificationTypeReaction NotificationType = "reaction" NotificationTypeComment NotificationType = "comment" + NotificationTypeReply NotificationType = "reply" ) type NotificationState string diff --git a/migrations/update_notification_type.sql b/migrations/update_notification_type.sql index 42d0c0e..01f1cc2 100644 --- a/migrations/update_notification_type.sql +++ b/migrations/update_notification_type.sql @@ -11,7 +11,7 @@ CREATE TABLE notifications ( actor_did TEXT NOT NULL, subject_uri TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'unread' CHECK(state IN ('unread', 'read')), - type TEXT NOT NULL CHECK(type IN ('follow', 'reaction', 'comment')), + type TEXT NOT NULL CHECK(type IN ('follow', 'reaction', 'comment', 'reply')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), FOREIGN KEY (recipient_did) REFERENCES profiles(did) ON DELETE CASCADE, FOREIGN KEY (actor_did) REFERENCES profiles(did) ON DELETE CASCADE -- 2.43.0