feat: replies #2

merged
opened by brookjeynes.dev targeting master from bj/2025-09-22/feat/replies

Adds replies to Yoten

+15 -6
internal/consumer/ingester.go
···
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")
+9 -1
internal/db/comment.go
···
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 {
···
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
}
+97 -38
internal/server/handlers/comment.go
···
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{
···
LexiconTypeID: yoten.FeedCommentNSID,
Body: newComment.Body,
Subject: newComment.StudySessionUri.String(),
+
Reply: reply,
CreatedAt: newComment.CreatedAt.Format(time.RFC3339),
},
},
···
}
}
-
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) {
···
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)
···
}
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)
···
LexiconTypeID: yoten.FeedCommentNSID,
Body: updatedComment.Body,
Subject: updatedComment.StudySessionUri.String(),
+
Reply: reply,
CreatedAt: updatedComment.CreatedAt.Format(time.RFC3339),
},
},
···
}
}
-
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))
}
}
···
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))
+
}
+2
internal/server/handlers/router.go
···
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)
+23 -4
internal/server/handlers/study-session.go
···
page = 1
}
-
const pageSize = 2
+
const pageSize = 10
offset := (page - 1) * pageSize
commentFeed, err := db.GetCommentsForSession(h.Db, studySessionUri.String(), pageSize+1, int(offset))
···
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.")
+21 -39
internal/server/views/partials/comment.templ
···
package partials
-
import (
-
"fmt"
-
"yoten.app/internal/db"
-
)
-
-
templ Reply(reply db.CommentWithBskyProfile) {
-
{{ replyId := SanitiseHtmlId(fmt.Sprintf("reply-%s-%s", reply.Did, reply.Rkey)) }}
-
<div id={ replyId } class="flex flex-col gap-3 pl-4 py-2 border-l-2 border-gray-200">
-
<div class="flex items-center gap-3">
-
if reply.BskyProfile.Avatar == "" {
-
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-primary">
-
<i class="w-7 h-7" data-lucide="user"></i>
-
</div>
-
} else {
-
<img src={ reply.BskyProfile.Avatar } class="w-10 h-10 rounded-full"/>
-
}
-
<div>
-
<div class="flex items-center gap-2">
-
<a href={ templ.URL(fmt.Sprintf("/@%s", reply.Did)) } class="font-semibold">
-
{ reply.ProfileDisplayName }
-
</a>
-
<p class="pill pill-secondary px-2 py-0.5 h-fit items-center justify-center gap-1 w-fit flex">
-
<i class="w-3.5 h-3.5" data-lucide="star"></i>
-
<span class="text-xs">{ reply.ProfileLevel }</span>
-
</p>
-
<span class="text-xs text-text-muted">{ reply.CreatedAt.Format("2006-01-02") }</span>
-
</div>
-
<p class="text-text-muted text-sm">&commat;{ reply.BskyProfile.Handle }</p>
-
</div>
-
</div>
-
<p class="leading-relaxed">
-
{ reply.Body }
-
</p>
-
</div>
-
}
+
import "fmt"
templ Comment(params CommentProps) {
{{ elementId := SanitiseHtmlId(fmt.Sprintf("comment-%s-%s", params.Comment.Did, params.Comment.Rkey)) }}
···
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)) }
>
<i class="w-4 h-4" data-lucide="square-pen"></i>
···
</details>
}
</div>
-
<p class="leading-relaxed break-words">
+
<p
+
id={ fmt.Sprintf("comment-body-%s-%s", SanitiseHtmlId(params.Comment.Did), SanitiseHtmlId(params.Comment.Rkey)) }
+
class="leading-relaxed break-words"
+
>
{ params.Comment.Body }
</p>
+
<button
+
hx-swap="afterend"
+
id="reply-button"
+
type="button"
+
hx-get={ templ.URL(fmt.Sprintf("/comment/reply?root=%s&parent=%s", params.Comment.StudySessionUri.String(), params.Comment.CommentAt().String())) }
+
class="btn text-xs text-text-muted self-start w-fit p-0"
+
>
+
Reply
+
</button>
<div class="flex flex-col mt-2">
for _, reply := range params.Comment.Replies {
-
@Reply(reply)
+
{{ isSelf := params.User != nil && params.User.Did == reply.Did }}
+
@Reply(ReplyProps{
+
Reply: reply,
+
DoesOwn: isSelf,
+
})
}
</div>
</div>
+8 -1
internal/server/views/partials/discussion.templ
···
<div class="text-sm text-text-muted">
<span x-text="text.length"></span> / 256
</div>
-
<button type="submit" id="post-comment-button" class="btn btn-primary w-fit">
+
<button
+
if params.User == nil {
+
disabled
+
}
+
type="submit"
+
id="post-comment-button"
+
class="btn btn-primary w-fit"
+
>
Post Comment
</button>
</div>
+9 -4
internal/server/views/partials/edit-comment.templ
···
import "fmt"
templ EditComment(params EditCommentProps) {
-
{{ elementId := SanitiseHtmlId(fmt.Sprintf("comment-%s-%s", params.Comment.Did, params.Comment.Rkey)) }}
-
<div id={ elementId } class="flex flex-col gap-3" x-init="lucide.createIcons()">
+
<div class="flex flex-col gap-3" x-init="lucide.createIcons()">
<form
hx-post={ templ.SafeURL("/comment/edit/" + params.Comment.Rkey) }
-
hx-target={ "#" + elementId }
hx-swap="outerHTML"
hx-disabled-elt="#update-comment-button,#cancel-comment-button"
x-data="{ text: '' }"
···
<button type="submit" id="update-comment-button" class="btn btn-primary w-fit">
Update Comment
</button>
-
<button id="cancel-comment-button" class="btn btn-muted w-fit">
+
<button
+
hx-get={ templ.SafeURL("/comment/cancel/" + params.Comment.Rkey) }
+
hx-target={ fmt.Sprintf("#comment-body-%s-%s", SanitiseHtmlId(params.Comment.Did), SanitiseHtmlId(params.Comment.Rkey)) }
+
hx-swap="innerHTML"
+
type="button"
+
id="cancel-comment-button"
+
class="btn btn-muted w-fit"
+
>
Cancel
</button>
</div>
+14
internal/server/views/partials/partials.go
···
}
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
}
···
StudySessionDid string
StudySessionRkey string
}
+
+
type NewReplyProps struct {
+
StudySessionUri string
+
ParentUri string
+
}
+
+
type ReplyProps struct {
+
Reply db.CommentWithBskyProfile
+
DoesOwn bool
+
}
+76
internal/server/views/partials/reply.templ
···
+
package partials
+
+
import "fmt"
+
+
templ Reply(props ReplyProps) {
+
{{ replyId := SanitiseHtmlId(fmt.Sprintf("reply-%s-%s", props.Reply.Did, props.Reply.Rkey)) }}
+
<div id={ replyId } class="flex flex-col gap-3 pl-4 py-2 border-l-2 border-gray-200" x-init="lucide.createIcons()">
+
<div class="flex items-center justify-between">
+
<div class="flex items-center gap-3">
+
if props.Reply.BskyProfile.Avatar == "" {
+
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-primary">
+
<i class="w-7 h-7" data-lucide="user"></i>
+
</div>
+
} else {
+
<img src={ props.Reply.BskyProfile.Avatar } class="w-10 h-10 rounded-full"/>
+
}
+
<div>
+
<div class="flex items-center gap-2">
+
<a href={ templ.URL(fmt.Sprintf("/@%s", props.Reply.Did)) } class="font-semibold">
+
{ props.Reply.ProfileDisplayName }
+
</a>
+
<p class="pill pill-secondary px-2 py-0.5 h-fit items-center justify-center gap-1 w-fit flex">
+
<i class="w-3.5 h-3.5" data-lucide="star"></i>
+
<span class="text-xs">{ props.Reply.ProfileLevel }</span>
+
</p>
+
<span class="text-xs text-text-muted">{ props.Reply.CreatedAt.Format("2006-01-02") }</span>
+
</div>
+
<p class="text-text-muted text-sm">&commat;{ props.Reply.BskyProfile.Handle }</p>
+
</div>
+
</div>
+
if props.DoesOwn {
+
<details class="relative inline-block text-left">
+
<summary class="cursor-pointer list-none">
+
<div class="btn btn-muted p-2">
+
<i class="w-4 h-4 flex-shrink-0" data-lucide="ellipsis"></i>
+
</div>
+
</summary>
+
<div class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-32 bg-bg-light border border-bg-dark">
+
<button
+
class="btn hover:bg-bg group justify-start px-2"
+
type="button"
+
id="edit-button"
+
hx-disabled-elt="#delete-button,#edit-button"
+
hx-target={ fmt.Sprintf("#comment-body-%s-%s", SanitiseHtmlId(props.Reply.Did), SanitiseHtmlId(props.Reply.Rkey)) }
+
hx-swap="innerHTML"
+
hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", props.Reply.Rkey)) }
+
>
+
<i class="w-4 h-4" data-lucide="square-pen"></i>
+
<span class="text-sm">Edit</span>
+
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
+
</button>
+
<button
+
class="btn text-red-600 hover:bg-bg group justify-start px-2"
+
type="button"
+
id="delete-button"
+
hx-disabled-elt="#delete-button,#edit-button"
+
hx-target={ "#" + replyId }
+
hx-swap="outerHTML"
+
hx-delete={ templ.URL(fmt.Sprintf("/comment/%s", props.Reply.Rkey)) }
+
>
+
<i class="w-4 h-4" data-lucide="trash-2"></i>
+
<span class="text-sm">Delete</span>
+
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
+
</button>
+
</div>
+
</details>
+
}
+
</div>
+
<p
+
id={ fmt.Sprintf("comment-body-%s-%s", SanitiseHtmlId(props.Reply.Did), SanitiseHtmlId(props.Reply.Rkey)) }
+
class="leading-relaxed break-words"
+
>
+
{ props.Reply.Body }
+
</p>
+
</div>
+
}
+1
internal/server/views/study-session.templ
···
StudySession: params.StudySession,
})
@partials.Discussion(partials.DiscussionProps{
+
User: params.User,
StudySessionDid: params.StudySession.Did,
StudySessionRkey: params.StudySession.Rkey,
StudySessionUri: params.StudySession.StudySessionAt().String(),
+26
migrations/update_notification_type.sql
···
+
-- 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;