Coffee journaling on ATProto (alpha)
alpha.arabica.social
coffee
1package components
2
3import (
4 "arabica/internal/firehose"
5 "arabica/internal/web/bff"
6 "fmt"
7)
8
9// CommentButtonProps defines properties for the comment button
10type CommentButtonProps struct {
11 SubjectURI string // AT-URI of the record to comment on
12 SubjectCID string // CID of the record
13 CommentCount int // Number of comments on this record
14 IsAuthenticated bool // Whether the user is authenticated
15}
16
17// CommentButton renders a comment button with count that links to comments section
18templ CommentButton(props CommentButtonProps) {
19 <button
20 type="button"
21 if props.IsAuthenticated && props.SubjectURI != "" && props.SubjectCID != "" {
22 hx-get={ fmt.Sprintf("/api/comments?subject_uri=%s", props.SubjectURI) }
23 hx-target="#comment-section"
24 hx-swap="innerHTML"
25 hx-trigger="click"
26 }
27 class="comment-btn"
28 aria-label="Comments"
29 >
30 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
31 <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path>
32 </svg>
33 if props.CommentCount > 0 {
34 <span>{ fmt.Sprintf("%d", props.CommentCount) }</span>
35 }
36 </button>
37}
38
39// CommentModerationContext holds moderation state for rendering comment actions
40type CommentModerationContext struct {
41 IsModerator bool // User has moderator role
42 CanHideRecord bool // User has hide_record permission
43 CanBlockUser bool // User has blacklist_user permission
44}
45
46// CommentSectionProps defines properties for the comment section
47type CommentSectionProps struct {
48 SubjectURI string // AT-URI of the record (brew/bean/etc)
49 SubjectCID string // CID of the record
50 Comments []firehose.IndexedComment // List of comments (threaded order with depth)
51 IsAuthenticated bool // Whether the user is authenticated
52 CurrentUserDID string // DID of the current user (for delete buttons)
53 ModCtx CommentModerationContext // Moderation context for comment actions
54 ViewURL string // URL of the parent brew (for sharing comments)
55}
56
57// CommentSection renders the full comment section with list and form
58templ CommentSection(props CommentSectionProps) {
59 <div id="comment-section" class="comment-section">
60 <!-- Section header -->
61 <div class="comment-section-header">
62 <div class="flex items-center gap-2">
63 <svg class="w-5 h-5 text-brown-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
64 <path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"></path>
65 </svg>
66 <h3 class="text-lg font-semibold text-brown-900">
67 Discussion
68 </h3>
69 if len(props.Comments) > 0 {
70 <span class="comment-count-badge">{ fmt.Sprintf("%d", len(props.Comments)) }</span>
71 }
72 </div>
73 </div>
74 <!-- Comment form or login prompt -->
75 if props.IsAuthenticated {
76 @CommentForm(CommentFormProps{
77 SubjectURI: props.SubjectURI,
78 SubjectCID: props.SubjectCID,
79 })
80 } else {
81 <div class="comment-login-prompt">
82 <svg class="w-5 h-5 text-brown-500 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
83 <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path>
84 </svg>
85 <p class="text-sm text-brown-600">
86 <a href="/login" class="font-semibold text-brown-800 hover:text-brown-950 underline underline-offset-2 decoration-brown-400 hover:decoration-brown-700 transition-colors">Log in</a> to join the conversation.
87 </p>
88 </div>
89 }
90 <!-- Comment list -->
91 @CommentList(CommentListProps{
92 Comments: props.Comments,
93 CurrentUserDID: props.CurrentUserDID,
94 SubjectURI: props.SubjectURI,
95 SubjectCID: props.SubjectCID,
96 IsAuthenticated: props.IsAuthenticated,
97 ModCtx: props.ModCtx,
98 ViewURL: props.ViewURL,
99 })
100 </div>
101}
102
103// CommentFormProps defines properties for the comment form
104type CommentFormProps struct {
105 SubjectURI string // AT-URI of the record
106 SubjectCID string // CID of the record
107}
108
109// CommentForm renders a form for creating new comments
110templ CommentForm(props CommentFormProps) {
111 <form
112 hx-post="/api/comments"
113 hx-target="#comment-section"
114 hx-swap="innerHTML"
115 class="comment-compose"
116 >
117 <input type="hidden" name="subject_uri" value={ props.SubjectURI }/>
118 <input type="hidden" name="subject_cid" value={ props.SubjectCID }/>
119 <textarea
120 name="text"
121 placeholder="Share your thoughts..."
122 class="comment-textarea"
123 rows="2"
124 maxlength="1000"
125 required
126 ></textarea>
127 <div class="flex justify-between items-center">
128 <span class="text-xs text-brown-400 tracking-wide">1000 char limit</span>
129 <button type="submit" class="btn-primary text-sm py-1.5 px-5">
130 Post
131 </button>
132 </div>
133 </form>
134}
135
136// CommentListProps defines properties for the comment list
137type CommentListProps struct {
138 Comments []firehose.IndexedComment // List of comments (threaded order with depth)
139 CurrentUserDID string // DID of the current user (for delete buttons)
140 SubjectURI string // AT-URI of the root subject (for reply forms)
141 SubjectCID string // CID of the root subject (for reply forms)
142 IsAuthenticated bool // Whether the user is authenticated (for reply buttons)
143 ModCtx CommentModerationContext // Moderation context for comment actions
144 ViewURL string // URL of the parent brew (for sharing comments)
145}
146
147// CommentList renders a list of comments with threading support
148templ CommentList(props CommentListProps) {
149 <div class="comment-list">
150 if len(props.Comments) == 0 {
151 <div class="comment-empty-state">
152 <svg class="w-10 h-10 text-brown-300 mx-auto mb-3" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
153 <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path>
154 </svg>
155 <p class="text-brown-500 text-sm font-medium">No comments yet</p>
156 <p class="text-brown-400 text-xs mt-1">Be the first to share your thoughts</p>
157 </div>
158 } else {
159 for _, comment := range props.Comments {
160 @CommentItem(CommentItemProps{
161 Comment: comment,
162 CanDelete: props.CurrentUserDID == comment.ActorDID,
163 CanReply: props.IsAuthenticated && comment.Depth < 2 && comment.CID != "",
164 IsOwner: props.CurrentUserDID == comment.ActorDID,
165 SubjectURI: props.SubjectURI,
166 SubjectCID: props.SubjectCID,
167 IsAuthenticated: props.IsAuthenticated,
168 ModCtx: props.ModCtx,
169 ViewURL: props.ViewURL,
170 })
171 }
172 }
173 </div>
174}
175
176// CommentItemProps defines properties for a single comment
177type CommentItemProps struct {
178 Comment firehose.IndexedComment
179 CanDelete bool // Whether the current user can delete this comment
180 CanReply bool // Whether the user can reply (authenticated and depth < 2)
181 IsOwner bool // Whether the current user owns this comment
182 SubjectURI string // AT-URI of the root subject (for reply form)
183 SubjectCID string // CID of the root subject (for reply form)
184 IsAuthenticated bool // Whether the user is authenticated
185 ModCtx CommentModerationContext // Moderation context for comment actions
186 ViewURL string // URL of the parent brew (for sharing)
187}
188
189// CommentItem renders a single comment with optional threading indentation
190templ CommentItem(props CommentItemProps) {
191 <div
192 class={ "comment-item", getDepthClass(props.Comment.Depth) }
193 id={ "comment-" + props.Comment.RKey }
194 x-data="{ showReplyForm: false }"
195 >
196 if props.Comment.Depth > 0 {
197 <div class="comment-thread-line"></div>
198 }
199 <div class="comment-item-inner">
200 <!-- Header row with user badge and reply button -->
201 <div class="flex items-center justify-between gap-2 mb-1.5">
202 @UserBadge(UserBadgeProps{
203 ProfileURL: "/profile/" + getCommentHandle(props.Comment),
204 AvatarURL: getCommentAvatarURL(props.Comment),
205 DisplayName: getCommentDisplayName(props.Comment),
206 Handle: getCommentHandle(props.Comment),
207 TimeAgo: bff.FormatTimeAgo(props.Comment.CreatedAt),
208 Size: "sm",
209 })
210 if props.CanReply {
211 <button
212 type="button"
213 @click="showReplyForm = !showReplyForm"
214 class="comment-reply-btn"
215 aria-label="Reply to comment"
216 >
217 <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
218 <path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3"></path>
219 </svg>
220 Reply
221 </button>
222 }
223 </div>
224 <!-- Comment text -->
225 <p class="text-brown-800 whitespace-pre-wrap break-words pl-11 text-[0.9375rem] leading-relaxed">{ props.Comment.Text }</p>
226 <!-- Action bar (like, share, more menu) -->
227 <div class="pl-11 mt-1">
228 @ActionBar(ActionBarProps{
229 SubjectURI: buildCommentURI(props.Comment),
230 SubjectCID: props.Comment.CID,
231 IsLiked: props.Comment.IsLiked,
232 LikeCount: props.Comment.LikeCount,
233 ShareURL: getCommentShareURL(props.ViewURL, props.Comment),
234 ShareTitle: "Comment on Arabica",
235 ShareText: props.Comment.Text,
236 IsOwner: props.IsOwner,
237 DeleteURL: "/api/comments/" + props.Comment.RKey,
238 DeleteTarget: "#comment-" + props.Comment.RKey,
239 IsAuthenticated: props.IsAuthenticated,
240 IsModerator: props.ModCtx.IsModerator,
241 CanHideRecord: props.ModCtx.CanHideRecord,
242 CanBlockUser: props.ModCtx.CanBlockUser,
243 AuthorDID: props.Comment.ActorDID,
244 })
245 </div>
246 <!-- Inline reply form (shown when Reply is clicked) -->
247 if props.CanReply {
248 <div x-show="showReplyForm" x-transition class="mt-3 pl-11">
249 @ReplyForm(ReplyFormProps{
250 SubjectURI: props.SubjectURI,
251 SubjectCID: props.SubjectCID,
252 ParentURI: buildCommentURI(props.Comment),
253 ParentCID: props.Comment.CID,
254 })
255 </div>
256 }
257 </div>
258 </div>
259}
260
261// ReplyFormProps defines properties for the reply form
262type ReplyFormProps struct {
263 SubjectURI string // AT-URI of the root subject (brew/bean/etc)
264 SubjectCID string // CID of the root subject
265 ParentURI string // AT-URI of the parent comment
266 ParentCID string // CID of the parent comment
267}
268
269// ReplyForm renders a compact inline reply form
270templ ReplyForm(props ReplyFormProps) {
271 <form
272 hx-post="/api/comments"
273 hx-target="#comment-section"
274 hx-swap="innerHTML"
275 class="comment-reply-form"
276 >
277 <input type="hidden" name="subject_uri" value={ props.SubjectURI }/>
278 <input type="hidden" name="subject_cid" value={ props.SubjectCID }/>
279 <input type="hidden" name="parent_uri" value={ props.ParentURI }/>
280 <input type="hidden" name="parent_cid" value={ props.ParentCID }/>
281 <textarea
282 name="text"
283 placeholder="Write a reply..."
284 class="comment-textarea text-sm"
285 rows="2"
286 maxlength="1000"
287 required
288 ></textarea>
289 <div class="flex justify-end gap-2">
290 <button type="button" @click="showReplyForm = false" class="btn-secondary text-xs py-1 px-3">
291 Cancel
292 </button>
293 <button type="submit" class="btn-primary text-xs py-1 px-3">
294 Reply
295 </button>
296 </div>
297 </form>
298}
299
300// Helper functions for comment display
301func getCommentHandle(comment firehose.IndexedComment) string {
302 if comment.Handle != "" {
303 return comment.Handle
304 }
305 return comment.ActorDID
306}
307
308func getCommentDisplayName(comment firehose.IndexedComment) string {
309 if comment.DisplayName != nil {
310 return *comment.DisplayName
311 }
312 return ""
313}
314
315func getCommentAvatarURL(comment firehose.IndexedComment) string {
316 if comment.Avatar != nil {
317 return *comment.Avatar
318 }
319 return ""
320}
321
322// getDepthClass returns the CSS class for comment depth
323func getDepthClass(depth int) string {
324 switch depth {
325 case 1:
326 return "comment-depth-1"
327 case 2:
328 return "comment-depth-2"
329 default:
330 return ""
331 }
332}
333
334// getCommentShareURL returns the share URL for a comment, linking to the comment anchor on the brew page
335func getCommentShareURL(viewURL string, comment firehose.IndexedComment) string {
336 if viewURL == "" {
337 return ""
338 }
339 return viewURL + "#comment-" + comment.RKey
340}
341
342// buildCommentURI constructs the AT-URI for a comment from its indexed data
343func buildCommentURI(comment firehose.IndexedComment) string {
344 // The subject URI contains the DID, format: at://did:plc:xxx/social.arabica.alpha.brew/rkey
345 // We need to extract the DID and build a comment URI: at://did:plc:xxx/social.arabica.alpha.comment/rkey
346 return fmt.Sprintf("at://%s/social.arabica.alpha.comment/%s", comment.ActorDID, comment.RKey)
347}
348