Files
amethyst/commons/plans/2026-05-04-custom-feeds-testing-sheet.md
T
nrobi144 4010a57e68 feat(desktop): add custom feeds system with creation, pinning, and inline switching
- FeedDefinition data model with FeedSource sealed interface (Filter, Following, Global, DVM, PeopleList, InterestSet, SingleRelay)
- FeedDefinitionRepository with StateFlow, groupedFeeds, pin/unpin/reorder (max 3 pinned)
- JSON serialization via Jackson with round-trip tests (18 unit tests)
- SearchQuery.toFeedDefinition() bridge for creating feeds from search
- FeedBuilderDialog with author search (cache lookup + npub decode), hashtag/relay inputs, kind filter checkboxes, exclude authors/keywords, Cmd+S save, Enter to add
- FeedsDrawerTab in app drawer with edit/delete/pin/unpin actions
- Feeds tab in top header (FilterChips) with inline mode switching
- CustomFeedScreen + DesktopCustomFeedFilter + createCustomFeedSubscription for relay-based custom feed content
- FeedDefinitionEvent (kind 31890) in quartz for future publish/import
- Local persistence via java.util.prefs.Preferences (auto-save on change)
- DeckColumnType.CustomFeed for navigation integration
- Renamed Home to Feeds in nav rail
- Fix: AnimatedGifImage race condition (coerceIn on frame index)
- Fix: search results margins (sidePadding applied consistently)
- Fix: app drawer search includes feeds in results

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 09:59:30 +03:00

7.0 KiB

Custom Feeds — Testing Sheet

Branch: feat/desktop-custom-feeds Run: ./gradlew :desktopApp:run

Prerequisites

Wiring is complete. FeedDefinitionRepository is provided via CompositionLocalProvider in Main.kt. Default feeds (Following + Global) are loaded on startup.


Phase 1: Data Model + Serialization (Unit Tests)

./gradlew :commons:jvmTest --tests "com.vitorpamplona.amethyst.commons.feeds.custom.*"
# Test Expected
1 FeedDefinitionSerializerTest.roundTripFilterSource Serialize/deserialize Filter with all fields
2 FeedDefinitionSerializerTest.roundTripGlobalSource Global source round-trip
3 FeedDefinitionSerializerTest.roundTripFollowingSource Following source round-trip
4 FeedDefinitionSerializerTest.roundTripDvmSource DVM source round-trip
5 FeedDefinitionSerializerTest.roundTripPeopleListSource PeopleList source round-trip
6 FeedDefinitionSerializerTest.roundTripInterestSetSource InterestSet source round-trip
7 FeedDefinitionSerializerTest.roundTripSingleRelaySource SingleRelay source round-trip
8 FeedDefinitionSerializerTest.multipleFeeds Multiple feeds in single JSON
9 FeedDefinitionSerializerTest.emptyJsonReturnsEmptyList Empty/blank input
10 FeedDefinitionSerializerTest.defaultFeedsAreValid Default feeds (Following+Global) pinned
11 SearchQueryToFeedTest.convertsHashtagsToFeedFilter SearchQuery hashtags -> FeedSource.Filter
12 SearchQueryToFeedTest.convertsAuthorsToFeedFilter SearchQuery authors -> FeedSource.Filter
13 SearchQueryToFeedTest.convertsExcludeTerms Exclude terms mapping
14 SearchQueryToFeedTest.convertsKinds Kind mapping
15 SearchQueryToFeedTest.canBecomeFeedWithHashtags canBecomeFeed = true
16 SearchQueryToFeedTest.canBecomeFeedWithAuthors canBecomeFeed = true
17 SearchQueryToFeedTest.canNotBecomeFeedEmpty canBecomeFeed = false
18 SearchQueryToFeedTest.canNotBecomeFeedTextOnly canBecomeFeed = false

Phase 2/3: Sidebar + App Drawer Feeds Tab (Manual)

App Drawer Integration

# Action Expected
1 Open app drawer (Cmd+K) Drawer opens, shows 3 tabs: Screens, Workspaces, Feeds
2 Click "Feeds" tab Feed list appears (empty initially + defaults after wiring)
3 Cmd+Shift+F Drawer opens directly on Feeds tab
4 Feeds tab shows sections "Pinned", "My Feeds", "Algo Feeds" sections visible
5 Click a feed row Drawer closes, main content switches to that feed

Feed CRUD (in Feeds tab)

# Action Expected
6 Click "+ Create Feed" Feed Builder dialog opens
7 Enter name + emoji + hashtags Form validates (Save enabled when name + at least 1 source)
8 Click "Save" Dialog closes, new feed appears in "My Feeds" section
9 Click "Pin" on a feed Feed moves to "Pinned" section (max 3)
10 Try pinning a 4th feed Pin fails (PinLimitReached event, button stays)
11 Click "Unpin" on a pinned feed Feed moves back to "My Feeds"

Phase 4: Feed Builder Dialog (Manual)

# Action Expected
1 Open builder, leave empty "Save" button disabled
2 Enter name only Still disabled (needs source)
3 Enter name + add hashtag "bitcoin" "Save" enabled
4 Add multiple hashtags All show as chips, removable by click
5 Add author pubkey Shows as chip
6 Add relay URL Shows as chip
7 Add exclude keyword Shows as chip in exclude section
8 Toggle refresh mode (Live / Every 5 min) Selection changes
9 Click "Cancel" Dialog dismissed, no feed created
10 Click "Save" Feed created with all specified params

Phase 5: Search -> Feed Bridge (Manual)

# Action Expected
1 Search "#bitcoin" in search screen Results appear
2 Programmatically: SearchQuery(hashtags=listOf("bitcoin")).canBecomeFeed() Returns true
3 query.toFeedDefinition("BTC", "₿") Creates FeedDefinition with FeedSource.Filter(hashtags=["bitcoin"])

(Note: "Save as Feed" button in search UI is not yet wired — the bridge logic exists but UI button pending)


Phase 1.5: Custom Feed Content (Manual)

# Action Expected
1 Create feed with hashtag "nostr" Feed created
2 Select that feed from drawer Content area shows FeedScreen
3 Wait for events Notes containing #nostr appear (from relay subscription)
4 Create feed with author pubkey Only notes from that author appear
5 Create feed with excludeKeyword "spam" Notes containing "spam" filtered out
6 Switch between feeds Content updates, old subscription closed
7 Feed with specific relay URL Subscription targets only that relay

Phase 7: Kind 31890 Event (Compile-level)

# Check Expected
1 FeedDefinitionEvent.KIND == 31890 Correct
2 FeedDefinitionEvent extends BaseAddressableEvent Has dTag(), address(), addressTag()
3 title() extracts from tags Parses ["title", "..."] tag
4 emoji() extracts from tags Parses ["emoji", "..."] tag
5 feedConfigJson() returns content JSON-serialized FeedSource

Compilation Verification

# Full compile (all modules)
./gradlew :quartz:compileKotlinJvm :commons:compileKotlinJvm :desktopApp:compileKotlin

# Unit tests
./gradlew :commons:jvmTest --tests "com.vitorpamplona.amethyst.commons.feeds.custom.*"

# Code formatting
./gradlew spotlessApply

Wiring TODO (Required for Manual Testing)

The FeedDefinitionRepository needs to be instantiated and provided via CompositionLocalProvider in Main.kt. Steps:

  1. In Main.kt App() function (around line 710), create:
val feedRepository = remember { FeedDefinitionRepository(appScope) }
LaunchedEffect(Unit) { feedRepository.load(defaultFeeds()) }
  1. In the CompositionLocalProvider block (around line 1166), add:
LocalFeedRepository provides feedRepository,
LocalFeedScope provides appScope,
  1. This enables:
    • FeedsDrawerTab to read/write feeds
    • CustomFeedScreen to resolve feed definitions by ID

Known Limitations (not bugs)

Item Status
DVM marketplace (Phase 6) Button wired, content shows "coming soon"
PeopleList/InterestSet resolution Shows "coming soon" — needs ATag resolution
Kind 10090 cross-device sync Not wired yet — local-only
Feed publish/import (naddr) Event class exists, publish UI not wired
Sidebar pinned feed emojis Needs PinnedNavBarState integration for feed-specific items
Account-scoped persistence Currently in-memory only (needs serialize to account settings)
Cmd+1/2/3 shortcuts Not wired yet (needs MenuBar items in Main.kt)
Drag-to-reorder Repository supports it, UI gesture handlers pending