feat: replies #2

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

Adds replies to Yoten

+9 -1
internal/db/comment.go
··· 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 { ··· 124 return Comment{}, fmt.Errorf("failed to parse study session at-uri: %w", err) 125 } 126 127 return comment, nil 128 } 129
··· 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 = ? and rkey = ?`, 108 did, rkey, 109 ).Scan(&comment.ID, &comment.Did, &comment.Rkey, &studySessionUriStr, &parentCommentUri, &comment.Body, &comment.IsDeleted, &createdAtStr) 110 if err != nil { ··· 124 return Comment{}, fmt.Errorf("failed to parse study session at-uri: %w", err) 125 } 126 127 + if parentCommentUri.Valid { 128 + parsedParentUri, err := syntax.ParseATURI(parentCommentUri.String) 129 + if err != nil { 130 + return Comment{}, fmt.Errorf("failed to parse at-uri: %w", err) 131 + } 132 + comment.ParentCommentUri = &parsedParentUri 133 + } 134 + 135 return comment, nil 136 } 137
+97 -38
internal/server/handlers/comment.go
··· 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{ ··· 82 LexiconTypeID: yoten.FeedCommentNSID, 83 Body: newComment.Body, 84 Subject: newComment.StudySessionUri.String(), 85 CreatedAt: newComment.CreatedAt.Format(time.RFC3339), 86 }, 87 }, ··· 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) { ··· 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) ··· 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) ··· 268 LexiconTypeID: yoten.FeedCommentNSID, 269 Body: updatedComment.Body, 270 Subject: updatedComment.StudySessionUri.String(), 271 CreatedAt: updatedComment.CreatedAt.Format(time.RFC3339), 272 }, 273 }, ··· 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 ··· 368 369 return feed 370 }
··· 65 return 66 } 67 68 + var reply *yoten.FeedComment_Reply = nil 69 + var parentCommentUri *string = nil 70 + parentCommentUriStr := r.FormValue("parent_uri") 71 + if len(parentCommentUriStr) != 0 { 72 + parentCommentUri = &parentCommentUriStr 73 + reply = &yoten.FeedComment_Reply{ 74 + Parent: parentCommentUriStr, 75 + Root: studySessionUri, 76 + } 77 + } 78 + 79 newComment := db.Comment{ 80 + Rkey: atproto.TID(), 81 + Did: user.Did, 82 + ParentCommentUri: (*syntax.ATURI)(parentCommentUri), 83 + StudySessionUri: syntax.ATURI(studySessionUri), 84 + Body: commentBody, 85 + CreatedAt: time.Now(), 86 } 87 88 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ ··· 94 LexiconTypeID: yoten.FeedCommentNSID, 95 Body: newComment.Body, 96 Subject: newComment.StudySessionUri.String(), 97 + Reply: reply, 98 CreatedAt: newComment.CreatedAt.Format(time.RFC3339), 99 }, 100 }, ··· 125 } 126 } 127 128 + if newComment.ParentCommentUri == nil { 129 + partials.Comment(partials.CommentProps{ 130 + User: user, 131 + Comment: db.CommentFeedItem{ 132 + CommentWithBskyProfile: db.CommentWithBskyProfile{ 133 + Comment: newComment, 134 + ProfileLevel: profile.Level, 135 + ProfileDisplayName: profile.DisplayName, 136 + BskyProfile: user.BskyProfile, 137 + }, 138 + Replies: []db.CommentWithBskyProfile{}, 139 + }, 140 + DoesOwn: true, 141 + }).Render(r.Context(), w) 142 + } else { 143 + partials.Reply(partials.ReplyProps{ 144 + Reply: db.CommentWithBskyProfile{ 145 Comment: newComment, 146 ProfileLevel: profile.Level, 147 ProfileDisplayName: profile.DisplayName, 148 BskyProfile: user.BskyProfile, 149 }, 150 + DoesOwn: true, 151 + }).Render(r.Context(), w) 152 + } 153 } 154 155 func (h *Handler) HandleDeleteComment(w http.ResponseWriter, r *http.Request) { ··· 250 return 251 } 252 253 err = r.ParseForm() 254 if err != nil { 255 log.Println("invalid comment form:", err) ··· 265 } 266 267 updatedComment := db.Comment{ 268 + Rkey: comment.Rkey, 269 + Did: comment.Did, 270 + StudySessionUri: comment.StudySessionUri, 271 + ParentCommentUri: comment.ParentCommentUri, 272 + Body: commentBody, 273 + CreatedAt: comment.CreatedAt, 274 + } 275 + 276 + var reply *yoten.FeedComment_Reply = nil 277 + if comment.ParentCommentUri != nil { 278 + reply = &yoten.FeedComment_Reply{ 279 + Parent: comment.ParentCommentUri.String(), 280 + Root: comment.StudySessionUri.String(), 281 + } 282 } 283 284 ex, _ := client.RepoGetRecord(r.Context(), "", yoten.FeedCommentNSID, user.Did, updatedComment.Rkey) ··· 296 LexiconTypeID: yoten.FeedCommentNSID, 297 Body: updatedComment.Body, 298 Subject: updatedComment.StudySessionUri.String(), 299 + Reply: reply, 300 CreatedAt: updatedComment.CreatedAt.Format(time.RFC3339), 301 }, 302 }, ··· 328 } 329 } 330 331 + w.WriteHeader(http.StatusOK) 332 + w.Write([]byte(updatedComment.Body)) 333 } 334 } 335 ··· 385 386 return feed 387 } 388 + 389 + func (h *Handler) HandleReply(w http.ResponseWriter, r *http.Request) { 390 + user := h.Oauth.GetUser(r) 391 + if user == nil { 392 + log.Println("failed to get logged-in user") 393 + htmx.HxRedirect(w, "/login") 394 + return 395 + } 396 + 397 + studySessionUri := r.URL.Query().Get("root") 398 + parentCommentUri := r.URL.Query().Get("parent") 399 + if len(studySessionUri) == 0 || len(parentCommentUri) == 0 { 400 + log.Println("invalid reply form: study session uri or parent comment uri is empty") 401 + htmx.HxError(w, http.StatusBadRequest, "Unable to process comment, please try again later.") 402 + return 403 + } 404 + 405 + partials.NewReply(partials.NewReplyProps{ 406 + StudySessionUri: studySessionUri, 407 + ParentUri: parentCommentUri, 408 + }).Render(r.Context(), w) 409 + } 410 + 411 + func (h *Handler) HandleCancelCommentEdit(w http.ResponseWriter, r *http.Request) { 412 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 413 + if err != nil { 414 + log.Println("failed to get logged-in user:", err) 415 + htmx.HxRedirect(w, "/login") 416 + return 417 + } 418 + 419 + rkey := chi.URLParam(r, "rkey") 420 + comment, err := db.GetCommentByRkey(h.Db, user.Did, rkey) 421 + if err != nil { 422 + log.Println("failed to get comment from db:", err) 423 + htmx.HxError(w, http.StatusInternalServerError, "Failed to update comment, try again later.") 424 + return 425 + } 426 + 427 + w.WriteHeader(http.StatusOK) 428 + w.Write([]byte(comment.Body)) 429 + }
+2
internal/server/handlers/router.go
··· 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)
··· 88 89 r.Route("/comment", func(r chi.Router) { 90 r.Use(middleware.AuthMiddleware(h.Oauth)) 91 + r.Get("/cancel/{rkey}", h.HandleCancelCommentEdit) 92 + r.Get("/reply", h.HandleReply) 93 r.Post("/new", h.HandleNewComment) 94 r.Get("/edit/{rkey}", h.HandleEditCommentPage) 95 r.Post("/edit/{rkey}", h.HandleEditCommentPage)
+23 -4
internal/server/handlers/study-session.go
··· 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)) ··· 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.")
··· 718 page = 1 719 } 720 721 + const pageSize = 10 722 offset := (page - 1) * pageSize 723 724 commentFeed, err := db.GetCommentsForSession(h.Db, studySessionUri.String(), pageSize+1, int(offset)) ··· 732 return !cwlp.IsDeleted 733 }) 734 735 + topLevelComments := utils.Filter(commentFeed, func(cwlp db.CommentWithLocalProfile) bool { 736 + return cwlp.ParentCommentUri == nil 737 + }) 738 + 739 nextPage := 0 740 + if len(topLevelComments) > pageSize { 741 nextPage = int(page + 1) 742 + topLevelComments = topLevelComments[:pageSize] 743 + } 744 + 745 + topLevelURIs := make(map[string]struct{}) 746 + for _, tlc := range topLevelComments { 747 + topLevelURIs[tlc.CommentAt().String()] = struct{}{} 748 } 749 750 + finalFeed := utils.Filter(commentFeed, func(cwlp db.CommentWithLocalProfile) bool { 751 + if cwlp.ParentCommentUri == nil { 752 + _, ok := topLevelURIs[cwlp.CommentAt().String()] 753 + return ok 754 + } else { 755 + _, ok := topLevelURIs[cwlp.ParentCommentUri.String()] 756 + return ok 757 + } 758 + }) 759 + 760 + populatedCommentFeed, err := h.BuildCommentFeed(finalFeed) 761 if err != nil { 762 log.Println("failed to populate comment feed:", err) 763 htmx.HxError(w, http.StatusInternalServerError, "Failed to get comment feed, try again later.")
+21 -39
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">&commat;{ 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)) }} ··· 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> ··· 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>
··· 1 package partials 2 3 + import "fmt" 4 5 templ Comment(params CommentProps) { 6 {{ elementId := SanitiseHtmlId(fmt.Sprintf("comment-%s-%s", params.Comment.Did, params.Comment.Rkey)) }} ··· 41 type="button" 42 id="edit-button" 43 hx-disabled-elt="#delete-button,#edit-button" 44 + hx-target={ fmt.Sprintf("#comment-body-%s-%s", SanitiseHtmlId(params.Comment.Did), SanitiseHtmlId(params.Comment.Rkey)) } 45 + hx-swap="innerHTML" 46 hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", params.Comment.Rkey)) } 47 > 48 <i class="w-4 h-4" data-lucide="square-pen"></i> ··· 66 </details> 67 } 68 </div> 69 + <p 70 + id={ fmt.Sprintf("comment-body-%s-%s", SanitiseHtmlId(params.Comment.Did), SanitiseHtmlId(params.Comment.Rkey)) } 71 + class="leading-relaxed break-words" 72 + > 73 { params.Comment.Body } 74 </p> 75 + <button 76 + hx-swap="afterend" 77 + id="reply-button" 78 + type="button" 79 + hx-get={ templ.URL(fmt.Sprintf("/comment/reply?root=%s&parent=%s", params.Comment.StudySessionUri.String(), params.Comment.CommentAt().String())) } 80 + class="btn text-xs text-text-muted self-start w-fit p-0" 81 + > 82 + Reply 83 + </button> 84 <div class="flex flex-col mt-2"> 85 for _, reply := range params.Comment.Replies { 86 + {{ isSelf := params.User != nil && params.User.Did == reply.Did }} 87 + @Reply(ReplyProps{ 88 + Reply: reply, 89 + DoesOwn: isSelf, 90 + }) 91 } 92 </div> 93 </div>
+8 -1
internal/server/views/partials/discussion.templ
··· 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>
··· 31 <div class="text-sm text-text-muted"> 32 <span x-text="text.length"></span> / 256 33 </div> 34 + <button 35 + if params.User == nil { 36 + disabled 37 + } 38 + type="submit" 39 + id="post-comment-button" 40 + class="btn btn-primary w-fit" 41 + > 42 Post Comment 43 </button> 44 </div>
+9 -4
internal/server/views/partials/edit-comment.templ
··· 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: '' }" ··· 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>
··· 3 import "fmt" 4 5 templ EditComment(params EditCommentProps) { 6 + <div class="flex flex-col gap-3" x-init="lucide.createIcons()"> 7 <form 8 hx-post={ templ.SafeURL("/comment/edit/" + params.Comment.Rkey) } 9 hx-swap="outerHTML" 10 hx-disabled-elt="#update-comment-button,#cancel-comment-button" 11 x-data="{ text: '' }" ··· 31 <button type="submit" id="update-comment-button" class="btn btn-primary w-fit"> 32 Update Comment 33 </button> 34 + <button 35 + hx-get={ templ.SafeURL("/comment/cancel/" + params.Comment.Rkey) } 36 + hx-target={ fmt.Sprintf("#comment-body-%s-%s", SanitiseHtmlId(params.Comment.Did), SanitiseHtmlId(params.Comment.Rkey)) } 37 + hx-swap="innerHTML" 38 + type="button" 39 + id="cancel-comment-button" 40 + class="btn btn-muted w-fit" 41 + > 42 Cancel 43 </button> 44 </div>
+14
internal/server/views/partials/partials.go
··· 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 } ··· 242 StudySessionDid string 243 StudySessionRkey string 244 }
··· 220 } 221 222 type DiscussionProps struct { 223 + // The current logged in user 224 + User *types.User 225 StudySessionUri string 226 StudySessionDid string 227 StudySessionRkey string 228 } 229 230 type CommentProps struct { 231 + // The current logged in user 232 + User *types.User 233 Comment db.CommentFeedItem 234 DoesOwn bool 235 } ··· 246 StudySessionDid string 247 StudySessionRkey string 248 } 249 + 250 + type NewReplyProps struct { 251 + StudySessionUri string 252 + ParentUri string 253 + } 254 + 255 + type ReplyProps struct { 256 + Reply db.CommentWithBskyProfile 257 + DoesOwn bool 258 + }
+76
internal/server/views/partials/reply.templ
···
··· 1 + package partials 2 + 3 + import "fmt" 4 + 5 + templ Reply(props ReplyProps) { 6 + {{ replyId := SanitiseHtmlId(fmt.Sprintf("reply-%s-%s", props.Reply.Did, props.Reply.Rkey)) }} 7 + <div id={ replyId } class="flex flex-col gap-3 pl-4 py-2 border-l-2 border-gray-200" x-init="lucide.createIcons()"> 8 + <div class="flex items-center justify-between"> 9 + <div class="flex items-center gap-3"> 10 + if props.Reply.BskyProfile.Avatar == "" { 11 + <div class="flex items-center justify-center w-10 h-10 rounded-full bg-primary"> 12 + <i class="w-7 h-7" data-lucide="user"></i> 13 + </div> 14 + } else { 15 + <img src={ props.Reply.BskyProfile.Avatar } class="w-10 h-10 rounded-full"/> 16 + } 17 + <div> 18 + <div class="flex items-center gap-2"> 19 + <a href={ templ.URL(fmt.Sprintf("/@%s", props.Reply.Did)) } class="font-semibold"> 20 + { props.Reply.ProfileDisplayName } 21 + </a> 22 + <p class="pill pill-secondary px-2 py-0.5 h-fit items-center justify-center gap-1 w-fit flex"> 23 + <i class="w-3.5 h-3.5" data-lucide="star"></i> 24 + <span class="text-xs">{ props.Reply.ProfileLevel }</span> 25 + </p> 26 + <span class="text-xs text-text-muted">{ props.Reply.CreatedAt.Format("2006-01-02") }</span> 27 + </div> 28 + <p class="text-text-muted text-sm">&commat;{ props.Reply.BskyProfile.Handle }</p> 29 + </div> 30 + </div> 31 + if props.DoesOwn { 32 + <details class="relative inline-block text-left"> 33 + <summary class="cursor-pointer list-none"> 34 + <div class="btn btn-muted p-2"> 35 + <i class="w-4 h-4 flex-shrink-0" data-lucide="ellipsis"></i> 36 + </div> 37 + </summary> 38 + <div class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-32 bg-bg-light border border-bg-dark"> 39 + <button 40 + class="btn hover:bg-bg group justify-start px-2" 41 + type="button" 42 + id="edit-button" 43 + hx-disabled-elt="#delete-button,#edit-button" 44 + hx-target={ fmt.Sprintf("#comment-body-%s-%s", SanitiseHtmlId(props.Reply.Did), SanitiseHtmlId(props.Reply.Rkey)) } 45 + hx-swap="innerHTML" 46 + hx-get={ templ.URL(fmt.Sprintf("/comment/edit/%s", props.Reply.Rkey)) } 47 + > 48 + <i class="w-4 h-4" data-lucide="square-pen"></i> 49 + <span class="text-sm">Edit</span> 50 + <i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i> 51 + </button> 52 + <button 53 + class="btn text-red-600 hover:bg-bg group justify-start px-2" 54 + type="button" 55 + id="delete-button" 56 + hx-disabled-elt="#delete-button,#edit-button" 57 + hx-target={ "#" + replyId } 58 + hx-swap="outerHTML" 59 + hx-delete={ templ.URL(fmt.Sprintf("/comment/%s", props.Reply.Rkey)) } 60 + > 61 + <i class="w-4 h-4" data-lucide="trash-2"></i> 62 + <span class="text-sm">Delete</span> 63 + <i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i> 64 + </button> 65 + </div> 66 + </details> 67 + } 68 + </div> 69 + <p 70 + id={ fmt.Sprintf("comment-body-%s-%s", SanitiseHtmlId(props.Reply.Did), SanitiseHtmlId(props.Reply.Rkey)) } 71 + class="leading-relaxed break-words" 72 + > 73 + { props.Reply.Body } 74 + </p> 75 + </div> 76 + }
+1
internal/server/views/study-session.templ
··· 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(),
··· 15 StudySession: params.StudySession, 16 }) 17 @partials.Discussion(partials.DiscussionProps{ 18 + User: params.User, 19 StudySessionDid: params.StudySession.Did, 20 StudySessionRkey: params.StudySession.Rkey, 21 StudySessionUri: params.StudySession.StudySessionAt().String(),
+1
internal/db/notification.go
··· 13 NotificationTypeFollow NotificationType = "follow" 14 NotificationTypeReaction NotificationType = "reaction" 15 NotificationTypeComment NotificationType = "comment" 16 ) 17 18 type NotificationState string
··· 13 NotificationTypeFollow NotificationType = "follow" 14 NotificationTypeReaction NotificationType = "reaction" 15 NotificationTypeComment NotificationType = "comment" 16 + NotificationTypeReply NotificationType = "reply" 17 ) 18 19 type NotificationState string