Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

feat: comments (#9)

* feat: comments

* feat: wip comment threading

* feat: improved comment styling

* feat: comment styling improvements

* feat: styling fixes and moderation additions

fix: comments action bar css fix

* feat: action bar refactor

authored by

Patrick Dewey and committed by
GitHub
664a72db 55d62937

+2017 -247
+4 -10
CLAUDE.md
··· 380 380 }) 381 381 ``` 382 382 383 - ### Generated Code 384 - 385 - The `templ generate` command produces `*_templ.go` files: 386 - 387 - - These are committed to version control 388 - - They implement the `templ.Component` interface 389 - - They contain optimized rendering logic 390 - - IDE autocomplete and type checking work on generated code 391 - 392 383 ## Common Tasks 393 384 394 385 ### Run Development Server ··· 431 422 templ generate --watch 432 423 ``` 433 424 434 - The generated `*_templ.go` files are committed to version control and should be regenerated whenever `.templ` files change. 425 + The generated `*_templ.go` should be regenerated whenever `.templ` files change. 426 + 427 + Templ files must use tabs rather than spaces. 435 428 436 429 ## Command-Line Flags 437 430 ··· 599 592 ``` 600 593 601 594 **Common testify assertions:** 595 + 602 596 - `assert.Equal(t, expected, actual)` - Equality check 603 597 - `assert.NotEqual(t, expected, actual)` - Inequality check 604 598 - `assert.True(t, value)` - Boolean true
+1
internal/atproto/nsid.go
··· 16 16 NSIDBean = NSIDBase + ".bean" 17 17 NSIDBrew = NSIDBase + ".brew" 18 18 NSIDBrewer = NSIDBase + ".brewer" 19 + NSIDComment = NSIDBase + ".comment" 19 20 NSIDGrinder = NSIDBase + ".grinder" 20 21 NSIDLike = NSIDBase + ".like" 21 22 NSIDRoaster = NSIDBase + ".roaster"
+1
internal/atproto/oauth.go
··· 16 16 "repo:" + NSIDBean, 17 17 "repo:" + NSIDBrew, 18 18 "repo:" + NSIDBrewer, 19 + "repo:" + NSIDComment, 19 20 "repo:" + NSIDGrinder, 20 21 "repo:" + NSIDLike, 21 22 "repo:" + NSIDRoaster,
+97
internal/atproto/records.go
··· 500 500 501 501 return like, nil 502 502 } 503 + 504 + // ========== Comment Conversions ========== 505 + 506 + // CommentToRecord converts a models.Comment to an atproto record map 507 + // Uses com.atproto.repo.strongRef format for the subject 508 + func CommentToRecord(comment *models.Comment) (map[string]interface{}, error) { 509 + if comment.SubjectURI == "" { 510 + return nil, fmt.Errorf("subject URI is required") 511 + } 512 + if comment.SubjectCID == "" { 513 + return nil, fmt.Errorf("subject CID is required") 514 + } 515 + if comment.Text == "" { 516 + return nil, fmt.Errorf("text is required") 517 + } 518 + 519 + record := map[string]interface{}{ 520 + "$type": NSIDComment, 521 + "subject": map[string]interface{}{ 522 + "uri": comment.SubjectURI, 523 + "cid": comment.SubjectCID, 524 + }, 525 + "text": comment.Text, 526 + "createdAt": comment.CreatedAt.Format(time.RFC3339), 527 + } 528 + 529 + // Add optional parent reference for replies 530 + if comment.ParentURI != "" && comment.ParentCID != "" { 531 + record["parent"] = map[string]interface{}{ 532 + "uri": comment.ParentURI, 533 + "cid": comment.ParentCID, 534 + } 535 + } 536 + 537 + return record, nil 538 + } 539 + 540 + // RecordToComment converts an atproto record map to a models.Comment 541 + func RecordToComment(record map[string]interface{}, atURI string) (*models.Comment, error) { 542 + comment := &models.Comment{} 543 + 544 + // Extract rkey from AT-URI 545 + if atURI != "" { 546 + parsedURI, err := syntax.ParseATURI(atURI) 547 + if err != nil { 548 + return nil, fmt.Errorf("invalid AT-URI: %w", err) 549 + } 550 + comment.RKey = parsedURI.RecordKey().String() 551 + } 552 + 553 + // Required field: subject (strongRef) 554 + subject, ok := record["subject"].(map[string]interface{}) 555 + if !ok { 556 + return nil, fmt.Errorf("subject is required") 557 + } 558 + subjectURI, ok := subject["uri"].(string) 559 + if !ok || subjectURI == "" { 560 + return nil, fmt.Errorf("subject.uri is required") 561 + } 562 + comment.SubjectURI = subjectURI 563 + 564 + subjectCID, ok := subject["cid"].(string) 565 + if !ok || subjectCID == "" { 566 + return nil, fmt.Errorf("subject.cid is required") 567 + } 568 + comment.SubjectCID = subjectCID 569 + 570 + // Required field: text 571 + text, ok := record["text"].(string) 572 + if !ok || text == "" { 573 + return nil, fmt.Errorf("text is required") 574 + } 575 + comment.Text = text 576 + 577 + // Required field: createdAt 578 + createdAtStr, ok := record["createdAt"].(string) 579 + if !ok { 580 + return nil, fmt.Errorf("createdAt is required") 581 + } 582 + createdAt, err := time.Parse(time.RFC3339, createdAtStr) 583 + if err != nil { 584 + return nil, fmt.Errorf("invalid createdAt format: %w", err) 585 + } 586 + comment.CreatedAt = createdAt 587 + 588 + // Optional field: parent (strongRef for replies) 589 + if parent, ok := record["parent"].(map[string]interface{}); ok { 590 + if parentURI, ok := parent["uri"].(string); ok && parentURI != "" { 591 + comment.ParentURI = parentURI 592 + } 593 + if parentCID, ok := parent["cid"].(string); ok && parentCID != "" { 594 + comment.ParentCID = parentCID 595 + } 596 + } 597 + 598 + return comment, nil 599 + }
+103
internal/atproto/store.go
··· 1229 1229 return likes, nil 1230 1230 } 1231 1231 1232 + // ========== Comment Operations ========== 1233 + 1234 + func (s *AtprotoStore) CreateComment(ctx context.Context, req *models.CreateCommentRequest) (*models.Comment, error) { 1235 + if req.SubjectURI == "" { 1236 + return nil, fmt.Errorf("subject_uri is required") 1237 + } 1238 + if req.SubjectCID == "" { 1239 + return nil, fmt.Errorf("subject_cid is required") 1240 + } 1241 + if req.Text == "" { 1242 + return nil, fmt.Errorf("text is required") 1243 + } 1244 + 1245 + commentModel := &models.Comment{ 1246 + SubjectURI: req.SubjectURI, 1247 + SubjectCID: req.SubjectCID, 1248 + Text: req.Text, 1249 + CreatedAt: time.Now(), 1250 + ParentURI: req.ParentURI, 1251 + ParentCID: req.ParentCID, 1252 + } 1253 + 1254 + record, err := CommentToRecord(commentModel) 1255 + if err != nil { 1256 + return nil, fmt.Errorf("failed to convert comment to record: %w", err) 1257 + } 1258 + 1259 + output, err := s.client.CreateRecord(ctx, s.did, s.sessionID, &CreateRecordInput{ 1260 + Collection: NSIDComment, 1261 + Record: record, 1262 + }) 1263 + if err != nil { 1264 + return nil, fmt.Errorf("failed to create comment record: %w", err) 1265 + } 1266 + 1267 + atURI, err := syntax.ParseATURI(output.URI) 1268 + if err != nil { 1269 + return nil, fmt.Errorf("failed to parse returned AT-URI: %w", err) 1270 + } 1271 + 1272 + commentModel.RKey = atURI.RecordKey().String() 1273 + // Store the CID of this comment record (useful for threading) 1274 + commentModel.CID = output.CID 1275 + 1276 + return commentModel, nil 1277 + } 1278 + 1279 + func (s *AtprotoStore) DeleteCommentByRKey(ctx context.Context, rkey string) error { 1280 + err := s.client.DeleteRecord(ctx, s.did, s.sessionID, &DeleteRecordInput{ 1281 + Collection: NSIDComment, 1282 + RKey: rkey, 1283 + }) 1284 + if err != nil { 1285 + return fmt.Errorf("failed to delete comment record: %w", err) 1286 + } 1287 + return nil 1288 + } 1289 + 1290 + func (s *AtprotoStore) GetCommentsForSubject(ctx context.Context, subjectURI string) ([]*models.Comment, error) { 1291 + // List all comments and filter by subject URI 1292 + // Note: This is inefficient for large numbers of comments. 1293 + // The firehose index provides a more efficient lookup. 1294 + comments, err := s.ListUserComments(ctx) 1295 + if err != nil { 1296 + return nil, err 1297 + } 1298 + 1299 + var filtered []*models.Comment 1300 + for _, comment := range comments { 1301 + if comment.SubjectURI == subjectURI { 1302 + filtered = append(filtered, comment) 1303 + } 1304 + } 1305 + 1306 + return filtered, nil 1307 + } 1308 + 1309 + func (s *AtprotoStore) ListUserComments(ctx context.Context) ([]*models.Comment, error) { 1310 + output, err := s.client.ListAllRecords(ctx, s.did, s.sessionID, NSIDComment) 1311 + if err != nil { 1312 + return nil, fmt.Errorf("failed to list comment records: %w", err) 1313 + } 1314 + 1315 + comments := make([]*models.Comment, 0, len(output.Records)) 1316 + 1317 + for _, rec := range output.Records { 1318 + comment, err := RecordToComment(rec.Value, rec.URI) 1319 + if err != nil { 1320 + log.Warn().Err(err).Str("uri", rec.URI).Msg("Failed to convert comment record") 1321 + continue 1322 + } 1323 + 1324 + // Extract rkey from URI 1325 + if components, err := ResolveATURI(rec.URI); err == nil { 1326 + comment.RKey = components.RKey 1327 + } 1328 + 1329 + comments = append(comments, comment) 1330 + } 1331 + 1332 + return comments, nil 1333 + } 1334 + 1232 1335 func (s *AtprotoStore) Close() error { 1233 1336 // No persistent connection to close for atproto 1234 1337 return nil
+81
internal/database/boltstore/moderation_store.go
··· 496 496 return count, err 497 497 } 498 498 499 + // SetAutoHideReset stores a reset timestamp for a user's auto-hide counter. 500 + // Reports created before this timestamp are ignored when checking the per-user auto-hide threshold. 501 + func (s *ModerationStore) SetAutoHideReset(ctx context.Context, did string, resetAt time.Time) error { 502 + return s.db.Update(func(tx *bolt.Tx) error { 503 + bucket := tx.Bucket(BucketModerationAutoHideResets) 504 + if bucket == nil { 505 + return fmt.Errorf("bucket not found: %s", BucketModerationAutoHideResets) 506 + } 507 + 508 + data, err := resetAt.MarshalBinary() 509 + if err != nil { 510 + return fmt.Errorf("failed to marshal reset time: %w", err) 511 + } 512 + 513 + return bucket.Put([]byte(did), data) 514 + }) 515 + } 516 + 517 + // GetAutoHideReset returns the auto-hide reset timestamp for a user, or zero time if none set. 518 + func (s *ModerationStore) GetAutoHideReset(ctx context.Context, did string) (time.Time, error) { 519 + var resetAt time.Time 520 + 521 + err := s.db.View(func(tx *bolt.Tx) error { 522 + bucket := tx.Bucket(BucketModerationAutoHideResets) 523 + if bucket == nil { 524 + return nil 525 + } 526 + 527 + data := bucket.Get([]byte(did)) 528 + if data == nil { 529 + return nil 530 + } 531 + 532 + return resetAt.UnmarshalBinary(data) 533 + }) 534 + 535 + return resetAt, err 536 + } 537 + 538 + // CountReportsForDIDSince returns the number of reports for content by a given DID 539 + // created after the specified time. 540 + func (s *ModerationStore) CountReportsForDIDSince(ctx context.Context, did string, since time.Time) (int, error) { 541 + var count int 542 + 543 + err := s.db.View(func(tx *bolt.Tx) error { 544 + didIndex := tx.Bucket(BucketModerationReportsByDID) 545 + if didIndex == nil { 546 + return nil 547 + } 548 + 549 + reportsBucket := tx.Bucket(BucketModerationReports) 550 + if reportsBucket == nil { 551 + return nil 552 + } 553 + 554 + cursor := didIndex.Cursor() 555 + prefix := []byte(did + ":") 556 + 557 + for k, v := cursor.Seek(prefix); k != nil && hasPrefix(k, prefix); k, v = cursor.Next() { 558 + // v is the report ID 559 + reportData := reportsBucket.Get(v) 560 + if reportData == nil { 561 + continue 562 + } 563 + 564 + var report moderation.Report 565 + if err := json.Unmarshal(reportData, &report); err != nil { 566 + continue 567 + } 568 + 569 + if report.CreatedAt.After(since) { 570 + count++ 571 + } 572 + } 573 + 574 + return nil 575 + }) 576 + 577 + return count, err 578 + } 579 + 499 580 // hasPrefix checks if a byte slice has a given prefix. 500 581 func hasPrefix(s, prefix []byte) bool { 501 582 if len(s) < len(prefix) {
+4
internal/database/boltstore/store.go
··· 41 41 // BucketModerationAuditLog stores moderation action audit trail 42 42 BucketModerationAuditLog = []byte("moderation_audit_log") 43 43 44 + // BucketModerationAutoHideResets stores DID -> timestamp for auto-hide counter resets 45 + BucketModerationAutoHideResets = []byte("moderation_autohide_resets") 46 + 44 47 // BucketJoinRequests stores PDS account join requests 45 48 BucketJoinRequests = []byte("join_requests") 46 49 ) ··· 114 117 BucketModerationReportsByURI, 115 118 BucketModerationReportsByDID, 116 119 BucketModerationAuditLog, 120 + BucketModerationAutoHideResets, 117 121 BucketJoinRequests, 118 122 } 119 123
+6
internal/database/store.go
··· 54 54 GetUserLikeForSubject(ctx context.Context, subjectURI string) (*models.Like, error) 55 55 ListUserLikes(ctx context.Context) ([]*models.Like, error) 56 56 57 + // Comment operations 58 + CreateComment(ctx context.Context, req *models.CreateCommentRequest) (*models.Comment, error) 59 + DeleteCommentByRKey(ctx context.Context, rkey string) error 60 + GetCommentsForSubject(ctx context.Context, subjectURI string) ([]*models.Comment, error) 61 + ListUserComments(ctx context.Context) ([]*models.Comment, error) 62 + 57 63 // Close the database connection 58 64 Close() error 59 65 }
+31 -26
internal/feed/service.go
··· 56 56 SubjectCID string // CID of this record (for like button) 57 57 IsLikedByViewer bool // Whether the current viewer has liked this record 58 58 59 + // Comment-related fields 60 + CommentCount int // Number of comments on this record 61 + 59 62 // Ownership 60 63 IsOwner bool // Whether the current viewer owns this record 61 64 } ··· 77 80 // FirehoseFeedItem matches the FeedItem structure from firehose package 78 81 // This avoids import cycles 79 82 type FirehoseFeedItem struct { 80 - RecordType lexicons.RecordType 81 - Action string 82 - Brew *models.Brew 83 - Bean *models.Bean 84 - Roaster *models.Roaster 85 - Grinder *models.Grinder 86 - Brewer *models.Brewer 87 - Author *atproto.Profile 88 - Timestamp time.Time 89 - TimeAgo string 90 - LikeCount int 91 - SubjectURI string 92 - SubjectCID string 83 + RecordType lexicons.RecordType 84 + Action string 85 + Brew *models.Brew 86 + Bean *models.Bean 87 + Roaster *models.Roaster 88 + Grinder *models.Grinder 89 + Brewer *models.Brewer 90 + Author *atproto.Profile 91 + Timestamp time.Time 92 + TimeAgo string 93 + LikeCount int 94 + CommentCount int 95 + SubjectURI string 96 + SubjectCID string 93 97 } 94 98 95 99 // Service fetches and aggregates brews from registered users ··· 287 291 items := make([]*FeedItem, len(firehoseItems)) 288 292 for i, fi := range firehoseItems { 289 293 items[i] = &FeedItem{ 290 - RecordType: fi.RecordType, 291 - Action: fi.Action, 292 - Brew: fi.Brew, 293 - Bean: fi.Bean, 294 - Roaster: fi.Roaster, 295 - Grinder: fi.Grinder, 296 - Brewer: fi.Brewer, 297 - Author: fi.Author, 298 - Timestamp: fi.Timestamp, 299 - TimeAgo: fi.TimeAgo, 300 - LikeCount: fi.LikeCount, 301 - SubjectURI: fi.SubjectURI, 302 - SubjectCID: fi.SubjectCID, 294 + RecordType: fi.RecordType, 295 + Action: fi.Action, 296 + Brew: fi.Brew, 297 + Bean: fi.Bean, 298 + Roaster: fi.Roaster, 299 + Grinder: fi.Grinder, 300 + Brewer: fi.Brewer, 301 + Author: fi.Author, 302 + Timestamp: fi.Timestamp, 303 + TimeAgo: fi.TimeAgo, 304 + LikeCount: fi.LikeCount, 305 + CommentCount: fi.CommentCount, 306 + SubjectURI: fi.SubjectURI, 307 + SubjectCID: fi.SubjectCID, 303 308 } 304 309 } 305 310
+14 -13
internal/firehose/adapter.go
··· 34 34 result := make([]*feed.FirehoseFeedItem, len(items)) 35 35 for i, item := range items { 36 36 result[i] = &feed.FirehoseFeedItem{ 37 - RecordType: item.RecordType, 38 - Action: item.Action, 39 - Brew: item.Brew, 40 - Bean: item.Bean, 41 - Roaster: item.Roaster, 42 - Grinder: item.Grinder, 43 - Brewer: item.Brewer, 44 - Author: item.Author, 45 - Timestamp: item.Timestamp, 46 - TimeAgo: item.TimeAgo, 47 - LikeCount: item.LikeCount, 48 - SubjectURI: item.SubjectURI, 49 - SubjectCID: item.SubjectCID, 37 + RecordType: item.RecordType, 38 + Action: item.Action, 39 + Brew: item.Brew, 40 + Bean: item.Bean, 41 + Roaster: item.Roaster, 42 + Grinder: item.Grinder, 43 + Brewer: item.Brewer, 44 + Author: item.Author, 45 + Timestamp: item.Timestamp, 46 + TimeAgo: item.TimeAgo, 47 + LikeCount: item.LikeCount, 48 + CommentCount: item.CommentCount, 49 + SubjectURI: item.SubjectURI, 50 + SubjectCID: item.SubjectCID, 50 51 } 51 52 } 52 53
+49
internal/firehose/consumer.go
··· 347 347 } 348 348 } 349 349 350 + // Special handling for comments - index for counts and retrieval 351 + if commit.Collection == "social.arabica.alpha.comment" { 352 + var recordData map[string]interface{} 353 + if err := json.Unmarshal(commit.Record, &recordData); err == nil { 354 + if subject, ok := recordData["subject"].(map[string]interface{}); ok { 355 + if subjectURI, ok := subject["uri"].(string); ok { 356 + text, _ := recordData["text"].(string) 357 + var createdAt time.Time 358 + if createdAtStr, ok := recordData["createdAt"].(string); ok { 359 + if parsed, err := time.Parse(time.RFC3339, createdAtStr); err == nil { 360 + createdAt = parsed 361 + } else { 362 + createdAt = time.Now() 363 + } 364 + } else { 365 + createdAt = time.Now() 366 + } 367 + // Extract optional parent URI for threading 368 + var parentURI string 369 + if parent, ok := recordData["parent"].(map[string]interface{}); ok { 370 + parentURI, _ = parent["uri"].(string) 371 + } 372 + if err := c.index.UpsertComment(event.DID, commit.RKey, subjectURI, parentURI, commit.CID, text, createdAt); err != nil { 373 + log.Warn().Err(err).Str("did", event.DID).Str("subject", subjectURI).Msg("failed to index comment") 374 + } 375 + } 376 + } 377 + } 378 + } 379 + 350 380 case "delete": 351 381 // Special handling for likes - need to look up subject URI before delete 352 382 if commit.Collection == "social.arabica.alpha.like" { ··· 360 390 if subjectURI, ok := subject["uri"].(string); ok { 361 391 if err := c.index.DeleteLike(event.DID, subjectURI); err != nil { 362 392 log.Warn().Err(err).Str("did", event.DID).Str("subject", subjectURI).Msg("failed to delete like index") 393 + } 394 + } 395 + } 396 + } 397 + } 398 + } 399 + 400 + // Special handling for comments - need to look up subject URI before delete 401 + if commit.Collection == "social.arabica.alpha.comment" { 402 + // Try to get the existing record to find its subject 403 + if existingRecord, err := c.index.GetRecord( 404 + fmt.Sprintf("at://%s/%s/%s", event.DID, commit.Collection, commit.RKey), 405 + ); err == nil && existingRecord != nil { 406 + var recordData map[string]interface{} 407 + if err := json.Unmarshal(existingRecord.Record, &recordData); err == nil { 408 + if subject, ok := recordData["subject"].(map[string]interface{}); ok { 409 + if subjectURI, ok := subject["uri"].(string); ok { 410 + if err := c.index.DeleteComment(event.DID, commit.RKey, subjectURI); err != nil { 411 + log.Warn().Err(err).Str("did", event.DID).Str("subject", subjectURI).Msg("failed to delete comment index") 363 412 } 364 413 } 365 414 }
+321
internal/firehose/index.go
··· 54 54 55 55 // BucketLikesByActor stores likes by actor for lookup: {actor_did:subject_uri} -> {rkey} 56 56 BucketLikesByActor = []byte("likes_by_actor") 57 + 58 + // BucketComments stores comment data: {subject_uri:timestamp:actor_did} -> {comment JSON} 59 + BucketComments = []byte("comments") 60 + 61 + // BucketCommentCounts stores aggregated comment counts: {subject_uri} -> {uint64 count} 62 + BucketCommentCounts = []byte("comment_counts") 63 + 64 + // BucketCommentsByActor stores comments by actor for lookup: {actor_did:rkey} -> {subject_uri} 65 + BucketCommentsByActor = []byte("comments_by_actor") 66 + 67 + // BucketCommentChildren stores parent-child relationships: {parent_uri:child_rkey} -> {child_actor_did} 68 + BucketCommentChildren = []byte("comment_children") 57 69 ) 58 70 59 71 // FeedableRecordTypes are the record types that should appear as feed items. ··· 134 146 BucketLikes, 135 147 BucketLikeCounts, 136 148 BucketLikesByActor, 149 + BucketComments, 150 + BucketCommentCounts, 151 + BucketCommentsByActor, 152 + BucketCommentChildren, 137 153 } 138 154 for _, bucket := range buckets { 139 155 if _, err := tx.CreateBucketIfNotExists(bucket); err != nil { ··· 355 371 LikeCount int // Number of likes on this record 356 372 SubjectURI string // AT-URI of this record (for like button) 357 373 SubjectCID string // CID of this record (for like button) 374 + 375 + // Comment-related fields 376 + CommentCount int // Number of comments on this record 358 377 } 359 378 360 379 // GetRecentFeed returns recent feed items from the index ··· 596 615 item.SubjectURI = record.URI 597 616 item.SubjectCID = record.CID 598 617 item.LikeCount = idx.GetLikeCount(record.URI) 618 + item.CommentCount = idx.GetCommentCount(record.URI) 599 619 600 620 return item, nil 601 621 } ··· 936 956 }) 937 957 return rkey 938 958 } 959 + 960 + // IndexedComment represents a comment stored in the index 961 + type IndexedComment struct { 962 + RKey string `json:"rkey"` 963 + SubjectURI string `json:"subject_uri"` 964 + Text string `json:"text"` 965 + ActorDID string `json:"actor_did"` 966 + CreatedAt time.Time `json:"created_at"` 967 + // Parent fields for threading (stored) 968 + ParentURI string `json:"parent_uri,omitempty"` 969 + ParentRKey string `json:"parent_rkey,omitempty"` 970 + CID string `json:"cid,omitempty"` 971 + // Computed fields (populated on retrieval, not stored) 972 + Depth int `json:"-"` // Nesting depth (0 = top-level, 1 = reply, 2+ = nested reply) 973 + Replies []IndexedComment `json:"-"` // Child comments (for tree building) 974 + // Profile fields (populated on retrieval, not stored) 975 + Handle string `json:"-"` 976 + DisplayName *string `json:"-"` 977 + Avatar *string `json:"-"` 978 + // Like fields (populated on retrieval, not stored) 979 + LikeCount int `json:"-"` 980 + IsLiked bool `json:"-"` 981 + } 982 + 983 + // UpsertComment adds or updates a comment in the index 984 + func (idx *FeedIndex) UpsertComment(actorDID, rkey, subjectURI, parentURI, cid, text string, createdAt time.Time) error { 985 + return idx.db.Update(func(tx *bolt.Tx) error { 986 + comments := tx.Bucket(BucketComments) 987 + commentCounts := tx.Bucket(BucketCommentCounts) 988 + commentsByActor := tx.Bucket(BucketCommentsByActor) 989 + commentChildren := tx.Bucket(BucketCommentChildren) 990 + 991 + // Key format: {subject_uri}:{timestamp}:{actor_did}:{rkey} 992 + // Using timestamp for chronological ordering 993 + commentKey := []byte(subjectURI + ":" + createdAt.Format(time.RFC3339Nano) + ":" + actorDID + ":" + rkey) 994 + 995 + // Check if this comment already exists (by actor key) 996 + actorKey := []byte(actorDID + ":" + rkey) 997 + existingSubject := commentsByActor.Get(actorKey) 998 + isNew := existingSubject == nil 999 + 1000 + // Extract parent rkey from parent URI if present 1001 + var parentRKey string 1002 + if parentURI != "" { 1003 + parts := strings.Split(parentURI, "/") 1004 + if len(parts) > 0 { 1005 + parentRKey = parts[len(parts)-1] 1006 + } 1007 + } 1008 + 1009 + // Store comment data as JSON 1010 + commentData := IndexedComment{ 1011 + RKey: rkey, 1012 + SubjectURI: subjectURI, 1013 + Text: text, 1014 + ActorDID: actorDID, 1015 + CreatedAt: createdAt, 1016 + ParentURI: parentURI, 1017 + ParentRKey: parentRKey, 1018 + CID: cid, 1019 + } 1020 + commentJSON, err := json.Marshal(commentData) 1021 + if err != nil { 1022 + return fmt.Errorf("failed to marshal comment: %w", err) 1023 + } 1024 + 1025 + // Store comment 1026 + if err := comments.Put(commentKey, commentJSON); err != nil { 1027 + return fmt.Errorf("failed to store comment: %w", err) 1028 + } 1029 + 1030 + // Store actor lookup 1031 + if err := commentsByActor.Put(actorKey, []byte(subjectURI)); err != nil { 1032 + return fmt.Errorf("failed to store comment by actor: %w", err) 1033 + } 1034 + 1035 + // Store parent-child relationship if this is a reply 1036 + if parentURI != "" { 1037 + childKey := []byte(parentURI + ":" + rkey) 1038 + if err := commentChildren.Put(childKey, []byte(actorDID)); err != nil { 1039 + return fmt.Errorf("failed to store comment child: %w", err) 1040 + } 1041 + } 1042 + 1043 + // Increment count only if this is a new comment 1044 + if isNew { 1045 + countKey := []byte(subjectURI) 1046 + var count uint64 1047 + if countData := commentCounts.Get(countKey); len(countData) == 8 { 1048 + count = binary.BigEndian.Uint64(countData) 1049 + } 1050 + count++ 1051 + countBytes := make([]byte, 8) 1052 + binary.BigEndian.PutUint64(countBytes, count) 1053 + if err := commentCounts.Put(countKey, countBytes); err != nil { 1054 + return fmt.Errorf("failed to update comment count: %w", err) 1055 + } 1056 + } 1057 + 1058 + return nil 1059 + }) 1060 + } 1061 + 1062 + // DeleteComment removes a comment from the index 1063 + func (idx *FeedIndex) DeleteComment(actorDID, rkey, subjectURI string) error { 1064 + return idx.db.Update(func(tx *bolt.Tx) error { 1065 + comments := tx.Bucket(BucketComments) 1066 + commentCounts := tx.Bucket(BucketCommentCounts) 1067 + commentsByActor := tx.Bucket(BucketCommentsByActor) 1068 + commentChildren := tx.Bucket(BucketCommentChildren) 1069 + 1070 + actorKey := []byte(actorDID + ":" + rkey) 1071 + 1072 + // Check if comment exists 1073 + existingSubject := commentsByActor.Get(actorKey) 1074 + if existingSubject == nil { 1075 + return nil // Comment doesn't exist, nothing to do 1076 + } 1077 + 1078 + // Find and delete the comment by iterating over comments with matching subject 1079 + var parentURI string 1080 + prefix := []byte(subjectURI + ":") 1081 + c := comments.Cursor() 1082 + for k, v := c.Seek(prefix); k != nil && strings.HasPrefix(string(k), string(prefix)); k, v = c.Next() { 1083 + // Check if this key contains our actor and rkey 1084 + if strings.HasSuffix(string(k), ":"+actorDID+":"+rkey) { 1085 + // Parse the comment to get parent URI for cleanup 1086 + var comment IndexedComment 1087 + if err := json.Unmarshal(v, &comment); err == nil { 1088 + parentURI = comment.ParentURI 1089 + } 1090 + if err := comments.Delete(k); err != nil { 1091 + return fmt.Errorf("failed to delete comment: %w", err) 1092 + } 1093 + break 1094 + } 1095 + } 1096 + 1097 + // Delete actor lookup 1098 + if err := commentsByActor.Delete(actorKey); err != nil { 1099 + return fmt.Errorf("failed to delete comment by actor: %w", err) 1100 + } 1101 + 1102 + // Delete parent-child relationship if this was a reply 1103 + if parentURI != "" { 1104 + childKey := []byte(parentURI + ":" + rkey) 1105 + if err := commentChildren.Delete(childKey); err != nil { 1106 + return fmt.Errorf("failed to delete comment child: %w", err) 1107 + } 1108 + } 1109 + 1110 + // Decrement count 1111 + countKey := []byte(subjectURI) 1112 + var count uint64 1113 + if countData := commentCounts.Get(countKey); len(countData) == 8 { 1114 + count = binary.BigEndian.Uint64(countData) 1115 + } 1116 + if count > 0 { 1117 + count-- 1118 + } 1119 + countBytes := make([]byte, 8) 1120 + binary.BigEndian.PutUint64(countBytes, count) 1121 + if err := commentCounts.Put(countKey, countBytes); err != nil { 1122 + return fmt.Errorf("failed to update comment count: %w", err) 1123 + } 1124 + 1125 + return nil 1126 + }) 1127 + } 1128 + 1129 + // GetCommentCount returns the number of comments on a record 1130 + func (idx *FeedIndex) GetCommentCount(subjectURI string) int { 1131 + var count uint64 1132 + _ = idx.db.View(func(tx *bolt.Tx) error { 1133 + commentCounts := tx.Bucket(BucketCommentCounts) 1134 + countData := commentCounts.Get([]byte(subjectURI)) 1135 + if len(countData) == 8 { 1136 + count = binary.BigEndian.Uint64(countData) 1137 + } 1138 + return nil 1139 + }) 1140 + return int(count) 1141 + } 1142 + 1143 + // GetCommentsForSubject returns all comments for a specific record, ordered by creation time 1144 + // This returns a flat list of comments without threading 1145 + func (idx *FeedIndex) GetCommentsForSubject(ctx context.Context, subjectURI string, limit int, viewerDID string) []IndexedComment { 1146 + var comments []IndexedComment 1147 + _ = idx.db.View(func(tx *bolt.Tx) error { 1148 + bucket := tx.Bucket(BucketComments) 1149 + prefix := []byte(subjectURI + ":") 1150 + c := bucket.Cursor() 1151 + 1152 + for k, v := c.Seek(prefix); k != nil && strings.HasPrefix(string(k), string(prefix)); k, v = c.Next() { 1153 + var comment IndexedComment 1154 + if err := json.Unmarshal(v, &comment); err != nil { 1155 + continue 1156 + } 1157 + comments = append(comments, comment) 1158 + if limit > 0 && len(comments) >= limit { 1159 + break 1160 + } 1161 + } 1162 + return nil 1163 + }) 1164 + 1165 + // Populate profile and like info for each comment 1166 + for i := range comments { 1167 + profile, err := idx.GetProfile(ctx, comments[i].ActorDID) 1168 + if err != nil { 1169 + // Use DID as fallback handle 1170 + comments[i].Handle = comments[i].ActorDID 1171 + } else { 1172 + comments[i].Handle = profile.Handle 1173 + comments[i].DisplayName = profile.DisplayName 1174 + comments[i].Avatar = profile.Avatar 1175 + } 1176 + 1177 + commentURI := fmt.Sprintf("at://%s/social.arabica.alpha.comment/%s", comments[i].ActorDID, comments[i].RKey) 1178 + comments[i].LikeCount = idx.GetLikeCount(commentURI) 1179 + if viewerDID != "" { 1180 + comments[i].IsLiked = idx.HasUserLiked(viewerDID, commentURI) 1181 + } 1182 + } 1183 + 1184 + return comments 1185 + } 1186 + 1187 + // GetThreadedCommentsForSubject returns comments for a record in threaded order with depth 1188 + // Comments are returned in depth-first order (parent followed by children) 1189 + // Visual depth is capped at 2 levels for display purposes 1190 + func (idx *FeedIndex) GetThreadedCommentsForSubject(ctx context.Context, subjectURI string, limit int, viewerDID string) []IndexedComment { 1191 + // First get all comments for this subject 1192 + allComments := idx.GetCommentsForSubject(ctx, subjectURI, 0, viewerDID) // Get all, we'll limit after threading 1193 + 1194 + if len(allComments) == 0 { 1195 + return nil 1196 + } 1197 + 1198 + // Build a map of comment rkey -> comment for quick lookup 1199 + commentMap := make(map[string]*IndexedComment) 1200 + for i := range allComments { 1201 + commentMap[allComments[i].RKey] = &allComments[i] 1202 + } 1203 + 1204 + // Build parent -> children map 1205 + childrenMap := make(map[string][]*IndexedComment) 1206 + var topLevel []*IndexedComment 1207 + 1208 + for i := range allComments { 1209 + comment := &allComments[i] 1210 + if comment.ParentRKey == "" { 1211 + // Top-level comment 1212 + topLevel = append(topLevel, comment) 1213 + } else { 1214 + // Reply - add to parent's children 1215 + childrenMap[comment.ParentRKey] = append(childrenMap[comment.ParentRKey], comment) 1216 + } 1217 + } 1218 + 1219 + // Sort top-level comments by creation time (oldest first) 1220 + sort.Slice(topLevel, func(i, j int) bool { 1221 + return topLevel[i].CreatedAt.Before(topLevel[j].CreatedAt) 1222 + }) 1223 + 1224 + // Sort children within each parent by creation time 1225 + for _, children := range childrenMap { 1226 + sort.Slice(children, func(i, j int) bool { 1227 + return children[i].CreatedAt.Before(children[j].CreatedAt) 1228 + }) 1229 + } 1230 + 1231 + // Flatten the tree in depth-first order 1232 + var result []IndexedComment 1233 + var flatten func(comment *IndexedComment, depth int) 1234 + flatten = func(comment *IndexedComment, depth int) { 1235 + if limit > 0 && len(result) >= limit { 1236 + return 1237 + } 1238 + // Cap visual depth at 2 for display 1239 + visualDepth := depth 1240 + if visualDepth > 2 { 1241 + visualDepth = 2 1242 + } 1243 + comment.Depth = visualDepth 1244 + result = append(result, *comment) 1245 + 1246 + // Add children (if any) 1247 + if children, ok := childrenMap[comment.RKey]; ok { 1248 + for _, child := range children { 1249 + flatten(child, depth+1) 1250 + } 1251 + } 1252 + } 1253 + 1254 + for _, comment := range topLevel { 1255 + flatten(comment, 0) 1256 + } 1257 + 1258 + return result 1259 + }
+122
internal/firehose/index_test.go
··· 1 1 package firehose 2 2 3 3 import ( 4 + "context" 4 5 "testing" 5 6 "time" 7 + 8 + "github.com/stretchr/testify/assert" 6 9 ) 7 10 8 11 func TestBackfillTracking(t *testing.T) { ··· 100 103 } 101 104 } 102 105 } 106 + 107 + func TestCommentThreading(t *testing.T) { 108 + tmpDir := t.TempDir() 109 + idx, err := NewFeedIndex(tmpDir+"/test.db", 1*time.Hour) 110 + assert.NoError(t, err) 111 + defer idx.Close() 112 + 113 + ctx := context.Background() 114 + subjectURI := "at://did:plc:user1/social.arabica.alpha.brew/abc123" 115 + actorDID := "did:plc:commenter1" 116 + 117 + // Create a top-level comment 118 + now := time.Now() 119 + err = idx.UpsertComment(actorDID, "comment1", subjectURI, "", "cid1", "Top level comment", now) 120 + assert.NoError(t, err) 121 + 122 + // Create a reply to the top-level comment 123 + parentURI := "at://did:plc:commenter1/social.arabica.alpha.comment/comment1" 124 + err = idx.UpsertComment("did:plc:commenter2", "comment2", subjectURI, parentURI, "cid2", "Reply to comment", now.Add(time.Second)) 125 + assert.NoError(t, err) 126 + 127 + // Create a nested reply (depth 2) 128 + parentURI2 := "at://did:plc:commenter2/social.arabica.alpha.comment/comment2" 129 + err = idx.UpsertComment("did:plc:commenter3", "comment3", subjectURI, parentURI2, "cid3", "Nested reply", now.Add(2*time.Second)) 130 + assert.NoError(t, err) 131 + 132 + // Get threaded comments 133 + comments := idx.GetThreadedCommentsForSubject(ctx, subjectURI, 100, "") 134 + assert.Len(t, comments, 3) 135 + 136 + // Verify ordering and depth 137 + // Order should be: top-level (depth 0) -> reply (depth 1) -> nested reply (depth 2) 138 + assert.Equal(t, "comment1", comments[0].RKey) 139 + assert.Equal(t, 0, comments[0].Depth) 140 + 141 + assert.Equal(t, "comment2", comments[1].RKey) 142 + assert.Equal(t, 1, comments[1].Depth) 143 + 144 + assert.Equal(t, "comment3", comments[2].RKey) 145 + assert.Equal(t, 2, comments[2].Depth) 146 + 147 + // Verify comment count 148 + count := idx.GetCommentCount(subjectURI) 149 + assert.Equal(t, 3, count) 150 + } 151 + 152 + func TestCommentThreading_DepthCap(t *testing.T) { 153 + tmpDir := t.TempDir() 154 + idx, err := NewFeedIndex(tmpDir+"/test.db", 1*time.Hour) 155 + assert.NoError(t, err) 156 + defer idx.Close() 157 + 158 + ctx := context.Background() 159 + subjectURI := "at://did:plc:user1/social.arabica.alpha.brew/abc123" 160 + 161 + // Create a chain of comments: depth 0 -> 1 -> 2 -> 3 -> 4 162 + now := time.Now() 163 + parentURI := "" 164 + for i := 0; i < 5; i++ { 165 + rkey := "comment" + string(rune('A'+i)) 166 + err = idx.UpsertComment("did:plc:user", rkey, subjectURI, parentURI, "cid"+rkey, "Comment", now.Add(time.Duration(i)*time.Second)) 167 + assert.NoError(t, err) 168 + parentURI = "at://did:plc:user/social.arabica.alpha.comment/" + rkey 169 + } 170 + 171 + // Get threaded comments 172 + comments := idx.GetThreadedCommentsForSubject(ctx, subjectURI, 100, "") 173 + assert.Len(t, comments, 5) 174 + 175 + // Verify depth is capped at 2 176 + assert.Equal(t, 0, comments[0].Depth) // commentA 177 + assert.Equal(t, 1, comments[1].Depth) // commentB 178 + assert.Equal(t, 2, comments[2].Depth) // commentC (capped) 179 + assert.Equal(t, 2, comments[3].Depth) // commentD (capped at 2) 180 + assert.Equal(t, 2, comments[4].Depth) // commentE (capped at 2) 181 + } 182 + 183 + func TestCommentThreading_MultipleTopLevel(t *testing.T) { 184 + tmpDir := t.TempDir() 185 + idx, err := NewFeedIndex(tmpDir+"/test.db", 1*time.Hour) 186 + assert.NoError(t, err) 187 + defer idx.Close() 188 + 189 + ctx := context.Background() 190 + subjectURI := "at://did:plc:user1/social.arabica.alpha.brew/abc123" 191 + 192 + now := time.Now() 193 + 194 + // Create two top-level comments 195 + err = idx.UpsertComment("did:plc:user1", "topA", subjectURI, "", "cidA", "First top comment", now) 196 + assert.NoError(t, err) 197 + err = idx.UpsertComment("did:plc:user2", "topB", subjectURI, "", "cidB", "Second top comment", now.Add(5*time.Second)) 198 + assert.NoError(t, err) 199 + 200 + // Reply to first top-level comment 201 + err = idx.UpsertComment("did:plc:user3", "replyA1", subjectURI, "at://did:plc:user1/social.arabica.alpha.comment/topA", "cidA1", "Reply to first", now.Add(2*time.Second)) 202 + assert.NoError(t, err) 203 + 204 + // Reply to second top-level comment 205 + err = idx.UpsertComment("did:plc:user4", "replyB1", subjectURI, "at://did:plc:user2/social.arabica.alpha.comment/topB", "cidB1", "Reply to second", now.Add(6*time.Second)) 206 + assert.NoError(t, err) 207 + 208 + // Get threaded comments 209 + comments := idx.GetThreadedCommentsForSubject(ctx, subjectURI, 100, "") 210 + assert.Len(t, comments, 4) 211 + 212 + // Order should be: topA (oldest) -> replyA1 -> topB -> replyB1 213 + assert.Equal(t, "topA", comments[0].RKey) 214 + assert.Equal(t, 0, comments[0].Depth) 215 + 216 + assert.Equal(t, "replyA1", comments[1].RKey) 217 + assert.Equal(t, 1, comments[1].Depth) 218 + 219 + assert.Equal(t, "topB", comments[2].RKey) 220 + assert.Equal(t, 0, comments[2].Depth) 221 + 222 + assert.Equal(t, "replyB1", comments[3].RKey) 223 + assert.Equal(t, 1, comments[3].Depth) 224 + }
+58 -3
internal/handlers/admin.go
··· 182 182 canViewReports := h.moderationService.HasPermission(userDID, moderation.PermissionViewReports) 183 183 canBlock := h.moderationService.HasPermission(userDID, moderation.PermissionBlacklistUser) 184 184 canUnblock := h.moderationService.HasPermission(userDID, moderation.PermissionUnblacklistUser) 185 + canResetAutoHide := h.moderationService.HasPermission(userDID, moderation.PermissionResetAutoHide) 185 186 186 187 var hiddenRecords []moderation.HiddenRecord 187 188 var auditLog []moderation.AuditEntry ··· 222 223 CanUnhide: canUnhide, 223 224 CanViewLogs: canViewLogs, 224 225 CanViewReports: canViewReports, 225 - CanBlock: canBlock, 226 - CanUnblock: canUnblock, 227 - IsAdmin: isAdmin, 226 + CanBlock: canBlock, 227 + CanUnblock: canUnblock, 228 + CanResetAutoHide: canResetAutoHide, 229 + IsAdmin: isAdmin, 228 230 } 229 231 } 230 232 ··· 505 507 Str("did", req.DID). 506 508 Str("by", userDID). 507 509 Msg("User unblocked") 510 + 511 + w.Header().Set("HX-Trigger", "mod-action") 512 + w.WriteHeader(http.StatusOK) 513 + } 514 + 515 + // HandleResetAutoHide handles POST /_mod/reset-autohide 516 + // Resets the per-user auto-hide report counter so that only future reports count toward the threshold. 517 + func (h *Handler) HandleResetAutoHide(w http.ResponseWriter, r *http.Request) { 518 + userDID, err := atproto.GetAuthenticatedDID(r.Context()) 519 + if err != nil || userDID == "" { 520 + http.Error(w, "Authentication required", http.StatusUnauthorized) 521 + return 522 + } 523 + 524 + if h.moderationService == nil || !h.moderationService.HasPermission(userDID, moderation.PermissionResetAutoHide) { 525 + http.Error(w, "Permission denied", http.StatusForbidden) 526 + return 527 + } 528 + 529 + if err := r.ParseForm(); err != nil { 530 + http.Error(w, "Invalid request body", http.StatusBadRequest) 531 + return 532 + } 533 + targetDID := r.FormValue("did") 534 + if targetDID == "" { 535 + http.Error(w, "DID is required", http.StatusBadRequest) 536 + return 537 + } 538 + 539 + now := time.Now() 540 + if err := h.moderationStore.SetAutoHideReset(r.Context(), targetDID, now); err != nil { 541 + log.Error().Err(err).Str("did", targetDID).Msg("Failed to reset auto-hide") 542 + http.Error(w, "Failed to reset auto-hide", http.StatusInternalServerError) 543 + return 544 + } 545 + 546 + auditEntry := moderation.AuditEntry{ 547 + ID: generateTID(), 548 + Action: moderation.AuditActionResetAutoHide, 549 + ActorDID: userDID, 550 + TargetURI: targetDID, 551 + Reason: "Auto-hide report counter reset", 552 + Timestamp: now, 553 + AutoMod: false, 554 + } 555 + if err := h.moderationStore.LogAction(r.Context(), auditEntry); err != nil { 556 + log.Error().Err(err).Msg("Failed to log reset-autohide action") 557 + } 558 + 559 + log.Info(). 560 + Str("did", targetDID). 561 + Str("by", userDID). 562 + Msg("Auto-hide counter reset for user") 508 563 509 564 w.Header().Set("HX-Trigger", "mod-action") 510 565 w.WriteHeader(http.StatusOK)
+30
internal/handlers/brew.go
··· 9 9 "strings" 10 10 11 11 "arabica/internal/atproto" 12 + "arabica/internal/firehose" 13 + "arabica/internal/moderation" 12 14 "arabica/internal/models" 13 15 "arabica/internal/web/bff" 14 16 "arabica/internal/web/components" ··· 261 263 } 262 264 } 263 265 266 + // Get comment data 267 + var commentCount int 268 + var comments []firehose.IndexedComment 269 + if h.feedIndex != nil && subjectURI != "" { 270 + commentCount = h.feedIndex.GetCommentCount(subjectURI) 271 + comments = h.feedIndex.GetThreadedCommentsForSubject(r.Context(), subjectURI, 100, didStr) 272 + comments = h.filterHiddenComments(r.Context(), comments) 273 + } 274 + 275 + // Get moderation data 276 + var isModerator, canHideRecord, canBlockUser, isRecordHidden bool 277 + if h.moderationService != nil && isAuthenticated { 278 + isModerator = h.moderationService.IsModerator(didStr) 279 + canHideRecord = h.moderationService.HasPermission(didStr, moderation.PermissionHideRecord) 280 + canBlockUser = h.moderationService.HasPermission(didStr, moderation.PermissionBlacklistUser) 281 + } 282 + if h.moderationStore != nil && isModerator && subjectURI != "" { 283 + isRecordHidden = h.moderationStore.IsRecordHidden(r.Context(), subjectURI) 284 + } 285 + 264 286 // Create brew view props 265 287 brewViewProps := pages.BrewViewProps{ 266 288 Brew: brew, ··· 270 292 SubjectCID: subjectCID, 271 293 IsLiked: isLiked, 272 294 LikeCount: likeCount, 295 + CommentCount: commentCount, 296 + Comments: comments, 297 + CurrentUserDID: didStr, 273 298 ShareURL: shareURL, 299 + IsModerator: isModerator, 300 + CanHideRecord: canHideRecord, 301 + CanBlockUser: canBlockUser, 302 + IsRecordHidden: isRecordHidden, 303 + AuthorDID: brewOwnerDID, 274 304 } 275 305 276 306 // Render using templ component
+224
internal/handlers/handlers.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "fmt" 6 7 "net/http" 7 8 "strings" 8 9 ··· 13 14 "arabica/internal/feed" 14 15 "arabica/internal/firehose" 15 16 "arabica/internal/middleware" 17 + "arabica/internal/models" 16 18 "arabica/internal/moderation" 17 19 "arabica/internal/web/bff" 18 20 "arabica/internal/web/components" ··· 252 254 IsModerator: isModerator, 253 255 } 254 256 } 257 + 258 + // HandleCommentCreate handles creating a new comment 259 + func (h *Handler) HandleCommentCreate(w http.ResponseWriter, r *http.Request) { 260 + // Require authentication 261 + store, authenticated := h.getAtprotoStore(r) 262 + if !authenticated { 263 + http.Error(w, "Authentication required", http.StatusUnauthorized) 264 + return 265 + } 266 + 267 + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 268 + 269 + if err := r.ParseForm(); err != nil { 270 + http.Error(w, "Invalid form data", http.StatusBadRequest) 271 + return 272 + } 273 + 274 + subjectURI := r.FormValue("subject_uri") 275 + subjectCID := r.FormValue("subject_cid") 276 + text := strings.TrimSpace(r.FormValue("text")) 277 + parentURI := r.FormValue("parent_uri") 278 + parentCID := r.FormValue("parent_cid") 279 + 280 + if subjectURI == "" || subjectCID == "" { 281 + http.Error(w, "subject_uri and subject_cid are required", http.StatusBadRequest) 282 + return 283 + } 284 + 285 + if text == "" { 286 + http.Error(w, "comment text is required", http.StatusBadRequest) 287 + return 288 + } 289 + 290 + if len(text) > models.MaxCommentLength { 291 + http.Error(w, "comment text is too long", http.StatusBadRequest) 292 + return 293 + } 294 + 295 + // Validate that parent fields are either both present or both absent 296 + if (parentURI != "" && parentCID == "") || (parentURI == "" && parentCID != "") { 297 + http.Error(w, "both parent_uri and parent_cid must be provided together", http.StatusBadRequest) 298 + return 299 + } 300 + 301 + req := &models.CreateCommentRequest{ 302 + SubjectURI: subjectURI, 303 + SubjectCID: subjectCID, 304 + Text: text, 305 + ParentURI: parentURI, 306 + ParentCID: parentCID, 307 + } 308 + 309 + comment, err := store.CreateComment(r.Context(), req) 310 + if err != nil { 311 + http.Error(w, "Failed to create comment", http.StatusInternalServerError) 312 + log.Error().Err(err).Msg("Failed to create comment") 313 + return 314 + } 315 + 316 + // Update firehose index (pass parent URI and comment's CID for threading) 317 + if h.feedIndex != nil { 318 + _ = h.feedIndex.UpsertComment(didStr, comment.RKey, subjectURI, parentURI, comment.CID, text, comment.CreatedAt) 319 + } 320 + 321 + // Return the updated comment section with threaded comments 322 + comments := h.feedIndex.GetThreadedCommentsForSubject(r.Context(), subjectURI, 100, didStr) 323 + 324 + // Build moderation context 325 + var modCtx components.CommentModerationContext 326 + if h.moderationService != nil { 327 + if h.moderationService.IsModerator(didStr) { 328 + modCtx.IsModerator = true 329 + modCtx.CanHideRecord = h.moderationService.HasPermission(didStr, moderation.PermissionHideRecord) 330 + modCtx.CanBlockUser = h.moderationService.HasPermission(didStr, moderation.PermissionBlacklistUser) 331 + } 332 + } 333 + 334 + if err := components.CommentSection(components.CommentSectionProps{ 335 + SubjectURI: subjectURI, 336 + SubjectCID: subjectCID, 337 + Comments: comments, 338 + IsAuthenticated: true, 339 + CurrentUserDID: didStr, 340 + ModCtx: modCtx, 341 + }).Render(r.Context(), w); err != nil { 342 + http.Error(w, "Failed to render", http.StatusInternalServerError) 343 + log.Error().Err(err).Msg("Failed to render comment section") 344 + } 345 + } 346 + 347 + // HandleCommentDelete handles deleting a comment 348 + func (h *Handler) HandleCommentDelete(w http.ResponseWriter, r *http.Request) { 349 + // Require authentication 350 + store, authenticated := h.getAtprotoStore(r) 351 + if !authenticated { 352 + http.Error(w, "Authentication required", http.StatusUnauthorized) 353 + return 354 + } 355 + 356 + didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 357 + 358 + rkey := r.PathValue("id") 359 + if rkey == "" { 360 + http.Error(w, "Comment ID is required", http.StatusBadRequest) 361 + return 362 + } 363 + 364 + // Get the comment to find its subject URI before deletion 365 + comments, err := store.ListUserComments(r.Context()) 366 + if err != nil { 367 + http.Error(w, "Failed to get comments", http.StatusInternalServerError) 368 + log.Error().Err(err).Msg("Failed to get user comments") 369 + return 370 + } 371 + 372 + var subjectURI string 373 + for _, c := range comments { 374 + if c.RKey == rkey { 375 + subjectURI = c.SubjectURI 376 + break 377 + } 378 + } 379 + 380 + if subjectURI == "" { 381 + http.Error(w, "Comment not found", http.StatusNotFound) 382 + return 383 + } 384 + 385 + // Delete the comment 386 + if err := store.DeleteCommentByRKey(r.Context(), rkey); err != nil { 387 + http.Error(w, "Failed to delete comment", http.StatusInternalServerError) 388 + log.Error().Err(err).Msg("Failed to delete comment") 389 + return 390 + } 391 + 392 + // Update firehose index 393 + if h.feedIndex != nil { 394 + _ = h.feedIndex.DeleteComment(didStr, rkey, subjectURI) 395 + } 396 + 397 + // Return empty response (the comment element will be removed via hx-swap="outerHTML") 398 + w.WriteHeader(http.StatusOK) 399 + } 400 + 401 + // filterHiddenComments removes comments that have been hidden by moderation. 402 + // Children of hidden comments are kept but shifted up in depth. 403 + func (h *Handler) filterHiddenComments(ctx context.Context, comments []firehose.IndexedComment) []firehose.IndexedComment { 404 + if h.moderationStore == nil || len(comments) == 0 { 405 + return comments 406 + } 407 + 408 + // Build set of hidden comment rkeys for depth adjustment 409 + hiddenRKeys := make(map[string]bool) 410 + for _, c := range comments { 411 + uri := fmt.Sprintf("at://%s/social.arabica.alpha.comment/%s", c.ActorDID, c.RKey) 412 + if h.moderationStore.IsRecordHidden(ctx, uri) { 413 + hiddenRKeys[c.RKey] = true 414 + } 415 + } 416 + 417 + if len(hiddenRKeys) == 0 { 418 + return comments 419 + } 420 + 421 + filtered := make([]firehose.IndexedComment, 0, len(comments)) 422 + for _, c := range comments { 423 + if hiddenRKeys[c.RKey] { 424 + continue 425 + } 426 + // If this comment's parent was hidden, reduce depth by 1 427 + if c.ParentRKey != "" && hiddenRKeys[c.ParentRKey] && c.Depth > 0 { 428 + c.Depth-- 429 + } 430 + filtered = append(filtered, c) 431 + } 432 + return filtered 433 + } 434 + 435 + // HandleCommentList returns the comment section for a subject 436 + func (h *Handler) HandleCommentList(w http.ResponseWriter, r *http.Request) { 437 + subjectURI := r.URL.Query().Get("subject_uri") 438 + if subjectURI == "" { 439 + http.Error(w, "subject_uri is required", http.StatusBadRequest) 440 + return 441 + } 442 + 443 + // Get authenticated user if any 444 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 445 + isAuthenticated := err == nil && didStr != "" 446 + 447 + // Get the subject CID from query params (for the form) 448 + subjectCID := r.URL.Query().Get("subject_cid") 449 + 450 + // Get threaded comments from firehose index 451 + var comments []firehose.IndexedComment 452 + if h.feedIndex != nil { 453 + comments = h.feedIndex.GetThreadedCommentsForSubject(r.Context(), subjectURI, 100, didStr) 454 + comments = h.filterHiddenComments(r.Context(), comments) 455 + } 456 + 457 + // Build moderation context 458 + var modCtx components.CommentModerationContext 459 + if h.moderationService != nil && isAuthenticated { 460 + if h.moderationService.IsModerator(didStr) { 461 + modCtx.IsModerator = true 462 + modCtx.CanHideRecord = h.moderationService.HasPermission(didStr, moderation.PermissionHideRecord) 463 + modCtx.CanBlockUser = h.moderationService.HasPermission(didStr, moderation.PermissionBlacklistUser) 464 + } 465 + } 466 + 467 + if err := components.CommentSection(components.CommentSectionProps{ 468 + SubjectURI: subjectURI, 469 + SubjectCID: subjectCID, 470 + Comments: comments, 471 + IsAuthenticated: isAuthenticated, 472 + CurrentUserDID: didStr, 473 + ModCtx: modCtx, 474 + }).Render(r.Context(), w); err != nil { 475 + http.Error(w, "Failed to render", http.StatusInternalServerError) 476 + log.Error().Err(err).Msg("Failed to render comment section") 477 + } 478 + }
+12 -2
internal/handlers/report.go
··· 184 184 return 185 185 } 186 186 187 - // Check total report count for content by this user 188 - didReportCount, err := h.moderationStore.CountReportsForDID(ctx, report.SubjectDID) 187 + // Check total report count for content by this user (respecting any reset) 188 + var didReportCount int 189 + resetAt, err := h.moderationStore.GetAutoHideReset(ctx, report.SubjectDID) 190 + if err != nil { 191 + log.Error().Err(err).Str("did", report.SubjectDID).Msg("moderation: failed to get auto-hide reset for automod") 192 + return 193 + } 194 + if !resetAt.IsZero() { 195 + didReportCount, err = h.moderationStore.CountReportsForDIDSince(ctx, report.SubjectDID, resetAt) 196 + } else { 197 + didReportCount, err = h.moderationStore.CountReportsForDID(ctx, report.SubjectDID) 198 + } 189 199 if err != nil { 190 200 log.Error().Err(err).Str("did", report.SubjectDID).Msg("moderation: failed to count DID reports for automod") 191 201 return
+50 -3
internal/models/models.go
··· 16 16 MaxRoastLevelLength = 100 17 17 MaxProcessLength = 100 18 18 MaxMethodLength = 100 19 - MaxGrindSizeLength = 100 19 + MaxGrindSizeLength = 100 20 + MaxTastingNotesLength = 2000 20 21 MaxGrinderTypeLength = 50 21 22 MaxBurrTypeLength = 50 22 - MaxBrewerTypeLength = 100 23 - MaxTastingNotesLength = 2000 23 + MaxBrewerTypeLength = 100 24 + MaxCommentLength = 1000 25 + MaxCommentGraphemes = 300 24 26 ) 25 27 26 28 // Validation errors ··· 33 35 ErrNotesTooLong = errors.New("notes is too long") 34 36 ErrOriginTooLong = errors.New("origin is too long") 35 37 ErrFieldTooLong = errors.New("field value is too long") 38 + ErrCommentRequired = errors.New("comment text is required") 39 + ErrCommentTooLong = errors.New("comment text is too long") 36 40 ) 37 41 38 42 // TODO: maybe add a "rating" field that can be updated when a bag is closed ··· 198 202 SubjectCID string `json:"subject_cid"` 199 203 } 200 204 205 + // Comment represents a comment on an Arabica record 206 + type Comment struct { 207 + RKey string `json:"rkey"` 208 + CID string `json:"cid,omitempty"` // CID of this comment record 209 + SubjectURI string `json:"subject_uri"` 210 + SubjectCID string `json:"subject_cid"` 211 + Text string `json:"text"` 212 + CreatedAt time.Time `json:"created_at"` 213 + ActorDID string `json:"actor_did,omitempty"` 214 + ParentURI string `json:"parent_uri,omitempty"` // AT-URI of parent comment for replies 215 + ParentCID string `json:"parent_cid,omitempty"` // CID of parent comment for replies 216 + } 217 + 218 + // CreateCommentRequest contains the data needed to create a comment 219 + type CreateCommentRequest struct { 220 + SubjectURI string `json:"subject_uri"` 221 + SubjectCID string `json:"subject_cid"` 222 + Text string `json:"text"` 223 + ParentURI string `json:"parent_uri,omitempty"` // AT-URI of parent comment for replies 224 + ParentCID string `json:"parent_cid,omitempty"` // CID of parent comment for replies 225 + } 226 + 201 227 // Validate checks that all fields are within acceptable limits 202 228 func (r *CreateBeanRequest) Validate() error { 203 229 if r.Name == "" { ··· 362 388 } 363 389 if len(r.Description) > MaxDescriptionLength { 364 390 return ErrDescTooLong 391 + } 392 + return nil 393 + } 394 + 395 + // Validate checks that all fields are within acceptable limits 396 + func (r *CreateCommentRequest) Validate() error { 397 + if r.Text == "" { 398 + return ErrCommentRequired 399 + } 400 + if len(r.Text) > MaxCommentLength { 401 + return ErrCommentTooLong 402 + } 403 + if r.SubjectURI == "" { 404 + return errors.New("subject_uri is required") 405 + } 406 + if r.SubjectCID == "" { 407 + return errors.New("subject_cid is required") 408 + } 409 + // If parent fields are provided, both must be present 410 + if (r.ParentURI != "" && r.ParentCID == "") || (r.ParentURI == "" && r.ParentCID != "") { 411 + return errors.New("both parent_uri and parent_cid must be provided together") 365 412 } 366 413 return nil 367 414 }
+3
internal/moderation/models.go
··· 13 13 PermissionViewReports Permission = "view_reports" 14 14 PermissionDismissReport Permission = "dismiss_report" 15 15 PermissionViewAuditLog Permission = "view_audit_log" 16 + PermissionResetAutoHide Permission = "reset_autohide" 16 17 ) 17 18 18 19 // AllPermissions returns all available permissions ··· 25 26 PermissionViewReports, 26 27 PermissionDismissReport, 27 28 PermissionViewAuditLog, 29 + PermissionResetAutoHide, 28 30 } 29 31 } 30 32 ··· 150 152 AuditActionUnblacklistUser AuditAction = "unblacklist_user" 151 153 AuditActionDismissReport AuditAction = "dismiss_report" 152 154 AuditActionActionReport AuditAction = "action_report" 155 + AuditActionResetAutoHide AuditAction = "reset_autohide" 153 156 AuditActionDismissJoinRequest AuditAction = "dismiss_join_request" 154 157 AuditActionCreateInvite AuditAction = "create_invite" 155 158 )
+16 -10
internal/routing/routing.go
··· 92 92 mux.Handle("POST /api/likes/toggle", cop.Handler(http.HandlerFunc(h.HandleLikeToggle))) 93 93 mux.Handle("POST /api/report", cop.Handler(http.HandlerFunc(h.HandleReport))) 94 94 95 - // Moderation routes (obscured path) 96 - mux.HandleFunc("GET /_mod", h.HandleAdmin) 97 - mux.Handle("GET /_mod/content", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleAdminPartial))) 98 - mux.Handle("POST /_mod/hide", cop.Handler(http.HandlerFunc(h.HandleHideRecord))) 99 - mux.Handle("POST /_mod/unhide", cop.Handler(http.HandlerFunc(h.HandleUnhideRecord))) 100 - mux.Handle("POST /_mod/dismiss-report", cop.Handler(http.HandlerFunc(h.HandleDismissReport))) 101 - mux.Handle("POST /_mod/block", cop.Handler(http.HandlerFunc(h.HandleBlockUser))) 102 - mux.Handle("POST /_mod/unblock", cop.Handler(http.HandlerFunc(h.HandleUnblockUser))) 103 - mux.Handle("POST /_mod/invite", cop.Handler(http.HandlerFunc(h.HandleCreateInvite))) 104 - mux.Handle("POST /_mod/dismiss-join", cop.Handler(http.HandlerFunc(h.HandleDismissJoinRequest))) 95 + // Comment routes 96 + mux.Handle("GET /api/comments", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleCommentList))) 97 + mux.Handle("POST /api/comments", cop.Handler(http.HandlerFunc(h.HandleCommentCreate))) 98 + mux.Handle("DELETE /api/comments/{id}", cop.Handler(http.HandlerFunc(h.HandleCommentDelete))) 105 99 106 100 // Modal routes for entity management (return dialog HTML) 107 101 mux.HandleFunc("GET /api/modals/bean/new", h.HandleBeanModalNew) ··· 115 109 116 110 // Profile routes (public user profiles) 117 111 mux.HandleFunc("GET /profile/{actor}", h.HandleProfile) 112 + 113 + // Moderation routes 114 + mux.HandleFunc("GET /_mod", h.HandleAdmin) 115 + mux.Handle("GET /_mod/content", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleAdminPartial))) 116 + mux.Handle("POST /_mod/hide", cop.Handler(http.HandlerFunc(h.HandleHideRecord))) 117 + mux.Handle("POST /_mod/unhide", cop.Handler(http.HandlerFunc(h.HandleUnhideRecord))) 118 + mux.Handle("POST /_mod/dismiss-report", cop.Handler(http.HandlerFunc(h.HandleDismissReport))) 119 + mux.Handle("POST /_mod/reset-autohide", cop.Handler(http.HandlerFunc(h.HandleResetAutoHide))) 120 + mux.Handle("POST /_mod/block", cop.Handler(http.HandlerFunc(h.HandleBlockUser))) 121 + mux.Handle("POST /_mod/unblock", cop.Handler(http.HandlerFunc(h.HandleUnblockUser))) 122 + mux.Handle("POST /_mod/invite", cop.Handler(http.HandlerFunc(h.HandleCreateInvite))) 123 + mux.Handle("POST /_mod/dismiss-join", cop.Handler(http.HandlerFunc(h.HandleDismissJoinRequest))) 118 124 119 125 // Static files (must come after specific routes) 120 126 fs := http.FileServer(http.Dir("static"))
+109 -29
internal/web/components/action_bar.templ
··· 15 15 IsLiked bool 16 16 LikeCount int 17 17 18 + // Comment state 19 + CommentCount int 20 + ViewURL string // URL to view the item (for comment link) 21 + ShowComments bool // Whether to show the comment button 22 + 18 23 // Share props 19 24 ShareURL string 20 25 ShareTitle string 21 26 ShareText string 22 27 23 28 // Ownership and actions 24 - IsOwner bool 25 - EditURL string 26 - DeleteURL string 29 + IsOwner bool 30 + EditURL string 31 + EditModalURL string // If set, loads edit modal via HTMX (for entities that use modal editing) 32 + DeleteURL string 33 + DeleteTarget string // HTMX target selector for delete (defaults to "closest .feed-card") 34 + DeleteRedirect string // If set, redirect to this URL after delete instead of swapping 27 35 28 36 // Auth state 29 37 IsAuthenticated bool ··· 36 44 AuthorDID string // DID of the content author (for block action) 37 45 } 38 46 47 + // getCommentHref returns the href for the comment button. 48 + func (p ActionBarProps) getCommentHref() string { 49 + if p.ViewURL != "" { 50 + return p.ViewURL + "#comment-section" 51 + } 52 + return "#comment-section" 53 + } 54 + 55 + // getDeleteTarget returns the HTMX target for the delete button. 56 + func (p ActionBarProps) getDeleteTarget() string { 57 + if p.DeleteTarget != "" { 58 + return p.DeleteTarget 59 + } 60 + return "closest .feed-card" 61 + } 62 + 39 63 // hasModActions returns true if any moderation menu items will render. 40 64 func (p ActionBarProps) hasModActions() bool { 41 65 return (p.CanHideRecord && p.SubjectURI != "") || ··· 51 75 // Order: [💬 Comments] [♡ Like] [↗ Share] [⋯ More] 52 76 templ ActionBar(props ActionBarProps) { 53 77 <div class="action-bar" x-data="{ moreOpen: false, openUp: true }"> 54 - <!-- Comments (placeholder) --> 55 - <button 56 - type="button" 57 - class="action-btn action-btn-disabled" 58 - disabled 59 - title="Comments coming soon" 60 - > 61 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 62 - <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> 63 - </svg> 64 - <span>0</span> 65 - </button> 78 + <!-- Comments --> 79 + if props.ShowComments { 80 + <a 81 + href={ templ.SafeURL(props.getCommentHref()) } 82 + class="action-btn" 83 + title="View comments" 84 + > 85 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 86 + <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> 87 + </svg> 88 + <span>{ fmt.Sprintf("%d", props.CommentCount) }</span> 89 + </a> 90 + } 66 91 <!-- Hidden indicator (visible to moderators) --> 67 92 if props.IsModerator && props.IsRecordHidden { 68 93 <span class="hidden-badge" title="This record is hidden from the public feed"> ··· 121 146 Edit 122 147 </a> 123 148 } 124 - if props.DeleteURL != "" { 125 - <button 126 - type="button" 127 - hx-delete={ props.DeleteURL } 128 - hx-confirm="Are you sure you want to delete this?" 129 - hx-target="closest .feed-card" 130 - hx-swap="outerHTML" 131 - class="action-menu-item action-menu-item-danger" 132 - > 133 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 134 - <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path> 135 - </svg> 136 - Delete 137 - </button> 149 + if props.EditModalURL != "" { 150 + @ActionBarEditModal(props) 151 + } 152 + if props.DeleteURL != "" && props.DeleteRedirect != "" { 153 + @ActionBarDeleteRedirect(props) 154 + } else if props.DeleteURL != "" { 155 + @ActionBarDeleteSwap(props) 138 156 } 139 157 if props.hasModActions() || props.hasReportAction() { 140 158 <div class="action-menu-divider"></div> ··· 339 357 </template> 340 358 </div> 341 359 </dialog> 360 + } 361 + 362 + // deleteRedirectScript returns an inline script for redirecting after a successful HTMX delete 363 + func deleteRedirectScript(url string) string { 364 + return fmt.Sprintf("if(event.detail.successful) window.location.href='%s'", url) 365 + } 366 + 367 + // editModalAfterSwapScript returns an inline script for opening a modal after HTMX swap and reloading on save 368 + func editModalAfterSwapScript() string { 369 + return "var d=document.querySelector('#modal-container dialog');if(d){d.showModal();var h=function(){window.location.reload()};document.body.addEventListener('refreshManage',h,{once:true});d.addEventListener('close',function(){document.body.removeEventListener('refreshManage',h)},{once:true})}" 370 + } 371 + 372 + // ActionBarDeleteRedirect renders a delete button that redirects after success (for view pages) 373 + templ ActionBarDeleteRedirect(props ActionBarProps) { 374 + <button 375 + type="button" 376 + hx-delete={ props.DeleteURL } 377 + hx-confirm="Are you sure you want to delete this?" 378 + hx-swap="none" 379 + hx-on--after-request={ deleteRedirectScript(props.DeleteRedirect) } 380 + class="action-menu-item action-menu-item-danger" 381 + > 382 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 383 + <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path> 384 + </svg> 385 + Delete 386 + </button> 387 + } 388 + 389 + // ActionBarDeleteSwap renders a delete button that swaps out the target element (for feed cards) 390 + templ ActionBarDeleteSwap(props ActionBarProps) { 391 + <button 392 + type="button" 393 + hx-delete={ props.DeleteURL } 394 + hx-confirm="Are you sure you want to delete this?" 395 + hx-target={ props.getDeleteTarget() } 396 + hx-swap="outerHTML" 397 + class="action-menu-item action-menu-item-danger" 398 + > 399 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 400 + <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path> 401 + </svg> 402 + Delete 403 + </button> 404 + } 405 + 406 + // ActionBarEditModal renders an edit button that loads an edit modal via HTMX (for entity view pages) 407 + templ ActionBarEditModal(props ActionBarProps) { 408 + <button 409 + type="button" 410 + hx-get={ props.EditModalURL } 411 + hx-target="#modal-container" 412 + hx-swap="innerHTML" 413 + class="action-menu-item" 414 + @click="moreOpen = false" 415 + hx-on--after-swap={ editModalAfterSwapScript() } 416 + > 417 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 418 + <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125"></path> 419 + </svg> 420 + Edit 421 + </button> 342 422 } 343 423 344 424 // escapeForAlpine escapes special characters in a string for use in Alpine.js expressions.
+348
internal/web/components/comments.templ
··· 1 + package components 2 + 3 + import ( 4 + "arabica/internal/firehose" 5 + "arabica/internal/web/bff" 6 + "fmt" 7 + ) 8 + 9 + // CommentButtonProps defines properties for the comment button 10 + type 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 18 + templ 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 40 + type 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 47 + type 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 58 + templ 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 104 + type 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 110 + templ 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 137 + type 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 148 + templ 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 177 + type 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 190 + templ 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 262 + type 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 270 + templ 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 301 + func getCommentHandle(comment firehose.IndexedComment) string { 302 + if comment.Handle != "" { 303 + return comment.Handle 304 + } 305 + return comment.ActorDID 306 + } 307 + 308 + func getCommentDisplayName(comment firehose.IndexedComment) string { 309 + if comment.DisplayName != nil { 310 + return *comment.DisplayName 311 + } 312 + return "" 313 + } 314 + 315 + func 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 323 + func 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 335 + func 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 343 + func 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 +
+1 -1
internal/web/components/layout.templ
··· 74 74 <link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/> 75 75 <link rel="icon" href="/static/favicon-32.svg" type="image/svg+xml" sizes="32x32"/> 76 76 <link rel="apple-touch-icon" href="/static/icon-192.svg"/> 77 - <link rel="stylesheet" href="/static/css/output.css?v=0.5.1"/> 77 + <link rel="stylesheet" href="/static/css/output.css?v=0.5.3"/> 78 78 <style> 79 79 [x-cloak] { display: none !important; } 80 80 </style>
+13 -17
internal/web/components/profile_brew_card.templ
··· 14 14 ProfileHandle string 15 15 Profile *atproto.Profile 16 16 LikeCount int 17 + CommentCount int 17 18 IsLiked bool 18 19 IsAuthenticated bool 19 20 SubjectCID string // CID for like functionality (empty if not available) ··· 23 24 templ ProfileBrewCard(props ProfileBrewCardProps) { 24 25 <div class="feed-card"> 25 26 <!-- Author row --> 26 - <div class="flex items-center gap-3 mb-3"> 27 - <a href={ templ.SafeURL("/profile/" + props.ProfileHandle) } class="flex-shrink-0"> 28 - @Avatar(AvatarProps{ 29 - AvatarURL: getProfileAvatarURL(props.Profile), 30 - DisplayName: getProfileDisplayName(props.Profile), 31 - Size: "md", 32 - }) 33 - </a> 34 - <div class="flex-1 min-w-0"> 35 - <div class="flex items-center gap-2"> 36 - if props.Profile != nil && props.Profile.DisplayName != nil && *props.Profile.DisplayName != "" { 37 - <a href={ templ.SafeURL("/profile/" + props.ProfileHandle) } class="link-bold text-brown-900 truncate">{ *props.Profile.DisplayName }</a> 38 - } 39 - <a href={ templ.SafeURL("/profile/" + props.ProfileHandle) } class="link text-sm truncate">{ "@" + props.ProfileHandle }</a> 40 - </div> 41 - <span class="text-brown-500 text-sm">{ bff.FormatTimeAgo(props.Brew.CreatedAt) }</span> 42 - </div> 27 + <div class="mb-3"> 28 + @UserBadge(UserBadgeProps{ 29 + ProfileURL: "/profile/" + props.ProfileHandle, 30 + AvatarURL: getProfileAvatarURL(props.Profile), 31 + DisplayName: getProfileDisplayName(props.Profile), 32 + Handle: props.ProfileHandle, 33 + TimeAgo: bff.FormatTimeAgo(props.Brew.CreatedAt), 34 + Size: "md", 35 + }) 43 36 </div> 44 37 <!-- Action text --> 45 38 <div class="mb-2 text-sm text-brown-700"> ··· 64 57 SubjectCID: props.SubjectCID, 65 58 IsLiked: props.IsLiked, 66 59 LikeCount: props.LikeCount, 60 + CommentCount: props.CommentCount, 61 + ShowComments: true, 62 + ViewURL: fmt.Sprintf("/brews/%s?owner=%s", props.Brew.RKey, props.ProfileHandle), 67 63 ShareURL: fmt.Sprintf("/brews/%s?owner=%s", props.Brew.RKey, props.ProfileHandle), 68 64 ShareTitle: getBrewShareTitle(props.Brew), 69 65 ShareText: getBrewShareText(props.Brew, props.ProfileHandle),
+13 -1
internal/web/components/profile_partial.templ
··· 20 20 BrewLikeCounts map[string]int 21 21 BrewLikedByUser map[string]bool 22 22 BrewCIDs map[string]string // CIDs keyed by brew RKey 23 - IsAuthenticated bool 23 + // Comment counts for brews (keyed by brew RKey) 24 + BrewCommentCounts map[string]int 25 + IsAuthenticated bool 24 26 } 25 27 26 28 // ProfileContentPartial renders the profile tabs content (for HTMX loading) ··· 35 37 ProfileHandle: props.ProfileHandle, 36 38 Profile: props.Profile, 37 39 LikeCounts: props.BrewLikeCounts, 40 + CommentCounts: props.BrewCommentCounts, 38 41 LikedByUser: props.BrewLikedByUser, 39 42 BrewCIDs: props.BrewCIDs, 40 43 IsAuthenticated: props.IsAuthenticated, ··· 142 145 ProfileHandle string 143 146 Profile *atproto.Profile 144 147 LikeCounts map[string]int 148 + CommentCounts map[string]int 145 149 LikedByUser map[string]bool 146 150 IsAuthenticated bool 147 151 BrewCIDs map[string]string // CIDs keyed by brew RKey ··· 170 174 ProfileHandle: props.ProfileHandle, 171 175 Profile: props.Profile, 172 176 LikeCount: getLikeCount(props.LikeCounts, brew.RKey), 177 + CommentCount: getCommentCount(props.CommentCounts, brew.RKey), 173 178 IsLiked: isLikedByUser(props.LikedByUser, brew.RKey), 174 179 IsAuthenticated: props.IsAuthenticated, 175 180 SubjectCID: getBrewCID(props.BrewCIDs, brew.RKey), ··· 198 203 return "" 199 204 } 200 205 return cids[rkey] 206 + } 207 + 208 + func getCommentCount(counts map[string]int, rkey string) int { 209 + if counts == nil { 210 + return 0 211 + } 212 + return counts[rkey] 201 213 } 202 214 203 215 // ProfileEquipmentTab renders the equipment tab for profile (grinders and brewers only)
+58
internal/web/components/shared.templ
··· 192 192 </div> 193 193 } 194 194 195 + // UserBadgeProps defines properties for user badge (avatar + name + handle) 196 + type UserBadgeProps struct { 197 + ProfileURL string // URL to link to (e.g., "/profile/handle") 198 + AvatarURL string 199 + DisplayName string 200 + Handle string 201 + TimeAgo string // Optional timestamp to display 202 + Size string // "sm" or "md" - defaults to "md" 203 + } 204 + 205 + // UserBadge renders a user avatar with display name and handle, properly aligned 206 + templ UserBadge(props UserBadgeProps) { 207 + <div class="flex items-center gap-3"> 208 + <a href={ templ.SafeURL(props.ProfileURL) } class="flex-shrink-0"> 209 + @Avatar(AvatarProps{ 210 + AvatarURL: props.AvatarURL, 211 + DisplayName: props.DisplayName, 212 + Size: props.Size, 213 + }) 214 + </a> 215 + <div class="flex-1 min-w-0"> 216 + <div class={ templ.Classes( 217 + "flex items-center gap-2", 218 + templ.KV("flex-wrap", props.Size == "sm"), 219 + ) }> 220 + if props.DisplayName != "" { 221 + <a 222 + href={ templ.SafeURL(props.ProfileURL) } 223 + class={ templ.Classes( 224 + "text-brown-900 truncate", 225 + templ.KV("font-medium hover:text-brown-700", props.Size == "sm"), 226 + templ.KV("link-bold", props.Size == "md" || props.Size == ""), 227 + ) } 228 + > 229 + { props.DisplayName } 230 + </a> 231 + } 232 + <a 233 + href={ templ.SafeURL(props.ProfileURL) } 234 + class={ templ.Classes( 235 + "truncate", 236 + templ.KV("text-sm text-brown-600 hover:text-brown-800", props.Size == "sm"), 237 + templ.KV("link text-sm", props.Size == "md" || props.Size == ""), 238 + ) } 239 + > 240 + { "@" + props.Handle } 241 + </a> 242 + if props.Size == "sm" && props.TimeAgo != "" { 243 + <span class="text-brown-500 text-sm">{ props.TimeAgo }</span> 244 + } 245 + </div> 246 + if (props.Size == "md" || props.Size == "") && props.TimeAgo != "" { 247 + <span class="text-brown-500 text-sm">{ props.TimeAgo }</span> 248 + } 249 + </div> 250 + </div> 251 + } 252 + 195 253 // AvatarProps defines properties for avatar rendering 196 254 type AvatarProps struct { 197 255 AvatarURL string
+13 -1
internal/web/components/social_buttons.templ
··· 8 8 IsLiked bool // Whether the current user has liked this record 9 9 LikeCount int // Number of likes on this record 10 10 11 + // Comment button props 12 + CommentCount int // Number of comments on this record 13 + ShowComment bool // Whether to show the comment button 14 + 11 15 // Share button props 12 16 ShareURL string // URL to share 13 17 ShareTitle string // Title for native share dialog ··· 18 22 IsAuthenticated bool // Whether the user is authenticated (controls click behavior) 19 23 } 20 24 21 - // SocialButtons renders a cluster of social interaction buttons (like, share) 25 + // SocialButtons renders a cluster of social interaction buttons (like, comment, share) 22 26 templ SocialButtons(props SocialButtonsProps) { 23 27 <div class="flex items-center gap-2"> 24 28 if props.ShowLike && props.SubjectURI != "" && props.SubjectCID != "" { ··· 27 31 SubjectCID: props.SubjectCID, 28 32 IsLiked: props.IsLiked, 29 33 LikeCount: props.LikeCount, 34 + IsAuthenticated: props.IsAuthenticated, 35 + }) 36 + } 37 + if props.ShowComment && props.SubjectURI != "" && props.SubjectCID != "" { 38 + @CommentButton(CommentButtonProps{ 39 + SubjectURI: props.SubjectURI, 40 + SubjectCID: props.SubjectCID, 41 + CommentCount: props.CommentCount, 30 42 IsAuthenticated: props.IsAuthenticated, 31 43 }) 32 44 }
+22 -6
internal/web/pages/admin.templ
··· 25 25 CanUnhide bool 26 26 CanViewLogs bool 27 27 CanViewReports bool 28 - CanBlock bool 29 - CanUnblock bool 30 - IsAdmin bool 28 + CanBlock bool 29 + CanUnblock bool 30 + CanResetAutoHide bool 31 + IsAdmin bool 31 32 } 32 33 33 34 templ Admin(layout *components.LayoutData, props AdminProps) { ··· 185 186 } else { 186 187 <div class="space-y-4"> 187 188 for _, report := range props.Reports { 188 - @ReportCard(report, props.CanHide, props.CanBlock) 189 + @ReportCard(report, props.CanHide, props.CanBlock, props.CanResetAutoHide) 189 190 } 190 191 </div> 191 192 } ··· 358 359 </div> 359 360 } 360 361 361 - templ ReportCard(report EnrichedReport, canHide bool, canBlock bool) { 362 + templ ReportCard(report EnrichedReport, canHide bool, canBlock bool, canResetAutoHide bool) { 362 363 <div class="bg-brown-50 border border-brown-200 rounded-lg p-4"> 363 364 <div class="flex flex-col gap-4"> 364 365 <!-- Header with status badge and time --> ··· 495 496 Block User 496 497 </button> 497 498 } 499 + if canResetAutoHide { 500 + <button 501 + class="text-sm bg-blue-100 text-blue-700 hover:bg-blue-200 px-3 py-1.5 rounded font-medium transition-colors" 502 + hx-post="/_mod/reset-autohide" 503 + hx-vals={ fmt.Sprintf(`{"did": "%s"}`, report.Report.SubjectDID) } 504 + hx-swap="none" 505 + hx-confirm={ fmt.Sprintf("Reset the auto-hide report counter for %s? Only future reports will count toward the auto-hide threshold.", report.Report.SubjectDID) } 506 + > 507 + Reset Auto-Hide 508 + </button> 509 + } 498 510 <button 499 511 class="text-sm text-brown-600 hover:text-brown-800 px-3 py-1.5 rounded font-medium transition-colors" 500 512 hx-post="/_mod/dismiss-report" ··· 624 636 <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"> 625 637 Unblock User 626 638 </span> 627 - case moderation.AuditActionDismissJoinRequest: 639 + case moderation.AuditActionResetAutoHide: 640 + <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"> 641 + Reset Auto-Hide 642 + </span> 643 + case moderation.AuditActionDismissJoinRequest: 628 644 <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800"> 629 645 Dismiss Join Request 630 646 </span>
+12 -23
internal/web/pages/bean_view.templ
··· 87 87 ShareTitle: getBeanShareTitle(props.Bean), 88 88 ShareText: "Check out this bean on Arabica", 89 89 IsOwner: props.IsOwnProfile, 90 + EditModalURL: "/api/modals/bean/" + props.Bean.RKey, 91 + DeleteURL: "/api/beans/" + props.Bean.RKey, 92 + DeleteRedirect: "/manage", 90 93 IsAuthenticated: props.IsAuthenticated, 91 94 IsModerator: props.IsModerator, 92 95 CanHideRecord: props.CanHideRecord, ··· 113 116 } 114 117 115 118 templ BeanViewHeader(props BeanViewProps) { 116 - <div class="flex justify-between items-start mb-6"> 117 - <div> 118 - <h2 class="text-3xl font-bold text-brown-900"> 119 - if props.Bean.Name != "" { 120 - { props.Bean.Name } 121 - } else { 122 - { props.Bean.Origin } 123 - } 124 - </h2> 125 - <p class="text-sm text-brown-600 mt-1">{ props.Bean.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 126 - </div> 127 - if props.IsOwnProfile { 128 - <div class="flex gap-2"> 129 - <button 130 - hx-delete={ "/api/beans/" + props.Bean.RKey } 131 - hx-confirm="Are you sure you want to delete this bean?" 132 - hx-target="body" 133 - class="inline-flex items-center bg-brown-200 text-brown-700 px-4 py-2 rounded-lg hover:bg-brown-300 font-medium transition-colors" 134 - > 135 - Delete 136 - </button> 137 - </div> 138 - } 119 + <div class="mb-6"> 120 + <h2 class="text-3xl font-bold text-brown-900"> 121 + if props.Bean.Name != "" { 122 + { props.Bean.Name } 123 + } else { 124 + { props.Bean.Origin } 125 + } 126 + </h2> 127 + <p class="text-sm text-brown-600 mt-1">{ props.Bean.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 139 128 </div> 140 129 } 141 130
+40 -27
internal/web/pages/brew_view.templ
··· 1 1 package pages 2 2 3 3 import ( 4 + "arabica/internal/firehose" 4 5 "arabica/internal/models" 5 6 "arabica/internal/web/bff" 6 7 "arabica/internal/web/components" ··· 16 17 SubjectCID string // CID of the brew (for like button) 17 18 IsLiked bool // Whether the current user has liked this brew 18 19 LikeCount int // Number of likes on this brew 20 + CommentCount int // Number of comments on this brew 21 + Comments []firehose.IndexedComment // Comments on this brew 22 + CurrentUserDID string // DID of the current user (for delete buttons) 19 23 ShareURL string // URL for sharing the brew 24 + // Moderation state 25 + IsModerator bool // User has moderator role 26 + CanHideRecord bool // User has hide_record permission 27 + CanBlockUser bool // User has blacklist_user permission 28 + IsRecordHidden bool // This record is currently hidden 29 + AuthorDID string // DID of the brew author 20 30 } 21 31 22 32 // BrewView renders the full brew view page ··· 48 58 } 49 59 <div class="flex justify-between items-center"> 50 60 @components.BackButton() 51 - <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200"> 52 - @components.SocialButtons(components.SocialButtonsProps{ 61 + <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions"> 62 + @components.ActionBar(components.ActionBarProps{ 53 63 SubjectURI: props.SubjectURI, 54 64 SubjectCID: props.SubjectCID, 55 65 IsLiked: props.IsLiked, 56 66 LikeCount: props.LikeCount, 67 + CommentCount: props.CommentCount, 68 + ShowComments: true, 57 69 ShareURL: props.ShareURL, 58 70 ShareTitle: getBrewShareTitle(props.Brew), 59 71 ShareText: "Check out this brew on Arabica", 60 - ShowLike: props.IsAuthenticated, 72 + IsOwner: props.IsOwnProfile, 73 + EditURL: "/brews/" + props.Brew.RKey + "/edit", 74 + DeleteURL: "/brews/" + props.Brew.RKey, 75 + DeleteRedirect: "/brews", 61 76 IsAuthenticated: props.IsAuthenticated, 77 + IsModerator: props.IsModerator, 78 + CanHideRecord: props.CanHideRecord, 79 + CanBlockUser: props.CanBlockUser, 80 + IsRecordHidden: props.IsRecordHidden, 81 + AuthorDID: props.AuthorDID, 62 82 }) 63 83 </div> 64 84 </div> 85 + @components.CommentSection(components.CommentSectionProps{ 86 + SubjectURI: props.SubjectURI, 87 + SubjectCID: props.SubjectCID, 88 + Comments: props.Comments, 89 + IsAuthenticated: props.IsAuthenticated, 90 + CurrentUserDID: props.CurrentUserDID, 91 + ModCtx: components.CommentModerationContext{ 92 + IsModerator: props.IsModerator, 93 + CanHideRecord: props.CanHideRecord, 94 + CanBlockUser: props.CanBlockUser, 95 + }, 96 + ViewURL: props.ShareURL, 97 + }) 65 98 </div> 66 99 } 67 100 68 - // BrewViewHeader renders the header with title and actions 101 + // BrewViewHeader renders the header with title and timestamp 69 102 templ BrewViewHeader(props BrewViewProps) { 70 - <div class="flex justify-between items-start mb-6"> 71 - <div> 72 - <h2 class="text-3xl font-bold text-brown-900">Brew Details</h2> 73 - <p class="text-sm text-brown-600 mt-1">{ props.Brew.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 74 - </div> 75 - if props.IsOwnProfile { 76 - <div class="flex gap-2"> 77 - <a 78 - href={ templ.SafeURL("/brews/" + props.Brew.RKey + "/edit") } 79 - class="inline-flex items-center btn-secondary" 80 - > 81 - Edit 82 - </a> 83 - <button 84 - hx-delete={ "/brews/" + props.Brew.RKey } 85 - hx-confirm="Are you sure you want to delete this brew?" 86 - hx-target="body" 87 - class="inline-flex items-center bg-brown-200 text-brown-700 px-4 py-2 rounded-lg hover:bg-brown-300 font-medium transition-colors" 88 - > 89 - Delete 90 - </button> 91 - </div> 92 - } 103 + <div class="mb-6"> 104 + <h2 class="text-3xl font-bold text-brown-900">Brew Details</h2> 105 + <p class="text-sm text-brown-600 mt-1">{ props.Brew.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 93 106 </div> 94 107 } 95 108
+6 -17
internal/web/pages/brewer_view.templ
··· 64 64 ShareTitle: props.Brewer.Name, 65 65 ShareText: "Check out this brewer on Arabica", 66 66 IsOwner: props.IsOwnProfile, 67 + EditModalURL: "/api/modals/brewer/" + props.Brewer.RKey, 68 + DeleteURL: "/api/brewers/" + props.Brewer.RKey, 69 + DeleteRedirect: "/manage", 67 70 IsAuthenticated: props.IsAuthenticated, 68 71 IsModerator: props.IsModerator, 69 72 CanHideRecord: props.CanHideRecord, ··· 90 93 } 91 94 92 95 templ BrewerViewHeader(props BrewerViewProps) { 93 - <div class="flex justify-between items-start mb-6"> 94 - <div> 95 - <h2 class="text-3xl font-bold text-brown-900">{ props.Brewer.Name }</h2> 96 - <p class="text-sm text-brown-600 mt-1">{ props.Brewer.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 97 - </div> 98 - if props.IsOwnProfile { 99 - <div class="flex gap-2"> 100 - <button 101 - hx-delete={ "/api/brewers/" + props.Brewer.RKey } 102 - hx-confirm="Are you sure you want to delete this brewer?" 103 - hx-target="body" 104 - class="inline-flex items-center bg-brown-200 text-brown-700 px-4 py-2 rounded-lg hover:bg-brown-300 font-medium transition-colors" 105 - > 106 - Delete 107 - </button> 108 - </div> 109 - } 96 + <div class="mb-6"> 97 + <h2 class="text-3xl font-bold text-brown-900">{ props.Brewer.Name }</h2> 98 + <p class="text-sm text-brown-600 mt-1">{ props.Brewer.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 110 99 </div> 111 100 }
+16 -21
internal/web/pages/feed.templ
··· 10 10 11 11 // FeedModerationContext holds moderation state for rendering feed items 12 12 type FeedModerationContext struct { 13 - IsModerator bool // User has moderator role 14 - CanHideRecord bool // User has hide_record permission 15 - CanBlockUser bool // User has blacklist_user permission 16 - HiddenURIs map[string]bool // URIs that are currently hidden 13 + IsModerator bool // User has moderator role 14 + CanHideRecord bool // User has hide_record permission 15 + CanBlockUser bool // User has blacklist_user permission 16 + HiddenURIs map[string]bool // URIs that are currently hidden 17 17 } 18 18 19 19 // FeedPartial renders the feed items (for HTMX loading) ··· 46 46 templ FeedCardWithModeration(item *feed.FeedItem, isAuthenticated bool, modCtx FeedModerationContext) { 47 47 <div class="feed-card"> 48 48 <!-- Author row --> 49 - <div class="flex items-center gap-3 mb-3"> 50 - <a href={ templ.SafeURL("/profile/" + item.Author.Handle) } class="flex-shrink-0"> 51 - @components.Avatar(components.AvatarProps{ 52 - AvatarURL: getAvatarURL(item.Author.Avatar), 53 - DisplayName: getDisplayName(item.Author.DisplayName), 54 - Size: "md", 55 - }) 56 - </a> 57 - <div class="flex-1 min-w-0"> 58 - <div class="flex items-center gap-2"> 59 - if item.Author.DisplayName != nil && *item.Author.DisplayName != "" { 60 - <a href={ templ.SafeURL("/profile/" + item.Author.Handle) } class="link-bold text-brown-900 truncate">{ *item.Author.DisplayName }</a> 61 - } 62 - <a href={ templ.SafeURL("/profile/" + item.Author.Handle) } class="link text-sm truncate">{ "@" + item.Author.Handle }</a> 63 - </div> 64 - <span class="text-brown-500 text-sm">{ item.TimeAgo }</span> 65 - </div> 49 + <div class="mb-3"> 50 + @components.UserBadge(components.UserBadgeProps{ 51 + ProfileURL: "/profile/" + item.Author.Handle, 52 + AvatarURL: getAvatarURL(item.Author.Avatar), 53 + DisplayName: getDisplayName(item.Author.DisplayName), 54 + Handle: item.Author.Handle, 55 + TimeAgo: item.TimeAgo, 56 + Size: "md", 57 + }) 66 58 </div> 67 59 <!-- Action header --> 68 60 <div class="mb-2 text-sm text-brown-700"> ··· 88 80 SubjectCID: item.SubjectCID, 89 81 IsLiked: item.IsLikedByViewer, 90 82 LikeCount: item.LikeCount, 83 + CommentCount: item.CommentCount, 84 + ViewURL: getFeedItemShareURL(item), 85 + ShowComments: true, 91 86 ShareURL: getFeedItemShareURL(item), 92 87 ShareTitle: getFeedItemShareTitle(item), 93 88 ShareText: getFeedItemShareText(item),
+6 -17
internal/web/pages/grinder_view.templ
··· 62 62 ShareTitle: props.Grinder.Name, 63 63 ShareText: "Check out this grinder on Arabica", 64 64 IsOwner: props.IsOwnProfile, 65 + EditModalURL: "/api/modals/grinder/" + props.Grinder.RKey, 66 + DeleteURL: "/api/grinders/" + props.Grinder.RKey, 67 + DeleteRedirect: "/manage", 65 68 IsAuthenticated: props.IsAuthenticated, 66 69 IsModerator: props.IsModerator, 67 70 CanHideRecord: props.CanHideRecord, ··· 88 91 } 89 92 90 93 templ GrinderViewHeader(props GrinderViewProps) { 91 - <div class="flex justify-between items-start mb-6"> 92 - <div> 93 - <h2 class="text-3xl font-bold text-brown-900">{ props.Grinder.Name }</h2> 94 - <p class="text-sm text-brown-600 mt-1">{ props.Grinder.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 95 - </div> 96 - if props.IsOwnProfile { 97 - <div class="flex gap-2"> 98 - <button 99 - hx-delete={ "/api/grinders/" + props.Grinder.RKey } 100 - hx-confirm="Are you sure you want to delete this grinder?" 101 - hx-target="body" 102 - class="inline-flex items-center bg-brown-200 text-brown-700 px-4 py-2 rounded-lg hover:bg-brown-300 font-medium transition-colors" 103 - > 104 - Delete 105 - </button> 106 - </div> 107 - } 94 + <div class="mb-6"> 95 + <h2 class="text-3xl font-bold text-brown-900">{ props.Grinder.Name }</h2> 96 + <p class="text-sm text-brown-600 mt-1">{ props.Grinder.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 108 97 </div> 109 98 } 110 99
+6 -17
internal/web/pages/roaster_view.templ
··· 68 68 ShareTitle: props.Roaster.Name, 69 69 ShareText: "Check out this roaster on Arabica", 70 70 IsOwner: props.IsOwnProfile, 71 + EditModalURL: "/api/modals/roaster/" + props.Roaster.RKey, 72 + DeleteURL: "/api/roasters/" + props.Roaster.RKey, 73 + DeleteRedirect: "/manage", 71 74 IsAuthenticated: props.IsAuthenticated, 72 75 IsModerator: props.IsModerator, 73 76 CanHideRecord: props.CanHideRecord, ··· 94 97 } 95 98 96 99 templ RoasterViewHeader(props RoasterViewProps) { 97 - <div class="flex justify-between items-start mb-6"> 98 - <div> 99 - <h2 class="text-3xl font-bold text-brown-900">{ props.Roaster.Name }</h2> 100 - <p class="text-sm text-brown-600 mt-1">{ props.Roaster.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 101 - </div> 102 - if props.IsOwnProfile { 103 - <div class="flex gap-2"> 104 - <button 105 - hx-delete={ "/api/roasters/" + props.Roaster.RKey } 106 - hx-confirm="Are you sure you want to delete this roaster?" 107 - hx-target="body" 108 - class="inline-flex items-center bg-brown-200 text-brown-700 px-4 py-2 rounded-lg hover:bg-brown-300 font-medium transition-colors" 109 - > 110 - Delete 111 - </button> 112 - </div> 113 - } 100 + <div class="mb-6"> 101 + <h2 class="text-3xl font-bold text-brown-900">{ props.Roaster.Name }</h2> 102 + <p class="text-sm text-brown-600 mt-1">{ props.Roaster.CreatedAt.Format("January 2, 2006 at 3:04 PM") }</p> 114 103 </div> 115 104 } 116 105
+38
lexicons/social.arabica.alpha.comment.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.arabica.alpha.comment", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "A comment on an Arabica record (brew, bean, roaster, grinder, brewer, or another comment)", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "text", "createdAt"], 12 + "properties": { 13 + "subject": { 14 + "type": "ref", 15 + "ref": "com.atproto.repo.strongRef", 16 + "description": "The AT-URI and CID of the record being commented on" 17 + }, 18 + "text": { 19 + "type": "string", 20 + "maxLength": 1000, 21 + "maxGraphemes": 300, 22 + "description": "The comment text content" 23 + }, 24 + "createdAt": { 25 + "type": "string", 26 + "format": "datetime", 27 + "description": "Timestamp when the comment was created" 28 + }, 29 + "parent": { 30 + "type": "ref", 31 + "ref": "com.atproto.repo.strongRef", 32 + "description": "Optional parent comment reference for replies" 33 + } 34 + } 35 + } 36 + } 37 + } 38 + }
+1
module.nix
··· 41 41 "view_reports" 42 42 "dismiss_report" 43 43 "view_audit_log" 44 + "reset_autohide" 44 45 ]; 45 46 }; 46 47 moderator = {
+88 -3
static/css/app.css
··· 269 269 270 270 /* Like Button */ 271 271 .like-btn { 272 - @apply inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md text-sm font-medium transition-colors; 272 + @apply inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium transition-colors min-h-[44px]; 273 273 } 274 274 275 275 .like-btn-liked { ··· 284 284 285 285 /* Share Button */ 286 286 .share-btn { 287 - @apply inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md text-sm font-medium transition-colors bg-brown-100 text-brown-600 hover:bg-brown-200; 287 + @apply inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium transition-colors bg-brown-100 text-brown-600 hover:bg-brown-200 min-h-[44px]; 288 + } 289 + 290 + /* Comment Button */ 291 + .comment-btn { 292 + @apply inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium transition-colors bg-brown-100 text-brown-600 hover:bg-brown-200 min-h-[44px]; 293 + } 294 + 295 + /* Comment Section */ 296 + .comment-section { 297 + @apply mt-8 pt-6 border-t-2 border-brown-200; 298 + } 299 + 300 + .comment-section-header { 301 + @apply mb-5; 302 + } 303 + 304 + .comment-count-badge { 305 + @apply inline-flex items-center justify-center text-xs font-bold bg-brown-700 text-brown-100 rounded-full min-w-[1.375rem] h-[1.375rem] px-1.5; 306 + } 307 + 308 + .comment-login-prompt { 309 + @apply flex items-center gap-3 bg-brown-50 rounded-lg p-4 mb-5 border border-dashed border-brown-300; 310 + } 311 + 312 + .comment-compose { 313 + @apply bg-brown-50 rounded-lg p-4 mb-5 border border-brown-200 flex flex-col gap-2; 314 + } 315 + 316 + .comment-textarea { 317 + @apply w-full rounded-lg border-2 border-brown-200 bg-white px-3 py-2.5 text-base text-brown-900 placeholder-brown-400 resize-none transition-colors focus:border-brown-500 focus:ring-0 focus:outline-none; 318 + } 319 + 320 + .comment-list { 321 + @apply space-y-1; 322 + } 323 + 324 + .comment-empty-state { 325 + @apply text-center py-8; 326 + } 327 + 328 + .comment-item { 329 + @apply relative rounded-lg p-3 transition-colors hover:bg-brown-50/60; 330 + } 331 + 332 + .comment-item-inner { 333 + @apply relative; 334 + } 335 + 336 + .comment-depth-1 { 337 + @apply ml-6 pl-4; 338 + } 339 + 340 + .comment-depth-2 { 341 + @apply ml-12 pl-4; 342 + } 343 + 344 + .comment-thread-line { 345 + @apply absolute left-0 top-3 bottom-3 w-0.5 bg-brown-200 rounded-full; 346 + } 347 + 348 + .comment-reply-btn { 349 + @apply inline-flex items-center gap-1 text-brown-400 hover:text-brown-700 transition-colors text-xs font-medium; 350 + } 351 + 352 + .comment-delete-btn { 353 + @apply text-brown-300 hover:text-brown-600 transition-colors; 354 + } 355 + 356 + .comment-reply-form { 357 + @apply flex flex-col gap-2 bg-brown-50 rounded-lg p-3 border border-brown-200; 288 358 } 289 359 290 360 /* Action Bar */ ··· 292 362 @apply flex items-center gap-2 mt-3 pt-3 border-t border-brown-200; 293 363 } 294 364 365 + /* Action bar inside brew view container - no separator needed */ 366 + .brew-view-actions .action-bar { 367 + @apply mt-0 pt-0 border-t-0; 368 + } 369 + 370 + /* Compact action bar variant for comments */ 371 + .comment-item .action-bar { 372 + @apply mt-1 border-t-0 gap-1 bg-brown-100 rounded-lg px-1.5 py-1 inline-flex items-center; 373 + } 374 + 375 + .comment-item .action-btn, 376 + .comment-item .like-btn { 377 + @apply px-2 py-1 text-xs min-h-[28px] bg-transparent; 378 + } 379 + 295 380 .action-btn { 296 - @apply inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md text-sm font-medium transition-colors bg-brown-100 text-brown-600 hover:bg-brown-200 cursor-pointer; 381 + @apply inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium transition-colors bg-brown-100 text-brown-600 hover:bg-brown-200 cursor-pointer min-h-[44px]; 297 382 } 298 383 299 384 .action-btn-liked {