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

feat: basic feed sorting and filtering

pdewey.com e57a2eb1 6c3fa527

verified
+580 -22
+3 -2
.cells/cells.jsonl
··· 3 3 {"id":"01KGA7T08BAWPB977KSHZNS8NE","title":"Revision pass on about and terms text","description":"Review and revise text content in about and terms pages for clarity and accuracy.\n\nAcceptance criteria:\n- Clear, concise messaging\n- No typos or grammar issues\n- Accurate information","status":"claimed","priority":"low","assignee":"patrick","labels":["content","documentation"],"created_at":"2026-01-31T14:37:42.539757583Z","updated_at":"2026-01-31T15:30:48.329882372Z"} 4 4 {"id":"01KGA7T0AE0R3F8PRHX761W03E","title":"Implement profile picture caching in database","description":"Cache profile pictures to database to avoid reloading them frequently. This may already be partially implemented - needs investigation.\n\nAcceptance criteria:\n- Profile pictures cached in database\n- Reduced PDS API calls for avatar fetching\n- Cache invalidation strategy\n- Performance improvement","status":"open","priority":"high","labels":["backend","performance","caching"],"created_at":"2026-01-31T14:37:42.606749189Z","updated_at":"2026-01-31T14:37:42.606749189Z"} 5 5 {"id":"01KGA7TZG6AP8FJJTW5GAA60X5","title":"Evaluate merging manage and profile pages","description":"Consider if manage, profile, and brew list should be separate pages.\n\nOptions:\n- Merge manage and profile\n- Merge brew list and manage\n- Keep separate\n\nNeeds design decision before implementation.\n\nAcceptance criteria:\n- Evaluate user flow and UX\n- Document decision rationale\n- Implementation plan if merge is chosen","status":"open","priority":"low","labels":["design","ux","frontend"],"created_at":"2026-01-31T14:38:14.534537889Z","updated_at":"2026-01-31T14:38:14.534537889Z"} 6 - {"id":"01KGA7TZMPFGZH6R6ZMASAD2GE","title":"Design nested modal system for entity creation","description":"Enable creating related entities from within modals (e.g., create roaster from within bean modal).\n\nDesign idea:\n- Transition that moves first modal left\n- Opens second modal to the right\n- Smooth nested flow\n\nAcceptance criteria:\n- Design nested modal UX\n- Smooth transitions\n- Good visual hierarchy\n- Can return to parent modal\n- Data flows correctly between modals","status":"open","priority":"low","labels":["design","ux","modals","future"],"created_at":"2026-01-31T14:38:14.678025134Z","updated_at":"2026-02-15T16:06:00.000000000Z"} 6 + {"id":"01KGA7TZMPFGZH6R6ZMASAD2GE","title":"Design nested modal system for entity creation","description":"Enable creating related entities from within modals (e.g., create roaster from within bean modal).\n\nDesign idea:\n- Transition that moves first modal left\n- Opens second modal to the right\n- Smooth nested flow\n\nAcceptance criteria:\n- Design nested modal UX\n- Smooth transitions\n- Good visual hierarchy\n- Can return to parent modal\n- Data flows correctly between modals","status":"open","priority":"low","labels":["design","ux","modals","future"],"created_at":"2026-01-31T14:38:14.678025134Z","updated_at":"2026-02-15T16:06:00Z"} 7 7 {"id":"01KGA7TZTT05WWENTHR41X4RQH","title":"Replace inline buttons with button components","description":"buttons.templ defines PrimaryButton/SecondaryButton but they're rarely used.\nMost places use inline button classes.\n\nAcceptance criteria:\n- All buttons use button components\n- Remove inline button class usage\n- Consistent button styling\n- Component-based approach","status":"open","priority":"low","labels":["refactor","frontend","components"],"created_at":"2026-01-31T14:38:14.874329448Z","updated_at":"2026-01-31T14:38:14.874329448Z"} 8 - {"id":"01KGA7VXDTVY5W11QJ1VPZ5M3Y","title":"Implement settings menu","description":"Create settings menu with the following features:\n\n1. Private mode - Don't show in community feed (records still public via PDS API)\n2. Dev mode - Show DID, copy DID in profiles (remove 'logged in as <did>' from home)\n3. Toggle for table view vs future post-style view\n\nAcceptance criteria:\n- Settings page/modal created\n- All three settings implemented\n- Settings persisted per user\n- UI updated based on settings","status":"blocked","priority":"normal","blocked_by":["01KGA7VXFQABCAQV83A6C49WEY"],"labels":["feature","frontend","settings"],"created_at":"2026-01-31T14:38:45.178941765Z","updated_at":"2026-01-31T14:39:00.016324424Z"} 8 + {"id":"01KGA7VXDTVY5W11QJ1VPZ5M3Y","title":"Implement settings menu","description":"Create settings menu with the following features:\n\n1. Private mode - Don't show in community feed (records still public via PDS API)\n2. Dev mode - Show DID, copy DID in profiles (remove 'logged in as \u003cdid\u003e' from home)\n3. Toggle for table view vs future post-style view\n\nAcceptance criteria:\n- Settings page/modal created\n- All three settings implemented\n- Settings persisted per user\n- UI updated based on settings","status":"blocked","priority":"normal","blocked_by":["01KGA7VXFQABCAQV83A6C49WEY"],"labels":["feature","frontend","settings"],"created_at":"2026-01-31T14:38:45.178941765Z","updated_at":"2026-01-31T14:39:00.016324424Z"} 9 9 {"id":"01KGA7VXFQABCAQV83A6C49WEY","title":"Design post-style record view (mobile-friendly)","description":"LARGE FEATURE: Complete record styling refactor from table-style to mobile-friendly post-style.\n\nSimilar to Bluesky posts format.\nShould include setting to use legacy table view.\n\nThis is a major redesign - to be done later down the line.\n\nAcceptance criteria:\n- Design post-style layout\n- Mobile-friendly and responsive\n- Legacy table view option\n- Smooth migration path\n- Better UX on mobile devices","status":"open","priority":"low","labels":["feature","design","frontend","mobile","future"],"created_at":"2026-01-31T14:38:45.23927703Z","updated_at":"2026-01-31T14:38:45.23927703Z"} 10 10 {"id":"01KGA7VXHR31MSHEKH91C41TYM","title":"Add loading progress bars to page navigation","description":"Consider adding loading bars to page loads (above header perhaps).\n\nSeparate nicer/prettier loading bar would also be nice on brews page.\n\nAcceptance criteria:\n- Loading bar visible during page navigation\n- Positioned appropriately (above header)\n- Smooth animations\n- Better perceived performance","status":"open","priority":"low","labels":["feature","frontend","ux"],"created_at":"2026-01-31T14:38:45.304772299Z","updated_at":"2026-01-31T14:38:45.304772299Z"} 11 11 {"id":"01KGA7VXM0F3A1R10BC3V29A0Y","title":"Verify context flows through all methods","description":"From CLAUDE.md Known Issues: Context should flow through methods (some fixed, verify all paths).\n\nAudit codebase to ensure context.Context is properly passed through all method chains.\n\nAcceptance criteria:\n- All methods accept and use context\n- No context.Background() in handlers\n- Context cancellation works correctly\n- Request timeouts respected","status":"open","priority":"high","labels":["backend","refactor","correctness"],"created_at":"2026-01-31T14:38:45.376033105Z","updated_at":"2026-01-31T14:38:45.376033105Z"} ··· 15 15 {"id":"01KGA7VXVZQBMZX6RVG3B49TYW","title":"Consider migration from BoltDB to SQLite","description":"Far future consideration: Maybe swap from BoltDB to SQLite using non-cgo library.\n\nThis is exploratory - need to evaluate:\n- Benefits of SQLite vs BoltDB\n- Migration effort\n- Performance impact\n- Query capabilities\n\nAcceptance criteria:\n- Evaluate pros/cons\n- Document decision\n- If proceeding: migration plan and implementation","status":"claimed","priority":"low","assignee":"patrick","labels":["backend","database","future","evaluation"],"created_at":"2026-01-31T14:38:45.631852717Z","updated_at":"2026-01-31T15:31:07.948160371Z"} 16 16 {"id":"01KGDXVR86CB2H44DJHKN4Z9M0","title":"Implement comments for social interactions","description":"Add a comment lexicon and implement comment functionality across the app.\n\n## Lexicon: social.arabica.alpha.comment\n\nThe comment record should:\n- Use `com.atproto.repo.strongRef` for the subject (URI + CID for immutability)\n- Support commenting on any arabica.social lexicon type (beans, roasters, grinders, brewers, brews, and other comments)\n- Include text field (max 1000 chars / 300 graphemes as per AT Protocol conventions)\n- Include createdAt timestamp\n- Use TID as record key\n- NOTE: This cell implements flat comments only. Threaded/nested comments are handled in a separate cell.\n\n## Backend Implementation\n\n1. Create lexicon file: `lexicons/social.arabica.alpha.comment.json`\n2. Add NSID constant in `internal/atproto/nsid.go`\n3. Add Comment model in `internal/models/models.go`\n4. Add record conversion functions in `internal/atproto/records.go`\n5. Add Store interface methods: CreateComment, DeleteComment, GetCommentsForSubject, GetUserComments\n6. Implement in AtprotoStore\n7. Update firehose indexing to track comments\n\n## Frontend Implementation\n\n1. Add comment section component below brew detail view\n2. Add comment form (authenticated users only)\n3. Display comment count on records in feed\n4. Update comment counts in response to firehose events\n5. Show commenter profile info (avatar, handle)\n\n## Acceptance Criteria\n\n- Users can comment on any arabica.social record\n- Comments are stored in the user's PDS (actor-owned data)\n- Comment counts display on records in the feed\n- Comments visible on record detail views\n- Real-time comment count updates via firehose","status":"open","priority":"normal","labels":["backend","frontend","atproto"],"created_at":"2026-02-02T01:00:51.846427126Z","updated_at":"2026-02-02T01:00:51.846427126Z"} 17 17 {"id":"01KGDXW31PHFMBE3WTV83HPET1","title":"Implement comment threading","description":"Add support for threaded/nested comments, building on the flat comment system.\n\n## Lexicon Changes\n\nUpdate `social.arabica.alpha.comment` to add optional threading fields:\n- `parent`: Optional strongRef to parent comment (for replies)\n- `root`: Optional strongRef to root subject (maintains context when replying to comments)\n\nAlternatively, consider a separate reply field or keeping comments flat with UI-level threading.\n\n## Backend Implementation\n\n1. Update comment model to include parent/root references\n2. Add methods to fetch comment threads: GetCommentThread, GetReplies\n3. Update firehose indexing to track parent-child relationships\n4. Add depth limits for threading (prevent infinite nesting)\n\n## Frontend Implementation\n\n1. Design threaded comment UI (indentation, collapse/expand)\n2. Add 'reply' button on comments\n3. Show reply context when replying\n4. Consider max nesting depth for display (e.g., 3-4 levels)\n5. Mobile-friendly thread navigation\n\n## Design Considerations\n\n- How deep should threading go? (Recommend max 3-4 levels visible, then flatten)\n- How to handle deleted parent comments?\n- Should users be notified when someone replies to their comment?\n- Performance: lazy-load deep threads vs eager-load\n\n## Acceptance Criteria\n\n- Users can reply directly to comments\n- Thread structure is visually clear\n- Threads can be collapsed/expanded\n- Works well on mobile devices\n- Parent context shown when replying","status":"blocked","priority":"low","blocked_by":["01KGDXVR86CB2H44DJHKN4Z9M0"],"labels":["frontend","atproto"],"created_at":"2026-02-02T01:01:02.902692829Z","updated_at":"2026-02-02T01:01:06.361891137Z"} 18 + {"id":"01KJ36P2BWM4HSZV6GNX0BJB1F","title":"Implement lightweight For You feed algorithm","description":"Add a 'For You' algorithmic feed tab alongside the existing chronological feed. This should be a lightweight scoring system that ranks posts based on:\n\n**Scoring Factors:**\n1. **Engagement score**: likes (weight 3x) + comments (weight 2x) on the post\n2. **Time decay**: Score multiplied by a decay factor based on post age. Use exponential decay with a half-life of ~24 hours so recent engaged content surfaces while popular older content still has a chance.\n3. **Type diversity**: After scoring, apply a diversity pass to avoid showing too many of the same record type in a row. If 3+ consecutive items are the same type, interleave with the next different-type item.\n4. **Social proximity** (future): Boost posts from users the viewer has interacted with (liked their content, commented on their posts). This requires building a per-user interaction graph from the like/comment indexes.\n\n**Implementation approach:**\n- Add a new `FeedSortForYou` sort option alongside recent/popular\n- Add a `scoreForYouItem(item *FeedItem, viewerDID string) float64` function in the firehose index\n- Fetch ~100 recent items, score them, apply diversity, return top N\n- Add a 'For You' tab in the feed filter bar UI (only for authenticated users)\n- Cache scored results per-viewer with short TTL (1-2 min) to avoid re-scoring on pagination\n\n**Key files:**\n- `internal/firehose/index.go` - Add scoring logic and ForYou query\n- `internal/feed/service.go` - Add FeedSortForYou constant\n- `internal/firehose/adapter.go` - Pass through ForYou sort\n- `internal/handlers/feed.go` - Handle sort=foryou param\n- `internal/web/pages/feed.templ` - Add For You tab\n\n**Dependencies:**\n- Relies on existing BucketByTime, BucketLikeCounts, BucketCommentCounts, BucketLikesByActor indexes\n- Social proximity scoring depends on being able to query BucketLikesByActor efficiently","status":"open","priority":"normal","created_at":"2026-02-22T17:34:47.67684351Z","updated_at":"2026-02-22T17:34:47.67684351Z"}
+103
internal/feed/service.go
··· 70 70 mu sync.RWMutex 71 71 } 72 72 73 + // FeedSort defines the sort order for feed queries 74 + type FeedSort string 75 + 76 + const ( 77 + FeedSortRecent FeedSort = "recent" 78 + FeedSortPopular FeedSort = "popular" 79 + ) 80 + 81 + // FeedQuery specifies filtering, sorting, and pagination for feed queries 82 + type FeedQuery struct { 83 + Limit int 84 + Cursor string 85 + TypeFilter lexicons.RecordType 86 + Sort FeedSort 87 + } 88 + 89 + // FeedResult contains feed items plus pagination info 90 + type FeedResult struct { 91 + Items []*FeedItem 92 + NextCursor string 93 + } 94 + 73 95 // FirehoseIndex is the interface for the firehose feed index 74 96 // This allows the feed service to use firehose data when available 75 97 type FirehoseIndex interface { 76 98 IsReady() bool 77 99 GetRecentFeed(ctx context.Context, limit int) ([]*FirehoseFeedItem, error) 100 + GetFeedWithQuery(ctx context.Context, q FirehoseFeedQuery) (*FirehoseFeedResult, error) 101 + } 102 + 103 + // FirehoseFeedQuery mirrors FeedQuery for the firehose layer 104 + type FirehoseFeedQuery struct { 105 + Limit int 106 + Cursor string 107 + TypeFilter lexicons.RecordType 108 + Sort string // "recent" or "popular" 109 + } 110 + 111 + // FirehoseFeedResult mirrors FeedResult for the firehose layer 112 + type FirehoseFeedResult struct { 113 + Items []*FirehoseFeedItem 114 + NextCursor string 78 115 } 79 116 80 117 // FirehoseFeedItem matches the FeedItem structure from firehose package ··· 277 314 } 278 315 279 316 return items, nil 317 + } 318 + 319 + // GetFeedWithQuery fetches feed items with filtering, sorting, and pagination 320 + func (s *Service) GetFeedWithQuery(ctx context.Context, q FeedQuery) (*FeedResult, error) { 321 + if s.firehoseIndex == nil || !s.firehoseIndex.IsReady() { 322 + return nil, fmt.Errorf("firehose index not ready") 323 + } 324 + 325 + if q.Limit <= 0 { 326 + q.Limit = FeedLimit 327 + } 328 + if q.Sort == "" { 329 + q.Sort = FeedSortRecent 330 + } 331 + 332 + // Fetch more than needed to account for moderation filtering 333 + fetchLimit := q.Limit 334 + if s.moderationFilter != nil { 335 + fetchLimit = q.Limit + 10 336 + } 337 + 338 + firehoseResult, err := s.firehoseIndex.GetFeedWithQuery(ctx, FirehoseFeedQuery{ 339 + Limit: fetchLimit, 340 + Cursor: q.Cursor, 341 + TypeFilter: q.TypeFilter, 342 + Sort: string(q.Sort), 343 + }) 344 + if err != nil { 345 + return nil, err 346 + } 347 + 348 + // Convert to FeedItems 349 + items := make([]*FeedItem, 0, len(firehoseResult.Items)) 350 + for _, fi := range firehoseResult.Items { 351 + items = append(items, &FeedItem{ 352 + RecordType: fi.RecordType, 353 + Action: fi.Action, 354 + Brew: fi.Brew, 355 + Bean: fi.Bean, 356 + Roaster: fi.Roaster, 357 + Grinder: fi.Grinder, 358 + Brewer: fi.Brewer, 359 + Author: fi.Author, 360 + Timestamp: fi.Timestamp, 361 + TimeAgo: fi.TimeAgo, 362 + LikeCount: fi.LikeCount, 363 + CommentCount: fi.CommentCount, 364 + SubjectURI: fi.SubjectURI, 365 + SubjectCID: fi.SubjectCID, 366 + }) 367 + } 368 + 369 + // Apply moderation filtering 370 + items = s.filterModeratedItems(ctx, items) 371 + 372 + // Trim to requested limit 373 + result := &FeedResult{ 374 + NextCursor: firehoseResult.NextCursor, 375 + } 376 + if len(items) > q.Limit { 377 + result.Items = items[:q.Limit] 378 + } else { 379 + result.Items = items 380 + } 381 + 382 + return result, nil 280 383 } 281 384 282 385 // getRecentRecordsFromFirehose fetches feed items from the firehose index
+23 -3
internal/firehose/adapter.go
··· 30 30 return nil, err 31 31 } 32 32 33 - // Convert to the type expected by feed.Service 33 + return convertFeedItems(items), nil 34 + } 35 + 36 + // GetFeedWithQuery returns feed items matching query parameters 37 + func (a *FeedIndexAdapter) GetFeedWithQuery(ctx context.Context, q feed.FirehoseFeedQuery) (*feed.FirehoseFeedResult, error) { 38 + result, err := a.index.GetFeedWithQuery(ctx, FeedQuery{ 39 + Limit: q.Limit, 40 + Cursor: q.Cursor, 41 + TypeFilter: q.TypeFilter, 42 + Sort: FeedSort(q.Sort), 43 + }) 44 + if err != nil { 45 + return nil, err 46 + } 47 + 48 + return &feed.FirehoseFeedResult{ 49 + Items: convertFeedItems(result.Items), 50 + NextCursor: result.NextCursor, 51 + }, nil 52 + } 53 + 54 + func convertFeedItems(items []*FeedItem) []*feed.FirehoseFeedItem { 34 55 result := make([]*feed.FirehoseFeedItem, len(items)) 35 56 for i, item := range items { 36 57 result[i] = &feed.FirehoseFeedItem{ ··· 50 71 SubjectCID: item.SubjectCID, 51 72 } 52 73 } 53 - 54 - return result, nil 74 + return result 55 75 }
+257
internal/firehose/index.go
··· 1 1 package firehose 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "encoding/binary" 7 + "encoding/hex" 6 8 "encoding/json" 7 9 "fmt" 8 10 "os" ··· 109 111 110 112 ready bool 111 113 readyMu sync.RWMutex 114 + } 115 + 116 + // FeedSort defines the sort order for feed queries 117 + type FeedSort string 118 + 119 + const ( 120 + FeedSortRecent FeedSort = "recent" 121 + FeedSortPopular FeedSort = "popular" 122 + ) 123 + 124 + // FeedQuery specifies filtering, sorting, and pagination for feed queries 125 + type FeedQuery struct { 126 + Limit int // Max items to return 127 + Cursor string // Opaque cursor for pagination (base64-encoded time key) 128 + TypeFilter lexicons.RecordType // Filter to a specific record type (empty = all) 129 + Sort FeedSort // Sort order (default: recent) 130 + } 131 + 132 + // FeedResult contains feed items plus pagination info 133 + type FeedResult struct { 134 + Items []*FeedItem 135 + NextCursor string // Empty if no more results 112 136 } 113 137 114 138 // NewFeedIndex creates a new feed index backed by BoltDB ··· 474 498 } 475 499 476 500 return items, nil 501 + } 502 + 503 + // recordTypeToNSID maps a lexicons.RecordType to its NSID collection string 504 + var recordTypeToNSID = map[lexicons.RecordType]string{ 505 + lexicons.RecordTypeBrew: atproto.NSIDBrew, 506 + lexicons.RecordTypeBean: atproto.NSIDBean, 507 + lexicons.RecordTypeRoaster: atproto.NSIDRoaster, 508 + lexicons.RecordTypeGrinder: atproto.NSIDGrinder, 509 + lexicons.RecordTypeBrewer: atproto.NSIDBrewer, 510 + } 511 + 512 + // GetFeedWithQuery returns feed items matching the given query with cursor-based pagination 513 + func (idx *FeedIndex) GetFeedWithQuery(ctx context.Context, q FeedQuery) (*FeedResult, error) { 514 + if q.Limit <= 0 { 515 + q.Limit = 20 516 + } 517 + if q.Sort == "" { 518 + q.Sort = FeedSortRecent 519 + } 520 + 521 + // For type-filtered queries, use BucketByCollection for efficiency 522 + // For unfiltered queries, use BucketByTime 523 + var records []*IndexedRecord 524 + var lastTimeKey []byte 525 + 526 + // Decode cursor if provided 527 + var cursorBytes []byte 528 + if q.Cursor != "" { 529 + var err error 530 + cursorBytes, err = decodeCursor(q.Cursor) 531 + if err != nil { 532 + return nil, fmt.Errorf("invalid cursor: %w", err) 533 + } 534 + } 535 + 536 + // Fetch more than needed to account for filtering 537 + fetchLimit := q.Limit + 10 538 + 539 + err := idx.db.View(func(tx *bolt.Tx) error { 540 + recordsBucket := tx.Bucket(BucketRecords) 541 + 542 + if q.TypeFilter != "" { 543 + // Use BucketByCollection for filtered queries 544 + nsid, ok := recordTypeToNSID[q.TypeFilter] 545 + if !ok { 546 + return fmt.Errorf("unknown record type: %s", q.TypeFilter) 547 + } 548 + 549 + byCollection := tx.Bucket(BucketByCollection) 550 + c := byCollection.Cursor() 551 + 552 + // Collection keys: {collection}:{inverted_timestamp}:{uri} 553 + prefix := []byte(nsid + ":") 554 + 555 + var k []byte 556 + if cursorBytes != nil { 557 + // Seek to cursor position (cursor is the full collection key) 558 + k, _ = c.Seek(cursorBytes) 559 + // Skip the cursor key itself (it was the last item of previous page) 560 + if k != nil && string(k) == string(cursorBytes) { 561 + k, _ = c.Next() 562 + } 563 + } else { 564 + k, _ = c.Seek(prefix) 565 + } 566 + 567 + count := 0 568 + for ; k != nil && count < fetchLimit; k, _ = c.Next() { 569 + if !bytes.HasPrefix(k, prefix) { 570 + break 571 + } 572 + 573 + // Extract URI from collection key: {collection}:{timestamp_bytes}:{uri} 574 + uri := extractURIFromCollectionKey(k, nsid) 575 + if uri == "" { 576 + continue 577 + } 578 + 579 + data := recordsBucket.Get([]byte(uri)) 580 + if data == nil { 581 + continue 582 + } 583 + 584 + var record IndexedRecord 585 + if err := json.Unmarshal(data, &record); err != nil { 586 + continue 587 + } 588 + records = append(records, &record) 589 + lastTimeKey = make([]byte, len(k)) 590 + copy(lastTimeKey, k) 591 + count++ 592 + } 593 + } else { 594 + // Use BucketByTime for unfiltered queries 595 + byTime := tx.Bucket(BucketByTime) 596 + c := byTime.Cursor() 597 + 598 + var k []byte 599 + if cursorBytes != nil { 600 + k, _ = c.Seek(cursorBytes) 601 + if k != nil && string(k) == string(cursorBytes) { 602 + k, _ = c.Next() 603 + } 604 + } else { 605 + k, _ = c.First() 606 + } 607 + 608 + count := 0 609 + for ; k != nil && count < fetchLimit; k, _ = c.Next() { 610 + uri := extractURIFromTimeKey(k) 611 + if uri == "" { 612 + continue 613 + } 614 + 615 + data := recordsBucket.Get([]byte(uri)) 616 + if data == nil { 617 + continue 618 + } 619 + 620 + var record IndexedRecord 621 + if err := json.Unmarshal(data, &record); err != nil { 622 + continue 623 + } 624 + records = append(records, &record) 625 + lastTimeKey = make([]byte, len(k)) 626 + copy(lastTimeKey, k) 627 + count++ 628 + } 629 + } 630 + 631 + return nil 632 + }) 633 + if err != nil { 634 + return nil, err 635 + } 636 + 637 + // Build lookup maps for reference resolution 638 + recordsByURI := make(map[string]*IndexedRecord) 639 + for _, r := range records { 640 + recordsByURI[r.URI] = r 641 + } 642 + 643 + // Load additional records for reference resolution 644 + err = idx.db.View(func(tx *bolt.Tx) error { 645 + recordsBucket := tx.Bucket(BucketRecords) 646 + return recordsBucket.ForEach(func(k, v []byte) error { 647 + uri := string(k) 648 + if _, exists := recordsByURI[uri]; exists { 649 + return nil 650 + } 651 + var record IndexedRecord 652 + if err := json.Unmarshal(v, &record); err != nil { 653 + return nil 654 + } 655 + switch record.Collection { 656 + case atproto.NSIDBean, atproto.NSIDRoaster, atproto.NSIDGrinder, atproto.NSIDBrewer: 657 + recordsByURI[uri] = &record 658 + } 659 + return nil 660 + }) 661 + }) 662 + if err != nil { 663 + return nil, err 664 + } 665 + 666 + // Convert to FeedItems 667 + items := make([]*FeedItem, 0, len(records)) 668 + for _, record := range records { 669 + if record.Collection == atproto.NSIDLike || record.Collection == atproto.NSIDComment { 670 + continue 671 + } 672 + 673 + item, err := idx.recordToFeedItem(ctx, record, recordsByURI) 674 + if err != nil { 675 + log.Warn().Err(err).Str("uri", record.URI).Msg("failed to convert record to feed item") 676 + continue 677 + } 678 + if !FeedableRecordTypes[item.RecordType] { 679 + continue 680 + } 681 + items = append(items, item) 682 + } 683 + 684 + // Sort based on query 685 + switch q.Sort { 686 + case FeedSortPopular: 687 + sort.Slice(items, func(i, j int) bool { 688 + scoreI := items[i].LikeCount*3 + items[i].CommentCount*2 689 + scoreJ := items[j].LikeCount*3 + items[j].CommentCount*2 690 + if scoreI != scoreJ { 691 + return scoreI > scoreJ 692 + } 693 + return items[i].Timestamp.After(items[j].Timestamp) 694 + }) 695 + default: // FeedSortRecent 696 + sort.Slice(items, func(i, j int) bool { 697 + return items[i].Timestamp.After(items[j].Timestamp) 698 + }) 699 + } 700 + 701 + // Build result with cursor 702 + result := &FeedResult{Items: items} 703 + 704 + if len(items) > q.Limit { 705 + result.Items = items[:q.Limit] 706 + // Cursor is the last time key we read from the DB 707 + if lastTimeKey != nil { 708 + result.NextCursor = encodeCursor(lastTimeKey) 709 + } 710 + } 711 + 712 + return result, nil 713 + } 714 + 715 + // extractURIFromCollectionKey extracts the URI from a collection key 716 + // Format: {collection}:{inverted_timestamp_8bytes}:{uri} 717 + func extractURIFromCollectionKey(key []byte, collection string) string { 718 + // prefix is collection + ":" 719 + prefixLen := len(collection) + 1 720 + // Then 8 bytes of timestamp + ":" 721 + minLen := prefixLen + 8 + 1 + 1 // prefix + timestamp + ":" + at least 1 char 722 + if len(key) < minLen { 723 + return "" 724 + } 725 + return string(key[prefixLen+9:]) 726 + } 727 + 728 + func encodeCursor(key []byte) string { 729 + return hex.EncodeToString(key) 730 + } 731 + 732 + func decodeCursor(s string) ([]byte, error) { 733 + return hex.DecodeString(s) 477 734 } 478 735 479 736 // recordToFeedItem converts an IndexedRecord to a FeedItem
+40 -3
internal/handlers/feed.go
··· 6 6 7 7 "arabica/internal/atproto" 8 8 "arabica/internal/feed" 9 + "arabica/internal/lexicons" 9 10 "arabica/internal/models" 10 11 "arabica/internal/moderation" 11 12 "arabica/internal/web/components" ··· 68 69 // Community feed partial (loaded async via HTMX) 69 70 func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) { 70 71 var feedItems []*feed.FeedItem 72 + var nextCursor string 71 73 72 74 // Check if user is authenticated 73 75 viewerDID, err := atproto.GetAuthenticatedDID(r.Context()) 74 76 isAuthenticated := err == nil 75 77 78 + // Parse query parameters 79 + typeFilter := lexicons.ParseRecordType(r.URL.Query().Get("type")) 80 + sortBy := feed.FeedSort(r.URL.Query().Get("sort")) 81 + cursor := r.URL.Query().Get("cursor") 82 + 83 + if sortBy != feed.FeedSortPopular { 84 + sortBy = feed.FeedSortRecent 85 + } 86 + 76 87 if h.feedService != nil { 77 88 if isAuthenticated { 78 - feedItems, _ = h.feedService.GetRecentRecords(r.Context(), feed.FeedLimit) 89 + result, _ := h.feedService.GetFeedWithQuery(r.Context(), feed.FeedQuery{ 90 + Limit: feed.FeedLimit, 91 + Cursor: cursor, 92 + TypeFilter: typeFilter, 93 + Sort: sortBy, 94 + }) 95 + if result != nil { 96 + feedItems = result.Items 97 + nextCursor = result.NextCursor 98 + } 79 99 } else { 80 - // Unauthenticated users get a limited feed from the cache 100 + // Unauthenticated users get a limited feed from the cache (no filtering) 81 101 feedItems, _ = h.feedService.GetCachedPublicFeed(r.Context()) 82 102 } 83 103 } ··· 99 119 // Build moderation context for moderators 100 120 modCtx := h.buildModerationContext(r.Context(), viewerDID, feedItems) 101 121 102 - if err := pages.FeedPartialWithModeration(feedItems, isAuthenticated, modCtx).Render(r.Context(), w); err != nil { 122 + // Build query state for template 123 + queryState := pages.FeedQueryState{ 124 + TypeFilter: string(typeFilter), 125 + Sort: string(sortBy), 126 + NextCursor: nextCursor, 127 + IsAuthenticated: isAuthenticated, 128 + } 129 + 130 + // If this is a "load more" request (has cursor), render just the additional items 131 + if cursor != "" { 132 + if err := pages.FeedMoreItems(feedItems, isAuthenticated, modCtx, queryState).Render(r.Context(), w); err != nil { 133 + http.Error(w, "Failed to render feed", http.StatusInternalServerError) 134 + log.Error().Err(err).Msg("Failed to render feed partial") 135 + } 136 + return 137 + } 138 + 139 + if err := pages.FeedPartialWithModeration(feedItems, isAuthenticated, modCtx, queryState).Render(r.Context(), w); err != nil { 103 140 http.Error(w, "Failed to render feed", http.StatusInternalServerError) 104 141 log.Error().Err(err).Msg("Failed to render feed partial") 105 142 }
+10
internal/lexicons/record_type.go
··· 19 19 return string(r) 20 20 } 21 21 22 + // ParseRecordType converts a string to a RecordType if valid, returns empty string if not. 23 + func ParseRecordType(s string) RecordType { 24 + switch RecordType(s) { 25 + case RecordTypeBean, RecordTypeBrew, RecordTypeBrewer, RecordTypeGrinder, RecordTypeRoaster: 26 + return RecordType(s) 27 + default: 28 + return "" 29 + } 30 + } 31 + 22 32 // DisplayName returns a human-readable name for the RecordType. 23 33 func (r RecordType) DisplayName() string { 24 34 switch r {
+144 -12
internal/web/pages/feed.templ
··· 16 16 HiddenURIs map[string]bool // URIs that are currently hidden 17 17 } 18 18 19 + // FeedQueryState holds the current filter/sort/pagination state 20 + type FeedQueryState struct { 21 + TypeFilter string // Current type filter (empty = all) 22 + Sort string // Current sort order 23 + NextCursor string // Cursor for next page (empty = no more) 24 + IsAuthenticated bool 25 + } 26 + 27 + // feedFilterTab defines a filter tab option 28 + type feedFilterTab struct { 29 + Label string 30 + Value string 31 + } 32 + 33 + func feedFilterTabs() []feedFilterTab { 34 + return []feedFilterTab{ 35 + {Label: "All", Value: ""}, 36 + {Label: "Brews", Value: "brew"}, 37 + {Label: "Beans", Value: "bean"}, 38 + {Label: "Roasters", Value: "roaster"}, 39 + {Label: "Grinders", Value: "grinder"}, 40 + {Label: "Brewers", Value: "brewer"}, 41 + } 42 + } 43 + 44 + func buildFeedURL(typeFilter, sort string) string { 45 + url := "/api/feed" 46 + sep := "?" 47 + if typeFilter != "" { 48 + url += sep + "type=" + typeFilter 49 + sep = "&" 50 + } 51 + if sort != "" && sort != "recent" { 52 + url += sep + "sort=" + sort 53 + } 54 + return url 55 + } 56 + 57 + func buildFeedURLWithCursor(typeFilter, sort, cursor string) string { 58 + url := buildFeedURL(typeFilter, sort) 59 + if cursor != "" { 60 + if len(url) > len("/api/feed") { 61 + url += "&cursor=" + cursor 62 + } else { 63 + url += "?cursor=" + cursor 64 + } 65 + } 66 + return url 67 + } 68 + 19 69 // FeedPartial renders the feed items (for HTMX loading) 20 70 templ FeedPartial(items []*feed.FeedItem, isAuthenticated bool) { 21 - @FeedPartialWithModeration(items, isAuthenticated, FeedModerationContext{}) 71 + @FeedPartialWithModeration(items, isAuthenticated, FeedModerationContext{}, FeedQueryState{IsAuthenticated: isAuthenticated}) 22 72 } 23 73 24 74 // FeedPartialWithModeration renders feed items with moderation context 25 - templ FeedPartialWithModeration(items []*feed.FeedItem, isAuthenticated bool, modCtx FeedModerationContext) { 26 - <div class="space-y-4"> 27 - if len(items) > 0 { 28 - for _, item := range items { 29 - @FeedCardWithModeration(item, isAuthenticated, modCtx) 30 - } 31 - } else { 32 - <div class="bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200"> 33 - <p class="mb-2 font-medium">No activity in the feed yet.</p> 34 - <p class="text-sm">Be the first to add something!</p> 35 - </div> 75 + templ FeedPartialWithModeration(items []*feed.FeedItem, isAuthenticated bool, modCtx FeedModerationContext, qs FeedQueryState) { 76 + <div id="feed-container"> 77 + <!-- Filter tabs (authenticated only) --> 78 + if isAuthenticated { 79 + @FeedFilterBar(qs) 36 80 } 81 + <!-- Feed items --> 82 + <div id="feed-items" class="space-y-4"> 83 + if len(items) > 0 { 84 + for _, item := range items { 85 + @FeedCardWithModeration(item, isAuthenticated, modCtx) 86 + } 87 + <!-- Load more button --> 88 + if qs.NextCursor != "" { 89 + @FeedLoadMoreButton(qs) 90 + } 91 + } else { 92 + <div class="bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200"> 93 + <p class="mb-2 font-medium">No activity in the feed yet.</p> 94 + <p class="text-sm">Be the first to add something!</p> 95 + </div> 96 + } 97 + </div> 37 98 </div> 99 + } 100 + 101 + // FeedFilterBar renders the type filter tabs and sort selector 102 + templ FeedFilterBar(qs FeedQueryState) { 103 + <div class="flex flex-wrap items-center justify-between gap-2 mb-4"> 104 + <!-- Type filter tabs --> 105 + <div class="flex flex-wrap gap-1"> 106 + for _, tab := range feedFilterTabs() { 107 + <button 108 + class={ "px-3 py-1.5 text-sm rounded-full transition-colors", 109 + templ.KV("bg-brown-800 text-brown-50 font-medium", qs.TypeFilter == tab.Value), 110 + templ.KV("bg-brown-100 text-brown-700 hover:bg-brown-200", qs.TypeFilter != tab.Value) } 111 + hx-get={ buildFeedURL(tab.Value, qs.Sort) } 112 + hx-target="#feed-container" 113 + hx-swap="outerHTML" 114 + > 115 + { tab.Label } 116 + </button> 117 + } 118 + </div> 119 + <!-- Sort selector --> 120 + <div class="flex items-center gap-1.5 text-sm"> 121 + <button 122 + class={ "px-2.5 py-1 rounded-md transition-colors", 123 + templ.KV("bg-brown-800 text-brown-50 font-medium", qs.Sort == "" || qs.Sort == "recent"), 124 + templ.KV("text-brown-600 hover:text-brown-800 hover:bg-brown-100", qs.Sort != "" && qs.Sort != "recent") } 125 + hx-get={ buildFeedURL(qs.TypeFilter, "recent") } 126 + hx-target="#feed-container" 127 + hx-swap="outerHTML" 128 + > 129 + New 130 + </button> 131 + <button 132 + class={ "px-2.5 py-1 rounded-md transition-colors", 133 + templ.KV("bg-brown-800 text-brown-50 font-medium", qs.Sort == "popular"), 134 + templ.KV("text-brown-600 hover:text-brown-800 hover:bg-brown-100", qs.Sort != "popular") } 135 + hx-get={ buildFeedURL(qs.TypeFilter, "popular") } 136 + hx-target="#feed-container" 137 + hx-swap="outerHTML" 138 + > 139 + Popular 140 + </button> 141 + </div> 142 + </div> 143 + } 144 + 145 + // FeedLoadMoreButton renders a "Load more" button for pagination 146 + templ FeedLoadMoreButton(qs FeedQueryState) { 147 + <div class="text-center pt-2" x-data="{ loading: false }"> 148 + <button 149 + class="px-4 py-2 text-sm text-brown-700 bg-brown-100 hover:bg-brown-200 rounded-lg transition-colors disabled:opacity-50" 150 + hx-get={ buildFeedURLWithCursor(qs.TypeFilter, qs.Sort, qs.NextCursor) } 151 + hx-target="closest div" 152 + hx-swap="outerHTML" 153 + @htmx:before-request="loading = true" 154 + x-bind:disabled="loading" 155 + > 156 + <span x-show="!loading">Load more</span> 157 + <span x-show="loading" x-cloak>Loading...</span> 158 + </button> 159 + </div> 160 + } 161 + 162 + // FeedMoreItems renders additional items for "load more" pagination (no filter bar) 163 + templ FeedMoreItems(items []*feed.FeedItem, isAuthenticated bool, modCtx FeedModerationContext, qs FeedQueryState) { 164 + for _, item := range items { 165 + @FeedCardWithModeration(item, isAuthenticated, modCtx) 166 + } 167 + if qs.NextCursor != "" { 168 + @FeedLoadMoreButton(qs) 169 + } 38 170 } 39 171 40 172 // FeedCard renders a single feed item card (without moderation context)
-2
internal/web/pages/home.templ
··· 30 30 </div> 31 31 } 32 32 33 - // TODO: add pagination and "load more" button to feed 34 - // - this is probably mostly a backend change 35 33 templ CommunityFeedSection() { 36 34 <div class="card p-2 sm:p-6 mb-8"> 37 35 <h3 class="text-xl font-bold text-brown-900 mb-4">☕ Community Feed</h3>