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

feat: add / delete comments

brookjeynes.dev a8a2c290 6e6f4201

verified
+510 -25
+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"
+69
internal/consumer/ingester.go
··· 50 50 err = i.ingestFollow(e) 51 51 case yoten.FeedReactionNSID: 52 52 err = i.ingestReaction(e) 53 + case yoten.FeedCommentNSID: 54 + err = i.ingestComment(e) 53 55 } 54 56 } 55 57 if err != nil { ··· 561 563 562 564 return nil 563 565 } 566 + 567 + func (i *Ingester) ingestComment(e *models.Event) error { 568 + var err error 569 + did := e.Did 570 + 571 + switch e.Commit.Operation { 572 + case models.CommitOperationCreate, models.CommitOperationUpdate: 573 + raw := json.RawMessage(e.Commit.Record) 574 + record := yoten.FeedComment{} 575 + err = json.Unmarshal(raw, &record) 576 + if err != nil { 577 + return fmt.Errorf("invalid record: %w", err) 578 + } 579 + 580 + subject := record.Subject 581 + subjectUri, err := syntax.ParseATURI(subject) 582 + if err != nil { 583 + return fmt.Errorf("failed to parse study session at-uri: %w", err) 584 + } 585 + 586 + body := record.Body 587 + if len(body) == 0 { 588 + return fmt.Errorf("invalid body: length cannot be 0") 589 + } 590 + 591 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 592 + if err != nil { 593 + return fmt.Errorf("invalid createdAt format: %w", err) 594 + } 595 + 596 + ddb, ok := i.Db.Execer.(*db.DB) 597 + if !ok { 598 + return fmt.Errorf("failed to index resource record: %w", err) 599 + } 600 + 601 + tx, err := ddb.Begin() 602 + if err != nil { 603 + return fmt.Errorf("failed to start transaction: %w", err) 604 + } 605 + 606 + // TODO: Parse reply 607 + 608 + comment := db.Comment{ 609 + Did: did, 610 + Rkey: e.Commit.RKey, 611 + StudySessionUri: subjectUri, 612 + Body: body, 613 + CreatedAt: createdAt, 614 + } 615 + 616 + log.Println("upserting comment from pds request") 617 + err = db.UpsertComment(i.Db, comment) 618 + if err != nil { 619 + tx.Rollback() 620 + return fmt.Errorf("failed to upsert comment record: %w", err) 621 + } 622 + return tx.Commit() 623 + case models.CommitOperationDelete: 624 + log.Println("deleting comment from pds request") 625 + err = db.DeleteCommentByRkey(i.Db, did, e.Commit.RKey) 626 + } 627 + if err != nil { 628 + return fmt.Errorf("failed to %s follow record: %w", e.Commit.Operation, err) 629 + } 630 + 631 + return nil 632 + }
+65 -6
internal/db/comment.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 5 6 "time" 6 7 ··· 9 10 "yoten.app/internal/types" 10 11 ) 11 12 13 + type CommentFeedItem struct { 14 + CommentWithBskyProfile 15 + Replies []CommentWithBskyProfile 16 + } 17 + 12 18 type CommentWithBskyProfile struct { 13 19 Comment 14 - BskyProfile types.BskyProfile 20 + ProfileLevel int 21 + ProfileDisplayName string 22 + BskyProfile types.BskyProfile 15 23 } 16 24 17 25 type Comment struct { ··· 29 37 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, yoten.FeedCommentNSID, c.Rkey)) 30 38 } 31 39 40 + func (c Comment) GetRkey() string { 41 + return c.Rkey 42 + } 43 + 32 44 func UpsertComment(e Execer, comment Comment) error { 45 + var parentCommentUri *string 46 + if comment.ParentCommentUri != nil { 47 + parentCommentUri = ToPtr(comment.ParentCommentUri.String()) 48 + } else { 49 + parentCommentUri = nil 50 + } 51 + 33 52 _, err := e.Exec(` 34 - insert into study_sessions ( 35 - id, 53 + insert into comments ( 36 54 did, 37 55 rkey, 38 56 study_session_uri, ··· 41 59 is_deleted, 42 60 created_at 43 61 ) 44 - values () 62 + values (?, ?, ?, ?, ?, ?, ?) 45 63 on conflict(did, rkey) do update set 46 64 is_deleted = excluded.is_deleted, 47 65 body = excluded.body`, 48 - comment.ID, 49 66 comment.Did, 50 67 comment.Rkey, 51 68 comment.StudySessionUri.String(), 52 - comment.ParentCommentUri.String(), 69 + parentCommentUri, 53 70 comment.Body, 54 71 comment.IsDeleted, 55 72 comment.CreatedAt.Format(time.RFC3339), ··· 60 77 61 78 return nil 62 79 } 80 + 81 + func DeleteCommentByRkey(e Execer, did string, rkey string) error { 82 + _, err := e.Exec(` 83 + update comments 84 + set is_deleted = ? 85 + where did = ? and rkey = ?`, 86 + Deleted, did, rkey, 87 + ) 88 + return err 89 + } 90 + 91 + func GetCommentByRkey(e Execer, did string, rkey string) (Comment, error) { 92 + comment := Comment{} 93 + var parentCommentUri sql.NullString 94 + var studySessionUriStr string 95 + var createdAtStr string 96 + 97 + err := e.QueryRow(` 98 + select id, did, rkey, study_session_uri, parent_comment_uri, body, is_deleted, created_at 99 + from comments 100 + where did is ? and rkey = ?`, 101 + did, rkey, 102 + ).Scan(&comment.ID, &comment.Did, &comment.Rkey, &studySessionUriStr, &parentCommentUri, &comment.Body, &comment.IsDeleted, &createdAtStr) 103 + if err != nil { 104 + if err == sql.ErrNoRows { 105 + return Comment{}, fmt.Errorf("comment does not exist") 106 + } 107 + return Comment{}, err 108 + } 109 + 110 + comment.CreatedAt, err = time.Parse(time.RFC3339, createdAtStr) 111 + if err != nil { 112 + return Comment{}, fmt.Errorf("failed to parse created at string '%s': %w", createdAtStr, err) 113 + } 114 + 115 + comment.StudySessionUri, err = syntax.ParseATURI(studySessionUriStr) 116 + if err != nil { 117 + return Comment{}, fmt.Errorf("failed to parse study session at-uri: %w", err) 118 + } 119 + 120 + return comment, nil 121 + }
+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,
+205
internal/server/handlers/comment.go
··· 1 + package handlers 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "time" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + "github.com/go-chi/chi/v5" 12 + "github.com/posthog/posthog-go" 13 + "yoten.app/api/yoten" 14 + "yoten.app/internal/atproto" 15 + "yoten.app/internal/clients/bsky" 16 + ph "yoten.app/internal/clients/posthog" 17 + "yoten.app/internal/db" 18 + "yoten.app/internal/server/htmx" 19 + "yoten.app/internal/server/views/partials" 20 + ) 21 + 22 + const ( 23 + PendingCommentCreation string = "pending_comment_creation" 24 + PendingCommentDeletion string = "pending_comment_deletion" 25 + ) 26 + 27 + func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) { 28 + client, err := h.Oauth.AuthorizedClient(r, w) 29 + if err != nil { 30 + log.Println("failed to get authorized client:", err) 31 + htmx.HxRedirect(w, "/login") 32 + return 33 + } 34 + 35 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 36 + if err != nil { 37 + log.Println("failed to get logged-in user:", err) 38 + htmx.HxRedirect(w, "/login") 39 + return 40 + } 41 + 42 + profile, err := db.GetProfile(h.Db, user.Did) 43 + if err != nil { 44 + log.Println("failed to get logged-in user:", err) 45 + htmx.HxRedirect(w, "/login") 46 + return 47 + } 48 + 49 + err = r.ParseForm() 50 + if err != nil { 51 + log.Println("invalid comment form:", err) 52 + htmx.HxError(w, http.StatusBadRequest, "Unable to process comment, please try again later.") 53 + return 54 + } 55 + 56 + commentBody := r.FormValue("comment") 57 + if len(commentBody) == 0 { 58 + log.Println("invalid comment form: missing comment body") 59 + htmx.HxError(w, http.StatusBadRequest, "Comment cannot be empty.") 60 + return 61 + } 62 + 63 + studySessionUri := r.FormValue("study_session_uri") 64 + if len(studySessionUri) == 0 { 65 + log.Println("invalid comment form: missing study session Uri") 66 + htmx.HxError(w, http.StatusBadRequest, "Unable to create comment, please try again later.") 67 + return 68 + } 69 + 70 + newComment := db.Comment{ 71 + Rkey: atproto.TID(), 72 + Did: user.Did, 73 + StudySessionUri: syntax.ATURI(studySessionUri), 74 + Body: commentBody, 75 + CreatedAt: time.Now(), 76 + } 77 + 78 + newCommentRecord := yoten.FeedComment{ 79 + LexiconTypeID: yoten.FeedCommentNSID, 80 + Body: newComment.Body, 81 + Subject: newComment.StudySessionUri.String(), 82 + CreatedAt: newComment.CreatedAt.Format(time.RFC3339), 83 + } 84 + 85 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 86 + Collection: yoten.FeedCommentNSID, 87 + Repo: newComment.Did, 88 + Rkey: newComment.Rkey, 89 + Record: &lexutil.LexiconTypeDecoder{ 90 + Val: &newCommentRecord, 91 + }, 92 + }) 93 + if err != nil { 94 + log.Println("failed to create comment record:", err) 95 + htmx.HxError(w, http.StatusInternalServerError, "Failed to create comment, try again later.") 96 + return 97 + } 98 + 99 + err = SavePendingCreate(h, w, r, PendingCommentCreation, newComment) 100 + if err != nil { 101 + log.Printf("failed to save yoten-session to add pending comment creation: %v", err) 102 + } 103 + 104 + if !h.Config.Core.Dev { 105 + event := posthog.Capture{ 106 + DistinctId: user.Did, 107 + Event: ph.CommentRecordDeletedEvent, 108 + Properties: posthog.NewProperties(). 109 + Set("is_reply", newComment.ParentCommentUri != nil). 110 + Set("character_count", len(newComment.Body)). 111 + Set("study_session_uri", newComment.StudySessionUri.String()), 112 + } 113 + 114 + if newComment.ParentCommentUri != nil { 115 + event.Properties.Set("parent_comment_uri", *newComment.ParentCommentUri) 116 + } 117 + 118 + err = h.Posthog.Enqueue(event) 119 + if err != nil { 120 + log.Println("failed to enqueue posthog event:", err) 121 + } 122 + } 123 + 124 + partials.Comment(partials.CommentProps{ 125 + Comment: db.CommentFeedItem{ 126 + CommentWithBskyProfile: db.CommentWithBskyProfile{ 127 + Comment: newComment, 128 + ProfileLevel: profile.Level, 129 + ProfileDisplayName: profile.DisplayName, 130 + BskyProfile: user.BskyProfile, 131 + }, 132 + Replies: []db.CommentWithBskyProfile{}, 133 + }, 134 + }).Render(r.Context(), w) 135 + } 136 + 137 + func (h *Handler) HandleDeleteComment(w http.ResponseWriter, r *http.Request) { 138 + user := h.Oauth.GetUser(r) 139 + if user == nil { 140 + log.Println("failed to get logged-in user") 141 + htmx.HxRedirect(w, "/login") 142 + return 143 + } 144 + client, err := h.Oauth.AuthorizedClient(r, w) 145 + if err != nil { 146 + log.Println("failed to get authorized client:", err) 147 + htmx.HxRedirect(w, "/login") 148 + return 149 + } 150 + 151 + switch r.Method { 152 + case http.MethodDelete: 153 + rkey := chi.URLParam(r, "rkey") 154 + comment, err := db.GetCommentByRkey(h.Db, user.Did, rkey) 155 + if err != nil { 156 + log.Println("failed to get comment from db:", err) 157 + htmx.HxError(w, http.StatusInternalServerError, "Failed to delete comment, try again later.") 158 + return 159 + } 160 + 161 + if user.Did != comment.Did { 162 + log.Printf("user '%s' does not own record '%s'", user.Did, rkey) 163 + htmx.HxError(w, http.StatusUnauthorized, "You do not have permissions to delete this comment.") 164 + return 165 + } 166 + 167 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 168 + Collection: yoten.FeedCommentNSID, 169 + Repo: user.Did, 170 + Rkey: comment.Rkey, 171 + }) 172 + if err != nil { 173 + log.Println("failed to delete comment from PDS:", err) 174 + htmx.HxError(w, http.StatusInternalServerError, "Failed to delete comment, try again later.") 175 + return 176 + } 177 + 178 + err = SavePendingDelete(h, w, r, PendingCommentDeletion, comment) 179 + if err != nil { 180 + log.Printf("failed to save yoten-session to add pending comment deletion: %v", err) 181 + } 182 + 183 + if !h.Config.Core.Dev { 184 + event := posthog.Capture{ 185 + DistinctId: user.Did, 186 + Event: ph.CommentRecordDeletedEvent, 187 + Properties: posthog.NewProperties(). 188 + Set("is_reply", comment.ParentCommentUri != nil). 189 + Set("character_count", len(comment.Body)). 190 + Set("study_session_uri", comment.StudySessionUri.String()), 191 + } 192 + 193 + if comment.ParentCommentUri != nil { 194 + event.Properties.Set("parent_comment_uri", *comment.ParentCommentUri) 195 + } 196 + 197 + err = h.Posthog.Enqueue(event) 198 + if err != nil { 199 + log.Println("failed to enqueue posthog event:", err) 200 + } 201 + } 202 + 203 + w.WriteHeader(http.StatusOK) 204 + } 205 + }
+10 -1
internal/server/handlers/router.go
··· 86 86 r.Delete("/{rkey}", h.HandleDeleteResource) 87 87 }) 88 88 89 + r.Route("/comment", func(r chi.Router) { 90 + r.Use(middleware.AuthMiddleware(h.Oauth)) 91 + r.Post("/new", h.HandleNewComment) 92 + r.Delete("/{rkey}", h.HandleDeleteComment) 93 + }) 94 + 89 95 r.Route("/activity", func(r chi.Router) { 90 96 r.Use(middleware.AuthMiddleware(h.Oauth)) 91 97 r.Get("/new", h.HandleNewActivityPage) ··· 129 135 r.Route("/{user}", func(r chi.Router) { 130 136 r.Get("/", h.HandleProfilePage) 131 137 r.Get("/feed", h.HandleProfileFeed) 132 - r.Get("/session/{rkey}", h.HandleStudySessionPage) 138 + r.Route("/session/{rkey}", func(r chi.Router) { 139 + r.Get("/", h.HandleStudySessionPage) 140 + r.Get("/feed", h.HandleStudySessionPageCommentFeed) 141 + }) 133 142 }) 134 143 }) 135 144
+3
internal/server/handlers/study-session.go
··· 685 685 }, 686 686 }).Render(r.Context(), w) 687 687 } 688 + 689 + func (h *Handler) HandleStudySessionPageCommentFeed(w http.ResponseWriter, r *http.Request) { 690 + }
+106
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)) }} 41 + <div id={ elementId } class="flex flex-col gap-3" x-init="lucide.createIcons()"> 42 + <div class="flex items-center justify-between"> 43 + <div class="flex items-center gap-3"> 44 + if params.Comment.BskyProfile.Avatar == "" { 45 + <div class="flex items-center justify-center w-10 h-10 rounded-full bg-primary"> 46 + <i class="w-7 h-7" data-lucide="user"></i> 47 + </div> 48 + } else { 49 + <img src={ params.Comment.BskyProfile.Avatar } class="w-10 h-10 rounded-full"/> 50 + } 51 + <div> 52 + <div class="flex items-center gap-2"> 53 + <a href={ templ.URL(fmt.Sprintf("/@%s", params.Comment.Did)) } class="font-semibold"> 54 + { params.Comment.ProfileDisplayName } 55 + </a> 56 + <p class="pill pill-secondary px-2 py-0.5 h-fit items-center justify-center gap-1 w-fit flex"> 57 + <i class="w-3.5 h-3.5" data-lucide="star"></i> 58 + <span class="text-xs">{ params.Comment.ProfileLevel }</span> 59 + </p> 60 + <span class="text-xs text-text-muted">{ params.Comment.CreatedAt.Format("2006-01-02") }</span> 61 + </div> 62 + <p class="text-text-muted text-sm">&commat;{ params.Comment.BskyProfile.Handle }</p> 63 + </div> 64 + </div> 65 + <details class="relative inline-block text-left"> 66 + <summary class="cursor-pointer list-none"> 67 + <div class="btn btn-muted p-2"> 68 + <i class="w-4 h-4 flex-shrink-0" data-lucide="ellipsis"></i> 69 + </div> 70 + </summary> 71 + <div class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-32 bg-bg-light border border-bg-dark"> 72 + <button id="edit-button" type="button" class="w-full"> 73 + <a 74 + href={ templ.URL(fmt.Sprintf("/comment/edit/%s", params.Comment.Rkey)) } 75 + class="text-base text-text flex items-center px-4 py-2 text-sm hover:bg-bg gap-2" 76 + > 77 + <i class="w-4 h-4" data-lucide="square-pen"></i> 78 + Edit 79 + </a> 80 + </button> 81 + <button 82 + class="text-base text-red-600 cursor-pointer flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group" 83 + type="button" 84 + id="delete-button" 85 + hx-disabled-elt="#delete-button,#edit-button" 86 + hx-target={ "#" + elementId } 87 + hx-swap="outerSelf" 88 + hx-delete={ templ.URL(fmt.Sprintf("/comment/%s", params.Comment.Rkey)) } 89 + > 90 + <i class="w-4 h-4" data-lucide="trash-2"></i> 91 + Delete 92 + <i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i> 93 + </button> 94 + </div> 95 + </details> 96 + </div> 97 + <p class="leading-relaxed break-words"> 98 + { params.Comment.Body } 99 + </p> 100 + <div class="flex flex-col mt-2"> 101 + for _, reply := range params.Comment.Replies { 102 + @Reply(reply) 103 + } 104 + </div> 105 + </div> 106 + }
+36 -16
internal/server/views/partials/discussion.templ
··· 6 6 <i class="w-5 h-5" data-lucide="message-square"></i> 7 7 <h1 class="text-2xl font-bold">Discussion</h1> 8 8 </div> 9 - <div class="mt-2" x-data="{ text: '' }" x-init="text = $el.querySelector('textarea').value"> 10 - <textarea 11 - x-model="text" 12 - id="comment" 13 - name="comment" 14 - placeholder="Share your thoughts about this study session..." 15 - class="input w-full" 16 - maxLength="256" 17 - rows="3" 18 - ></textarea> 19 - <div class="flex justify-between mt-2"> 20 - <div class="text-sm text-text-muted"> 21 - <span x-text="text.length"></span> / 256 9 + <form 10 + hx-post="/comment/new" 11 + hx-swap="afterbegin" 12 + hx-target="#comments" 13 + hx-disabled-elt="#post-comment-button" 14 + @htmx:after-request="text = ''" 15 + x-data="{ text: '' }" 16 + x-init="text = $el.querySelector('textarea').value" 17 + > 18 + <input type="hidden" name="study_session_uri" value={ params.StudySessionUri }/> 19 + <div class="mt-2"> 20 + <textarea 21 + x-model="text" 22 + id="comment" 23 + name="comment" 24 + placeholder="Share your thoughts about this study session..." 25 + class="input w-full" 26 + maxLength="256" 27 + rows="3" 28 + ></textarea> 29 + <div class="flex justify-between mt-2"> 30 + <div class="text-sm text-text-muted"> 31 + <span x-text="text.length"></span> / 256 32 + </div> 33 + <button 34 + type="submit" 35 + id="post-comment-button" 36 + class="btn btn-primary w-fit" 37 + > 38 + Post Comment 39 + </button> 22 40 </div> 23 - <button class="btn btn-primary w-fit"> 24 - Post Comment 25 - </button> 26 41 </div> 42 + </form> 43 + <div id="comments" class="flex flex-col gap-4 mt-2"> 44 + for _, comment := range params.Comments { 45 + @Comment(CommentProps{Comment: comment}) 46 + } 27 47 </div> 28 48 </div> 29 49 }
+6
internal/server/views/partials/partials.go
··· 220 220 } 221 221 222 222 type DiscussionProps struct { 223 + Comments []db.CommentFeedItem 224 + StudySessionUri string 225 + } 226 + 227 + type CommentProps struct { 228 + Comment db.CommentFeedItem 223 229 }
+3 -1
internal/server/views/study-session.templ
··· 14 14 DoesOwn: params.DoesOwn, 15 15 StudySession: params.StudySession, 16 16 }) 17 - @partials.Discussion(partials.DiscussionProps{}) 17 + @partials.Discussion(partials.DiscussionProps{ 18 + StudySessionUri: params.StudySession.StudySessionAt().String(), 19 + }) 18 20 </div> 19 21 } 20 22 }