Adds replies to Yoten
+9
-1
internal/db/comment.go
+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
+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
+2
internal/server/handlers/router.go
···
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
+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
+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">@{ 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
+8
-1
internal/server/views/partials/discussion.templ
+9
-4
internal/server/views/partials/edit-comment.templ
+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
+14
internal/server/views/partials/partials.go
···
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
+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">@{ 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
+1
internal/server/views/study-session.templ
+1
internal/db/notification.go
+1
internal/db/notification.go