Yōten: A social tracker for your language learning journey built on the atproto.

feat: fetch study session comments

brookjeynes.dev 7aed43aa b3033c41

verified
+358 -59
+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" ··· 20 21 ProfileLevel int 21 22 ProfileDisplayName string 22 23 BskyProfile types.BskyProfile 24 + } 25 + 26 + type CommentWithLocalProfile struct { 27 + Comment 28 + ProfileLevel int 29 + ProfileDisplayName string 23 30 } 24 31 25 32 type Comment struct { ··· 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
··· 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 + }
+59 -8
internal/server/handlers/comment.go
··· 18 18 "yoten.app/internal/db" 19 19 "yoten.app/internal/server/htmx" 20 20 "yoten.app/internal/server/views/partials" 21 - ) 22 - 23 - const ( 24 - PendingCommentCreation string = "pending_comment_creation" 25 - PendingCommentDeletion string = "pending_comment_deletion" 21 + "yoten.app/internal/types" 22 + "yoten.app/internal/utils" 26 23 ) 27 24 28 25 func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { ··· 125 122 }, 126 123 Replies: []db.CommentWithBskyProfile{}, 127 124 }, 125 + DoesOwn: true, 128 126 }).Render(r.Context(), w) 129 127 } 130 128 ··· 209 207 return 210 208 } 211 209 212 - // TODO: Get comment replies. 213 - 214 210 if user.Did != comment.Did { 215 211 log.Printf("user '%s' does not own record '%s'", user.Did, rkey) 216 212 htmx.HxError(w, http.StatusUnauthorized, "You do not have permissions to edit this comment.") ··· 311 307 ProfileDisplayName: profile.DisplayName, 312 308 BskyProfile: user.BskyProfile, 313 309 }, 314 - // TODO 310 + // Replies are not needed to be populated as this response will 311 + // replace just the edited comment. 315 312 Replies: []db.CommentWithBskyProfile{}, 316 313 }, 314 + DoesOwn: true, 317 315 }).Render(r.Context(), w) 318 316 } 319 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 + }
+61
internal/server/handlers/study-session.go
··· 10 10 11 11 comatproto "github.com/bluesky-social/indigo/api/atproto" 12 12 "github.com/bluesky-social/indigo/atproto/identity" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 13 14 lexutil "github.com/bluesky-social/indigo/lex/util" 14 15 "github.com/go-chi/chi/v5" 15 16 "github.com/posthog/posthog-go" ··· 687 688 } 688 689 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 + nextPage := 0 732 + if len(commentFeed) > pageSize { 733 + nextPage = int(page + 1) 734 + commentFeed = commentFeed[:pageSize] 735 + } 736 + 737 + populatedCommentFeed, err := h.BuildCommentFeed(commentFeed) 738 + if err != nil { 739 + log.Println("failed to populate comment feed:", err) 740 + htmx.HxError(w, http.StatusInternalServerError, "Failed to get comment feed, try again later.") 741 + return 742 + } 743 + 744 + partials.CommentFeed(partials.CommentFeedProps{ 745 + Feed: populatedCommentFeed, 746 + NextPage: nextPage, 747 + User: user, 748 + StudySessionDid: ident.DID.String(), 749 + StudySessionRkey: rkey, 750 + }).Render(r.Context(), w) 690 751 }
+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
··· 62 62 <p class="text-text-muted text-sm">&commat;{ 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 }
+14 -10
internal/server/views/partials/discussion.templ
··· 1 1 package partials 2 2 3 + import "fmt" 4 + 3 5 templ Discussion(params DiscussionProps) { 4 6 <div class="card"> 5 7 <div class="flex items-center gap-2"> ··· 9 11 <form 10 12 hx-post="/comment/new" 11 13 hx-swap="afterbegin" 12 - hx-target="#comments" 14 + hx-target="#comment-feed" 13 15 hx-disabled-elt="#post-comment-button" 14 16 @htmx:after-request="text = ''" 15 17 x-data="{ text: '' }" ··· 29 31 <div class="text-sm text-text-muted"> 30 32 <span x-text="text.length"></span> / 256 31 33 </div> 32 - <button 33 - type="submit" 34 - id="post-comment-button" 35 - class="btn btn-primary w-fit" 36 - > 34 + <button type="submit" id="post-comment-button" class="btn btn-primary w-fit"> 37 35 Post Comment 38 36 </button> 39 37 </div> 40 38 </div> 41 39 </form> 42 - <div id="comments" class="flex flex-col gap-4 mt-2"> 43 - for _, comment := range params.Comments { 44 - @Comment(CommentProps{Comment: comment}) 45 - } 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> 46 50 </div> 47 51 </div> 48 52 }
+1 -1
internal/server/views/partials/edit-comment.templ
··· 8 8 <form 9 9 hx-post={ templ.SafeURL("/comment/edit/" + params.Comment.Rkey) } 10 10 hx-target={ "#" + elementId } 11 - hx-swap="outerSelf" 11 + hx-swap="outerHTML" 12 12 hx-disabled-elt="#update-comment-button,#cancel-comment-button" 13 13 x-data="{ text: '' }" 14 14 x-init="text = $el.querySelector('textarea').value"
+13 -2
internal/server/views/partials/partials.go
··· 220 220 } 221 221 222 222 type DiscussionProps struct { 223 - Comments []db.CommentFeedItem 224 - StudySessionUri string 223 + StudySessionUri string 224 + StudySessionDid string 225 + StudySessionRkey string 225 226 } 226 227 227 228 type CommentProps struct { 228 229 Comment db.CommentFeedItem 230 + DoesOwn bool 229 231 } 230 232 231 233 type EditCommentProps struct { 232 234 Comment db.Comment 233 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 + }
+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
··· 156 156 SessionRkey: params.StudySession.Rkey, 157 157 ReactionEvents: params.StudySession.Reactions, 158 158 }) 159 - <a href={ studySessionUrl }> 159 + <a href={ studySessionUrl } title="comments"> 160 160 <i class="w-5 h-5" data-lucide="message-square-share"></i> 161 161 </a> 162 162 </div>
+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 }