···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{"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{"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"}
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"}
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{"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{"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{"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{"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{"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"}
0
···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{"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{"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:00Z"}
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 \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{"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{"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{"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{"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{"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{"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 mu sync.RWMutex
71}
72000000000000000000000073// FirehoseIndex is the interface for the firehose feed index
74// This allows the feed service to use firehose data when available
75type FirehoseIndex interface {
76 IsReady() bool
77 GetRecentFeed(ctx context.Context, limit int) ([]*FirehoseFeedItem, error)
00000000000000078}
7980// FirehoseFeedItem matches the FeedItem structure from firehose package
···277 }
278279 return items, nil
000000000000000000000000000000000000000000000000000000000000000000280}
281282// getRecentRecordsFromFirehose fetches feed items from the firehose index
···70 mu sync.RWMutex
71}
7273+// 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+95// FirehoseIndex is the interface for the firehose feed index
96// This allows the feed service to use firehose data when available
97type FirehoseIndex interface {
98 IsReady() bool
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
115}
116117// FirehoseFeedItem matches the FeedItem structure from firehose package
···314 }
315316 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
383}
384385// getRecentRecordsFromFirehose fetches feed items from the firehose index
+23-3
internal/firehose/adapter.go
···30 return nil, err
31 }
3233- // Convert to the type expected by feed.Service
00000000000000000000034 result := make([]*feed.FirehoseFeedItem, len(items))
35 for i, item := range items {
36 result[i] = &feed.FirehoseFeedItem{
···50 SubjectCID: item.SubjectCID,
51 }
52 }
53-54- return result, nil
55}
···1package firehose
23import (
04 "context"
5 "encoding/binary"
06 "encoding/json"
7 "fmt"
8 "os"
···109110 ready bool
111 readyMu sync.RWMutex
0000000000000000000000112}
113114// NewFeedIndex creates a new feed index backed by BoltDB
···474 }
475476 return items, nil
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000477}
478479// recordToFeedItem converts an IndexedRecord to a FeedItem
···1package firehose
23import (
4+ "bytes"
5 "context"
6 "encoding/binary"
7+ "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "os"
···111112 ready bool
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
136}
137138// NewFeedIndex creates a new feed index backed by BoltDB
···498 }
499500 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)
734}
735736// recordToFeedItem converts an IndexedRecord to a FeedItem
+40-3
internal/handlers/feed.go
···67 "arabica/internal/atproto"
8 "arabica/internal/feed"
09 "arabica/internal/models"
10 "arabica/internal/moderation"
11 "arabica/internal/web/components"
···68// Community feed partial (loaded async via HTMX)
69func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) {
70 var feedItems []*feed.FeedItem
07172 // Check if user is authenticated
73 viewerDID, err := atproto.GetAuthenticatedDID(r.Context())
74 isAuthenticated := err == nil
7500000000076 if h.feedService != nil {
77 if isAuthenticated {
78- feedItems, _ = h.feedService.GetRecentRecords(r.Context(), feed.FeedLimit)
00000000079 } else {
80- // Unauthenticated users get a limited feed from the cache
81 feedItems, _ = h.feedService.GetCachedPublicFeed(r.Context())
82 }
83 }
···99 // Build moderation context for moderators
100 modCtx := h.buildModerationContext(r.Context(), viewerDID, feedItems)
101102- if err := pages.FeedPartialWithModeration(feedItems, isAuthenticated, modCtx).Render(r.Context(), w); err != nil {
00000000000000000103 http.Error(w, "Failed to render feed", http.StatusInternalServerError)
104 log.Error().Err(err).Msg("Failed to render feed partial")
105 }
···67 "arabica/internal/atproto"
8 "arabica/internal/feed"
9+ "arabica/internal/lexicons"
10 "arabica/internal/models"
11 "arabica/internal/moderation"
12 "arabica/internal/web/components"
···69// Community feed partial (loaded async via HTMX)
70func (h *Handler) HandleFeedPartial(w http.ResponseWriter, r *http.Request) {
71 var feedItems []*feed.FeedItem
72+ var nextCursor string
7374 // Check if user is authenticated
75 viewerDID, err := atproto.GetAuthenticatedDID(r.Context())
76 isAuthenticated := err == nil
7778+ // 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+87 if h.feedService != nil {
88 if isAuthenticated {
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+ }
99 } else {
100+ // Unauthenticated users get a limited feed from the cache (no filtering)
101 feedItems, _ = h.feedService.GetCachedPublicFeed(r.Context())
102 }
103 }
···119 // Build moderation context for moderators
120 modCtx := h.buildModerationContext(r.Context(), viewerDID, feedItems)
121122+ // 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 {
140 http.Error(w, "Failed to render feed", http.StatusInternalServerError)
141 log.Error().Err(err).Msg("Failed to render feed partial")
142 }
+10
internal/lexicons/record_type.go
···19 return string(r)
20}
21000000000022// DisplayName returns a human-readable name for the RecordType.
23func (r RecordType) DisplayName() string {
24 switch r {
···19 return string(r)
20}
2122+// 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+32// DisplayName returns a human-readable name for the RecordType.
33func (r RecordType) DisplayName() string {
34 switch r {
+144-12
internal/web/pages/feed.templ
···16 HiddenURIs map[string]bool // URIs that are currently hidden
17}
180000000000000000000000000000000000000000000000000019// FeedPartial renders the feed items (for HTMX loading)
20templ FeedPartial(items []*feed.FeedItem, isAuthenticated bool) {
21- @FeedPartialWithModeration(items, isAuthenticated, FeedModerationContext{})
22}
2324// 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>
36 }
0000000000000000037 </div>
0000000000000000000000000000000000000000000000000000000000000000000000038}
3940// FeedCard renders a single feed item card (without moderation context)