diff --git a/commons/plans/2026-05-04-custom-feeds-plan.md b/commons/plans/2026-05-04-custom-feeds-plan.md new file mode 100644 index 000000000..fb4ee35f2 --- /dev/null +++ b/commons/plans/2026-05-04-custom-feeds-plan.md @@ -0,0 +1,566 @@ +# Custom Feeds for Amethyst Desktop + +**Date:** 2026-05-04 +**Branch:** `feat/desktop-custom-feeds` +**Status:** Planning +**Deepened:** 2026-05-04 + +## Enhancement Summary + +**Research agents used:** feed-patterns, compose-expert, desktop-expert, relay-client, kotlin-expert, nostr-expert, account-state, compose-dnd-research, nip90-research + +### Key Improvements from Research +1. Feeds must be **account-scoped** (not java.util.prefs) — supports multi-account + cross-device sync via kind 10090 +2. Added **Phase 1.5** (FeedFilter mapping + relay subscriptions) — critical missing layer between data model and UI +3. Use `ImmutableList` from kotlinx.collections.immutable for all FeedSource collections — Compose stability +4. Sync pinned feeds with existing **kind 10090 `FavoriteAlgoFeedsListEvent`** for cross-device persistence +5. Use **Calvin-LL/Reorderable** library for drag-reorder (KMP, proven) +6. Changed chord shortcut from `Cmd+F, 1/2/3` to `Cmd+1/2/3` (standard tab-switch, no conflict) +7. Added EOSE-aware loading states and subscription lifecycle management + +--- + +## Overview + +Add custom feed creation, discovery, and management to Amethyst Desktop. Users can create feeds from hashtags, authors, relays, keywords; browse DVM algorithmic feeds; pin top 3 to the sidebar; manage all feeds via the app drawer's new Feeds tab; and optionally publish/import feeds via kind 31890 events. + +## Goals + +- Intuitive feed lookup and creation (from search or builder) +- Customizable feed navbar (top 3 pinned in sidebar, expandable via drawer) +- DVM marketplace for algorithmic feeds +- Local-first with optional protocol-level sharing (kind 31890 + naddr) + +## Non-Goals (for now) + +- Set operations (union/intersection/difference) +- WoT-based filtering +- Auto-zapping DVMs + +## Design Decisions + +| # | Decision | +|---|----------| +| 1 | Top 3 feeds pinned in left sidebar, hard cap | +| 2 | "More" opens app drawer with FEEDS tab | +| 3 | Following + Global pre-created as FeedDefinitions | +| 4 | Local + private first; optional publish to relays | +| 5 | Live stream refresh; periodic poll fallback for DVMs | +| 6 | DVM zap requires confirm popup | +| 7 | Emoji-only feed icons | +| 8 | Drag-reorder in both sidebar and drawer | +| 9 | Reuse existing HomeFeed column rendering (swap filter) | +| 10 | Feed sharing via kind 31890 naddr (publish, copy, paste) | +| 11 | Account-scoped persistence (not machine-global java.util.prefs) | +| 12 | Sync pinned feeds with kind 10090 FavoriteAlgoFeedsListEvent | + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `Cmd+Shift+F` | Open drawer on Feeds tab | +| `Cmd+K` | Open drawer (last tab) | +| `Cmd+1/2/3` | Switch to pinned feed 1/2/3 | +| `Cmd+N` (in feeds tab) | Create new feed | + +> **Note:** `Cmd+1/2/3` is standard tab-switching (like browsers, Slack). Avoids `Cmd+F` conflict with search. Implemented via `MenuBar` items in a "Feeds" menu — no chord state machine needed. + +## Data Model + +```kotlin +// commons/src/commonMain/.../feeds/custom/FeedDefinition.kt + +@Immutable +data class FeedDefinition( + val id: String, // UUID + val name: String, + val emoji: String, // single emoji as icon + val pinned: Boolean, + val pinOrder: Int, // 0, 1, 2 + val source: FeedSource, + val refreshMode: RefreshMode, + val createdAt: Long, +) + +@Immutable +sealed interface FeedSource { + @Immutable + data class Filter( + val hashtags: ImmutableList, + val authors: ImmutableList, + val relays: ImmutableList, + val excludeAuthors: ImmutableList, + val excludeKeywords: ImmutableList, + val kinds: ImmutableList, + ) : FeedSource + + @Immutable data class PeopleList(val address: ATag) : FeedSource + @Immutable data class InterestSet(val address: ATag) : FeedSource + @Immutable data class DVM(val address: ATag) : FeedSource + @Immutable data class SingleRelay(val url: String) : FeedSource + @Immutable data object Global : FeedSource + @Immutable data object Following : FeedSource +} + +enum class RefreshMode { + LIVE_STREAM, + POLL_5MIN, +} +``` + +### Research Insights: Data Model + +- **ImmutableList** (from `kotlinx.collections.immutable`, already in project) required for Compose stability — bare `List` is treated as unstable +- **@Immutable** on sealed interface + all subtypes ensures skip-safe recomposition in FeedCard/sidebar +- **feedKey identity**: use `"custom-${definition.id}"` in generated filters to avoid cache collision between feeds with same parameters but different names +- **DSL builder** for test/programmatic construction: + +```kotlin +inline fun feedDefinition(init: FeedDefinitionBuilder.() -> Unit): FeedDefinition = + FeedDefinitionBuilder().apply(init).build() + +// Usage in tests: +val feed = feedDefinition { + name = "Bitcoin" + emoji = "₿" + filter { + hashtags += "bitcoin" + kinds += 1 + } +} +``` + +## Repository & State Management + +```kotlin +// commons/src/commonMain/.../feeds/custom/FeedDefinitionRepository.kt + +@Stable +class FeedDefinitionRepository( + private val scope: CoroutineScope, + private val serializer: FeedDefinitionSerializer, +) { + private val _feeds = MutableStateFlow>(persistentListOf()) + val feeds: StateFlow> = _feeds.asStateFlow() + + // Pre-computed grouped view for drawer UI + val groupedFeeds: StateFlow = _feeds.mapLatest { all -> + GroupedFeeds( + pinned = all.filter { it.pinned }.sortedBy { it.pinOrder }.toImmutableList(), + myFeeds = all.filter { !it.pinned && it.source !is FeedSource.DVM }.toImmutableList(), + algoFeeds = all.filter { it.source is FeedSource.DVM }.toImmutableList(), + ) + }.distinctUntilChanged().stateIn(scope, SharingStarted.Eagerly, GroupedFeeds.EMPTY) + + // Derived for sidebar (only recomposes when pinned change) + val pinnedFeeds: StateFlow> = groupedFeeds.map { it.pinned } + .distinctUntilChanged().stateIn(scope, SharingStarted.Eagerly, persistentListOf()) + + // Transient UI events + private val _events = MutableSharedFlow(replay = 0) + val events: SharedFlow = _events.asSharedFlow() +} + +sealed interface FeedEvent { + data class Created(val feed: FeedDefinition) : FeedEvent + data class PinLimitReached(val max: Int) : FeedEvent +} + +@Immutable +data class GroupedFeeds( + val pinned: ImmutableList, + val myFeeds: ImmutableList, + val algoFeeds: ImmutableList, +) { + companion object { val EMPTY = GroupedFeeds(persistentListOf(), persistentListOf(), persistentListOf()) } +} +``` + +### Account-Scoped Persistence + +Feeds are per-account, NOT machine-global: +- Serialize alongside `AccountSettings` (same mechanism as `defaultHomeFollowList`, `favoriteAlgoFeeds`) +- On account switch, feed list swaps automatically +- On login, also subscribe to own kind 10090 events to restore pinned feeds from relay + +## Navigation Layout + +``` ++--------+------------------------------------+ +| A | | +| | | +| [em1] | <- Pinned feed 1 (active) | +| [em2] | | +| [em3] | Feed Content (reuses HomeFeed) | +| | | +| ... | <- "More feeds" (opens drawer) | +| | | +| | | +| gear | | ++--------+------------------------------------+ +``` + +### Research Insights: Sidebar + +- Extend existing `DeckSidebar` params: `pinnedFeeds`, `activeFeedId`, `onSwitchFeed`, `onOpenFeedsDrawer` +- Insert emoji buttons between "Add Column" button and `Spacer(weight=1)` +- Active feed: `primaryContainer` background with `CircleShape` +- Tooltip: `"${feed.name} (Cmd+${index+1})"` +- For 3 items, use simple `Column` + `pointerInput` with `detectDragGestures` (no library needed) +- Drag state: track `draggedIndex` + `dragOffsetY`, swap on threshold cross + +## App Drawer Feeds Tab + +``` ++--- App Drawer (Cmd+K / Cmd+Shift+F) ------+ +| Search: [________________________] | +| | +| [Screens] [Workspaces] [Feeds <-active] | +| | +| Pinned (3/3) | +| em Following [unpin] [menu] | +| em Bitcoin [unpin] [edit] [menu]| +| em Trending (DVM) [unpin] [menu] | +| | +| My Feeds | +| em Dev Talk [pin] [edit] [menu] | +| em Memes [pin] [edit] [menu] | +| | +| Algo Feeds | +| em Primal Popular [pin] [menu] | +| | +| [+ Create Feed] [Browse DVMs] | ++---------------------------------------------+ +``` + +### Research Insights: Drawer + +- Extend `AppDrawerTab` enum + `AppDrawerState` with filtered feeds +- `Cmd+Shift+F` → add `MenuBar` item with `KeyShortcut(Key.F, meta=true, shift=true)` that sets `showAppDrawer=true` + `appDrawerInitialTab=FEEDS` +- Use `derivedStateOf` for search filtering (avoids recomposition on every keystroke when filtered result unchanged) +- LazyColumn with `key = { it.id }` + `Modifier.animateItem()` for smooth reorder +- Right-click: `onPointerEvent(PointerEventType.Press)` + `isSecondaryPressed` → `DropdownMenu` (existing pattern in AppDrawer) +- For drawer reorder: use **Calvin-LL/Reorderable** (v3.1.0, full KMP support) + +## Feed Creation Paths + +| Path | Entry | Result | +|------|-------|--------| +| Search -> Feed | Search results -> "Save as Feed" | SearchQuery -> FeedSource.Filter | +| Builder | Drawer -> "+ Create Feed" | Feed Builder dialog | +| DVM Browse | Drawer -> "Browse DVMs" -> pick | FeedSource.DVM | +| Import | Paste naddr in search/drawer | Fetch kind 31890 -> add | + +## Feed Sharing + +| Action | Mechanism | +|--------|-----------| +| Publish | Menu -> "Publish to Relays" -> signs kind 31890 | +| Share | After publish -> "Copy naddr" via `NAddress.create(31890, pubkey, dTag, relays)` | +| Import | Paste naddr -> client decodes with `Nip19Parser` -> if kind==31890 render feed card -> "Add to My Feeds" | + +## Kind 31890 Event Structure + +``` +kind: 31890 (addressable replaceable) +content: JSON-serialized FeedSource (see schema below) +tags: + ["d", ""] + ["title", ""] + ["emoji", ""] + ["alt", "Feed definition: "] + // Discoverability tags (duplicated from content for relay filtering): + ["t", ""] // for each hashtag in filter + ["p", ""] // for each author in filter + ["relay", ""] // for relay-based feeds + ["a", "31990::"] // DVM reference + ["a", "30000::"] // PeopleList reference + ["a", "30015::"] // InterestSet reference +``` + +**Content JSON schema:** +```json +{ + "type": "filter|people_list|interest_set|dvm|relay|global|following", + "hashtags": ["bitcoin"], + "authors": ["hex..."], + "relays": ["wss://..."], + "exclude_authors": ["hex..."], + "exclude_keywords": ["spam"], + "kinds": [1, 6, 30023], + "refresh": "live|poll_5min", + "source_address": "30000:hex:dtag" +} +``` + +## Implementation Phases + +### Phase 1: Data Model + Persistence + +**Location:** `commons/src/commonMain/kotlin/.../feeds/custom/` + +- `FeedDefinition` data class with `@Immutable`, `ImmutableList` collections +- `FeedSource` sealed interface with all variants +- `RefreshMode` enum +- `FeedDefinitionRepository` with `StateFlow>` + `groupedFeeds` + `pinnedFeeds` +- `FeedDefinitionSerializer` (JSON via Jackson, exhaustive `when` on FeedSource for compile safety) +- `FeedDefinitionBuilder` DSL for tests and programmatic creation +- Account-scoped persistence (serialize alongside AccountSettings) +- Pre-create Following + Global as defaults on first launch +- Unit tests for serialization round-trip + builder DSL + +### Phase 1.5: FeedFilter Mapping + Relay Subscriptions + +**Location:** `commons/src/commonMain/kotlin/.../feeds/custom/` + +This is the critical bridge between data model and UI rendering. + +**FeedFilter per FeedSource variant:** + +| FeedSource | Filter Type | Base Class | +|------------|-------------|------------| +| Filter | `CustomFilterFeedFilter` | `AdditiveComplexFeedFilter` | +| Following | Reuse existing `HomeFeedFilter` | — | +| Global | Reuse existing `GlobalFeedFilter` | — | +| PeopleList | Resolve ATag -> extract pubkeys -> author filter | `AdditiveComplexFeedFilter` | +| InterestSet | Resolve ATag -> extract hashtags -> tag filter | `AdditiveComplexFeedFilter` | +| DVM | `DvmFeedFilter` (non-additive, results from external) | `FeedFilter` | +| SingleRelay | `CustomFilterFeedFilter` (targeted to one relay) | `AdditiveComplexFeedFilter` | + +**FeedFilterFactory:** +```kotlin +class FeedFilterFactory { + fun createFilter(definition: FeedDefinition): IFeedFilter = when (definition.source) { + is FeedSource.Filter -> CustomFilterFeedFilter(definition) + is FeedSource.Following -> HomeFeedFilter(account) + is FeedSource.Global -> GlobalFeedFilter() + // ... etc + } +} +``` + +**Key rules:** +- `feedKey() = "custom-${definition.id}"` — unique per feed, avoids cache collision +- `excludeAuthors`/`excludeKeywords` applied client-side in `applyFilter()`, not at relay level +- Unit test: `applyFilter(event)` must match what `feed()` would include/exclude + +**Relay subscription assembler:** +```kotlin +class CustomFeedFilterAssembler(private val source: FeedSource.Filter) { + fun toFilter(): Filter = filter { + if (source.kinds.isNotEmpty()) kinds(source.kinds) + if (source.authors.isNotEmpty()) authors(source.authors.toSet()) + if (source.hashtags.isNotEmpty()) tags("t", source.hashtags.toSet()) + limit(200) + } +} +``` + +**ViewModel selection:** +- Standard feeds (hashtags, authors, relays) -> `FeedViewModel` +- DVM feeds -> `FeedViewModel` with poll-based invalidation +- PeopleList feeds -> `ListChangeFeedViewModel` (membership changes) + +**EOSE-aware loading state:** +```kotlin +class CustomFeedSubscriptionState( + val events: StateFlow>, + val eoseReceived: StateFlow, + val lastRefreshed: StateFlow, +) +``` + +**Subscription lifecycle:** +- Only the ACTIVE feed has a live subscription +- Pinned feeds not currently displayed = NO open subscription +- On switch: old feed `unsubscribe()`, new feed `subscribe()` +- For POLL_5MIN: subscribe -> wait EOSE -> unsubscribe -> timer -> repeat + +**Invalidation signals per FeedSource:** +- `Filter` with authors -> invalidate when those authors' notes arrive in LocalCache +- `Following` -> invalidate on `Account.followListFlow` change +- `PeopleList` -> invalidate when referenced list event updates + +### Phase 2: Sidebar Pinned Feeds + +**Location:** `desktopApp/src/jvmMain/.../deck/DeckSidebar.kt` + +- Add params to `DeckSidebar`: `pinnedFeeds`, `activeFeedId`, `onSwitchFeed`, `onOpenFeedsDrawer` +- Insert pinned feed emoji buttons between "Add Column" and spacer +- Active state: `primaryContainer` background + `CircleShape` +- Click switches active feed (triggers subscription swap) +- Drag-to-reorder: `detectDragGestures` on each item (3 items, Column, no library needed) +- Tooltip with shortcut hint: `"${feed.name} (Cmd+${index+1})"` +- "More" button (MaterialSymbols.MoreHoriz) opens drawer on Feeds tab +- Wire `Cmd+1/2/3` via `MenuBar` items in "Feeds" menu (OS-aware: meta on macOS, ctrl on others) + +### Phase 3: App Drawer Feeds Tab + +**Location:** `desktopApp/src/jvmMain/.../deck/AppDrawer.kt` + +- Add `FEEDS` to `AppDrawerTab` enum +- Extend `AppDrawerState` with `filteredFeeds()` method + keyboard nav for 3rd tab +- `Cmd+Shift+F` → MenuBar item that opens drawer on Feeds tab (pass `appDrawerInitialTab`) +- Feed list grouped via `groupedFeeds` StateFlow (pre-computed in repository) +- Search: `derivedStateOf` filtering by name/emoji +- Pin/unpin buttons (grayed out at 3 cap, emit `FeedEvent.PinLimitReached`) +- Right-click: `onPointerEvent` + `isSecondaryPressed` -> DropdownMenu (Edit, Duplicate, Delete, Publish) +- Drag-reorder in pinned section: Calvin-LL/Reorderable v3.1.0 with `LazyColumn` + `key = { it.id }` +- `animateItem()` for smooth movement on reorder + +### Phase 4: Feed Builder Dialog + +**Location:** `commons/src/commonMain/.../feeds/custom/ui/` (composable) + `desktopApp` (host) + +**State hoisting pattern:** +```kotlin +@Stable +class FeedBuilderState(initial: FeedDefinition?) { + var name by mutableStateOf(initial?.name ?: "") + var emoji by mutableStateOf(initial?.emoji ?: "") + val hashtags = mutableStateListOf() + val authors = mutableStateListOf() + val relays = mutableStateListOf() + val excludeAuthors = mutableStateListOf() + val excludeKeywords = mutableStateListOf() + // ... + fun toDefinition(): FeedDefinition = ... +} +``` + +- Stateless composable: `FeedBuilderDialog(initialDefinition, onSave, onDismiss)` +- Internal state via `rememberFeedBuilderState(initial)` +- Emoji picker: simple grid of common emojis in `FlowRow` (use Emoji.kt data library for full Unicode set) +- Author autocomplete via ViewModel (never query LocalCache directly from composable) +- `dismissOnBackPress = true, dismissOnClickOutside = false` (prevent accidental data loss) +- Material3: `AlertDialog` or `Dialog` with `surface` bg + +### Phase 5: Search -> Feed Bridge + +**Location:** `commons/src/commonMain/.../feeds/custom/` + +- `SearchQuery.toFeedDefinition()` extension +- Maps hashtag operators -> `FeedSource.Filter.hashtags` +- Maps from: operators -> `FeedSource.Filter.authors` +- Maps relay: operators -> `FeedSource.Filter.relays` +- Maps exclude operators -> excludeAuthors/excludeKeywords +- Maps kind: operators -> `FeedSource.Filter.kinds` +- "Save as Feed" button in search results UI +- Uses `FeedDefinitionBuilder` DSL internally + +### Phase 6: DVM Marketplace + +**Location:** `desktopApp/src/jvmMain/.../feeds/` + +- Browse kind 31990 `AppDefinitionEvent` filtered by `isTaggedKind(5300)` (existing Quartz class) +- List with name, description, author, cost indicator +- Preview: send kind 5300 request, show results in preview panel +- "Add to My Feeds" creates `FeedSource.DVM(address)` entry +- Zap confirm popup when kind 7000 status = "payment-required" with `firstAmount()` + invoice +- DVM request goes to DVM's advertised relays (from kind 31990 `relay` tags) +- Response subscription listens on both user's relays AND DVM's relays +- Use `MetadataPreloader` for bulk-fetching author metadata of returned notes +- Reuse existing `NIP90ContentDiscoveryRequestEvent.build()` pattern from Quartz + +### Phase 7: Publish/Import (kind 31890) + +**Location:** `quartz/src/commonMain/.../feedDefinition/` (event type) + `commons` (UI) + +- `FeedDefinitionEvent` extends `BaseAddressableEvent` (kind 31890) +- d-tag = feed UUID, content = JSON FeedSource, tags for discoverability +- Serialize `FeedDefinition` -> event via `FeedDefinitionEvent.build(signer, definition)` +- Parse kind 31890 events -> `FeedDefinition` via content JSON deserialization +- "Publish to Relays" action in feed context menu (signs + publishes) +- "Copy naddr" via `NAddress.create(31890, pubkey, dTag, relays)` +- Import: `Nip19Parser` detects naddr with kind 31890 -> fetch event -> render feed card preview +- "Add to My Feeds" clones with new UUID (marks as not-published-by-me) +- On login: subscribe to own kind 31890 + kind 10090 to restore from relay + +### Cross-Device Sync (kind 10090) + +- Sync pinned feed addresses with existing `FavoriteAlgoFeedsListEvent` (kind 10090) +- On pin/unpin, update kind 10090 event with current pinned feed addresses +- On login/restore, fetch own kind 10090, resolve `AddressBookmark` entries, populate sidebar +- This reuses the existing protocol — no new event kind needed for pin sync + +## File Map (expected new files) + +``` +commons/src/commonMain/kotlin/.../feeds/custom/ + FeedDefinition.kt # @Immutable data class + FeedSource + RefreshMode + FeedDefinitionRepository.kt # StateFlow-based, account-scoped + FeedDefinitionSerializer.kt # JSON serialization (exhaustive when) + FeedDefinitionBuilder.kt # DSL for tests + SearchQuery bridge + GroupedFeeds.kt # @Immutable pre-computed grouping + FeedEvent.kt # SharedFlow events (PinLimitReached, etc.) + SearchQueryToFeed.kt # SearchQuery.toFeedDefinition() extension + filters/ + CustomFilterFeedFilter.kt # AdditiveComplexFeedFilter for FeedSource.Filter + FeedFilterFactory.kt # FeedSource -> IFeedFilter mapping + assemblers/ + CustomFeedFilterAssembler.kt # FeedSource.Filter -> relay Filter + PeopleListFilterAssembler.kt # Resolve ATag -> author set -> Filter + DvmFeedSubscribable.kt # NIP-90 request/response lifecycle + ui/ + FeedBuilderDialog.kt # Shared composable (stateless) + FeedBuilderState.kt # @Stable state holder + FeedCard.kt # Feed preview card + EmojiPicker.kt # Simple emoji grid (FlowRow) + +desktopApp/src/jvmMain/kotlin/.../deck/ + FeedSidebarSection.kt # Pinned feeds in sidebar + FeedDrawerTab.kt # FEEDS tab content + DvmMarketplace.kt # DVM browse UI + +quartz/src/commonMain/kotlin/.../feedDefinition/ + FeedDefinitionEvent.kt # kind 31890 (BaseAddressableEvent) + +commons/src/commonTest/kotlin/.../feeds/custom/ + FeedDefinitionSerializerTest.kt + FeedDefinitionBuilderTest.kt + SearchQueryToFeedTest.kt + CustomFeedFilterAssemblerTest.kt +``` + +## Dependencies on Existing Code + +| Component | Location | Usage | +|-----------|----------|-------| +| `TopFilter` | `amethyst/.../AccountSettings.kt` | Reference; `FeedSource.toTopFilter()` for bridge | +| `FavoriteAlgoFeedsOrchestrator` | `amethyst/.../algoFeeds/` | Extract to commons for DVM reuse | +| `FavoriteAlgoFeedsListEvent` (kind 10090) | `quartz/.../nip51Lists/` | Pinned feed sync | +| `NIP90ContentDiscoveryRequestEvent` | `quartz/.../nip90Dvms/` | DVM request building | +| `AppDefinitionEvent` (kind 31990) | `quartz/.../nip89AppHandlers/` | DVM marketplace discovery | +| `NAddress` | `quartz/.../nip19Bech32/entities/` | naddr encode/decode | +| `Nip19Parser` | `quartz/.../nip19Bech32/` | Detect pasted naddr | +| `SearchQuery` / `QueryParser` | `commons/.../search/` | Phase 5 bridge | +| `AppDrawer` / `AppDrawerTab` | `desktopApp/.../deck/AppDrawer.kt` | Phase 3 integration | +| `DeckSidebar` | `desktopApp/.../deck/DeckSidebar.kt` | Phase 2 integration | +| `PinnedNavBarState` | `desktopApp/.../deck/PinnedNavBarState.kt` | Reference pattern for pin state | +| `HomeFeed` rendering | `desktopApp/.../home/` | Phase 2 content reuse | +| `BaseAddressableEvent` | `quartz/.../nip01Core/core/` | Base for kind 31890 | +| `PeopleListEvent` / `InterestSetEvent` | `quartz/.../nip51Lists/` | Resolve ATag -> members | +| `MetadataPreloader` | `commons/.../relayClient/` | Bulk metadata fetch for feed results | +| `FeedMetadataCoordinator` | `commons/.../relayClient/` | Coordinate metadata for visible notes | +| `ComposeSubscriptionManager` | `commons/.../relayClient/` | Subscription lifecycle | + +## External Dependencies + +| Library | Version | Usage | +|---------|---------|-------| +| `sh.calvin.reorderable:reorderable` | 3.1.0 | Drag-reorder in drawer LazyColumn | +| `org.kodein.emoji:emoji-compose` (Emoji.kt) | latest | Emoji data for picker grid | +| `kotlinx.collections.immutable` | (already in project) | ImmutableList for stability | + +## Risk & Mitigations + +| Risk | Mitigation | +|------|------------| +| DVM latency makes feeds feel broken | Show loading skeleton + "last refreshed" timestamp + EOSE state | +| Kind 31890 NIP still in draft | Keep publish optional; local-first always works | +| Sidebar drag-reorder complexity | Only 3 items — simple `detectDragGestures`, no library | +| Feed builder autocomplete for authors | Reuse existing user search via ViewModel (never direct LocalCache query) | +| Account switching breaks feed state | Account-scoped repository auto-swaps with account | +| Kind 10090 sync conflicts | Last-write-wins (same as other replaceable events) | +| Preferences 8KB limit (if used for temp storage) | JSON chunking pattern or switch to account serialization | +| DVM payment format inconsistency | Support bolt11 from amount tag + NIP-57 zap; show raw amount if format unclear | diff --git a/commons/plans/2026-05-04-custom-feeds-testing-sheet.md b/commons/plans/2026-05-04-custom-feeds-testing-sheet.md new file mode 100644 index 000000000..f90735ed4 --- /dev/null +++ b/commons/plans/2026-05-04-custom-feeds-testing-sheet.md @@ -0,0 +1,170 @@ +# 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) + +```bash +./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 + +```bash +# 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: +```kotlin +val feedRepository = remember { FeedDefinitionRepository(appScope) } +LaunchedEffect(Unit) { feedRepository.load(defaultFeeds()) } +``` + +2. In the `CompositionLocalProvider` block (around line 1166), add: +```kotlin +LocalFeedRepository provides feedRepository, +LocalFeedScope provides appScope, +``` + +3. 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 | diff --git a/commons/plans/2026-05-05-feed-builder-enhancements-plan.md b/commons/plans/2026-05-05-feed-builder-enhancements-plan.md new file mode 100644 index 000000000..de85496ef --- /dev/null +++ b/commons/plans/2026-05-05-feed-builder-enhancements-plan.md @@ -0,0 +1,93 @@ +# Feed Builder Enhancements + +**Date:** 2026-05-05 +**Status:** Implementation + +## Scope + +Enhance `FeedBuilderDialog` with 6 items: +1. npub auto-decode (paste npub -> hex + display name) +2. Author search (type name -> search LocalCache -> pick) +3. Kind filter checkboxes (Notes, Reposts, Articles) +4. Edit + Delete feeds (CRUD completeness) +5. "Save as Feed" button in search results +6. Exclude authors field in dialog + +## Implementation + +### 1. npub Auto-Decode + +**Where:** `FeedBuilderDialog.kt` ChipInputField for Authors + +**Logic:** +- On add: if input starts with `npub1`, decode via `decodePublicKeyAsHexOrNull()` +- If decode succeeds, add hex to authors list +- Show display name in chip (resolve from LocalCache) + +### 2. Author Search + +**Where:** New `AuthorSearchField` composable in `FeedBuilderDialog.kt` + +**Flow:** +- Replace plain text field for authors with search field +- Type >= 2 chars -> filter `DesktopLocalCache.users` by displayName/nip05 +- Show dropdown with matching profiles (avatar placeholder + name + npub short) +- Click adds hex to authors list +- Still allow raw npub/hex paste (falls through to auto-decode) + +**Data source:** `DesktopLocalCache.users` (already populated from metadata subscriptions) + +### 3. Kind Filter Checkboxes + +**Where:** `FeedBuilderDialog.kt`, new section between relays and exclude + +**UI:** FlowRow of FilterChips: +- [x] Notes (kind 1) +- [x] Reposts (kind 6, 16) +- [ ] Articles (kind 30023) +- [ ] Highlights (kind 9802) +- [ ] Reactions (kind 7) + +Default: Notes + Reposts checked. Maps to `FeedBuilderState.kinds`. + +### 4. Edit + Delete in Drawer + +**Where:** `FeedsDrawerTab.kt` + +**Edit:** Add edit button on each FeedRow -> opens `FeedBuilderDialog(initial = feed)` +- On save: calls `feedRepository.update(feed)` + +**Delete:** Add delete in context or as swipe action +- Confirm dialog: "Delete feed X?" +- Calls `feedRepository.delete(feed.id)` + +### 5. "Save as Feed" from Search + +**Where:** `SearchScreen.kt` or `SearchResultsList.kt` + +**UI:** Show "Save as Feed" button when `query.canBecomeFeed()` is true +- Button appears in the header area, next to the search bar actions +- Click opens `FeedBuilderDialog` pre-filled from `query.toFeedDefinition()` + +### 6. Exclude Authors in Dialog + +**Where:** `FeedBuilderDialog.kt` + +**UI:** Same chip input pattern as regular authors but for excludes +- Uses same author search/npub-decode logic +- Maps to `FeedBuilderState.excludeAuthors` + +## File Changes + +| File | Change | +|------|--------| +| `FeedBuilderDialog.kt` | Author search field, npub decode, kind checkboxes, exclude authors | +| `FeedsDrawerTab.kt` | Edit/delete buttons on feed rows | +| `SearchScreen.kt` | "Save as Feed" button | +| `FeedBuilderState.kt` | No changes needed (already has all fields) | + +## Dependencies + +- `decodePublicKeyAsHexOrNull` from `quartz/nip19Bech32` +- `DesktopLocalCache.users` for profile search +- `SearchQuery.canBecomeFeed()` + `toFeedDefinition()` already exist diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt new file mode 100644 index 000000000..9417caffc --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedBuilderState.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.feeds.custom + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.toImmutableList + +@Stable +class FeedBuilderState( + initial: FeedDefinition? = null, +) { + var name by mutableStateOf(initial?.name ?: "") + var emoji by mutableStateOf(initial?.emoji ?: "") + var refreshMode by mutableStateOf(initial?.refreshMode ?: RefreshMode.LIVE_STREAM) + + val hashtags = + mutableStateListOf().apply { + (initial?.source as? FeedSource.Filter)?.hashtags?.let { addAll(it) } + } + val authors = + mutableStateListOf().apply { + (initial?.source as? FeedSource.Filter)?.authors?.let { addAll(it) } + } + val relays = + mutableStateListOf().apply { + (initial?.source as? FeedSource.Filter)?.relays?.let { addAll(it) } + } + val excludeAuthors = + mutableStateListOf().apply { + (initial?.source as? FeedSource.Filter)?.excludeAuthors?.let { addAll(it) } + } + val excludeKeywords = + mutableStateListOf().apply { + (initial?.source as? FeedSource.Filter)?.excludeKeywords?.let { addAll(it) } + } + val kinds = + mutableStateListOf().apply { + (initial?.source as? FeedSource.Filter)?.kinds?.let { addAll(it) } + } + + val isValid: Boolean + get() = name.isNotBlank() && (hashtags.isNotEmpty() || authors.isNotEmpty() || relays.isNotEmpty()) + + private val editId: String? = initial?.id + + fun toDefinition(): FeedDefinition { + val source = + FeedSource.Filter( + hashtags = hashtags.toImmutableList(), + authors = authors.toImmutableList(), + relays = relays.toImmutableList(), + excludeAuthors = excludeAuthors.toImmutableList(), + excludeKeywords = excludeKeywords.toImmutableList(), + kinds = kinds.toImmutableList(), + ) + return FeedDefinition( + id = + editId ?: java.util.UUID + .randomUUID() + .toString(), + name = name, + emoji = emoji, + pinned = false, + pinOrder = Int.MAX_VALUE, + source = source, + refreshMode = refreshMode, + createdAt = System.currentTimeMillis() / 1000, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinition.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinition.kt new file mode 100644 index 000000000..281285dfb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinition.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.feeds.custom + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +data class FeedDefinition( + val id: String, + val name: String, + val emoji: String, + val pinned: Boolean, + val pinOrder: Int, + val source: FeedSource, + val refreshMode: RefreshMode, + val createdAt: Long, +) + +@Immutable +sealed interface FeedSource { + @Immutable + data class Filter( + val hashtags: ImmutableList = persistentListOf(), + val authors: ImmutableList = persistentListOf(), + val relays: ImmutableList = persistentListOf(), + val excludeAuthors: ImmutableList = persistentListOf(), + val excludeKeywords: ImmutableList = persistentListOf(), + val kinds: ImmutableList = persistentListOf(), + ) : FeedSource + + @Immutable + data class PeopleList( + val kind: Int, + val pubkey: HexKey, + val dTag: String, + ) : FeedSource + + @Immutable + data class InterestSet( + val kind: Int, + val pubkey: HexKey, + val dTag: String, + ) : FeedSource + + @Immutable + data class DVM( + val kind: Int, + val pubkey: HexKey, + val dTag: String, + ) : FeedSource + + @Immutable + data class SingleRelay( + val url: String, + ) : FeedSource + + @Immutable + data object Global : FeedSource + + @Immutable + data object Following : FeedSource +} + +enum class RefreshMode { + LIVE_STREAM, + POLL_5MIN, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt new file mode 100644 index 000000000..58d72f665 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionBuilder.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.feeds.custom + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.toImmutableList + +class FeedDefinitionBuilder { + var name: String = "" + var emoji: String = "" + var refreshMode: RefreshMode = RefreshMode.LIVE_STREAM + private var source: FeedSource? = null + + fun filter(init: FilterBuilder.() -> Unit) { + source = FilterBuilder().apply(init).build() + } + + fun fromPeopleList( + kind: Int = 30000, + pubkey: HexKey, + dTag: String, + ) { + source = FeedSource.PeopleList(kind, pubkey, dTag) + } + + fun fromDvm( + kind: Int = 31990, + pubkey: HexKey, + dTag: String, + ) { + source = FeedSource.DVM(kind, pubkey, dTag) + } + + fun fromRelay(url: String) { + source = FeedSource.SingleRelay(url) + } + + fun global() { + source = FeedSource.Global + } + + fun following() { + source = FeedSource.Following + } + + fun build(): FeedDefinition = + FeedDefinition( + id = generateId(), + name = name, + emoji = emoji, + pinned = false, + pinOrder = Int.MAX_VALUE, + source = source ?: error("FeedDefinition requires a source"), + refreshMode = refreshMode, + createdAt = System.currentTimeMillis() / 1000, + ) + + private fun generateId(): String = + java.util.UUID + .randomUUID() + .toString() +} + +class FilterBuilder { + val hashtags = mutableListOf() + val authors = mutableListOf() + val relays = mutableListOf() + val excludeAuthors = mutableListOf() + val excludeKeywords = mutableListOf() + val kinds = mutableListOf() + + fun build(): FeedSource.Filter = + FeedSource.Filter( + hashtags = hashtags.toImmutableList(), + authors = authors.toImmutableList(), + relays = relays.toImmutableList(), + excludeAuthors = excludeAuthors.toImmutableList(), + excludeKeywords = excludeKeywords.toImmutableList(), + kinds = kinds.toImmutableList(), + ) +} + +inline fun feedDefinition(init: FeedDefinitionBuilder.() -> Unit): FeedDefinition = FeedDefinitionBuilder().apply(init).build() + +fun defaultFeeds(): List = + listOf( + FeedDefinition( + id = "default-following", + name = "Following", + emoji = "\uD83C\uDFE0", + pinned = true, + pinOrder = 0, + source = FeedSource.Following, + refreshMode = RefreshMode.LIVE_STREAM, + createdAt = 0L, + ), + FeedDefinition( + id = "default-global", + name = "Global", + emoji = "\uD83C\uDF10", + pinned = true, + pinOrder = 1, + source = FeedSource.Global, + refreshMode = RefreshMode.LIVE_STREAM, + createdAt = 0L, + ), + ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionRepository.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionRepository.kt new file mode 100644 index 000000000..f42389b58 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionRepository.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.feeds.custom + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +const val MAX_PINNED_FEEDS = 3 + +@Stable +class FeedDefinitionRepository( + private val scope: CoroutineScope, +) { + private val _feeds = MutableStateFlow>(persistentListOf()) + val feeds: StateFlow> = _feeds.asStateFlow() + + val groupedFeeds: StateFlow = + _feeds + .map { all -> + GroupedFeeds( + pinned = all.filter { it.pinned }.sortedBy { it.pinOrder }.toImmutableList(), + myFeeds = all.filter { !it.pinned && it.source !is FeedSource.DVM }.toImmutableList(), + algoFeeds = all.filter { it.source is FeedSource.DVM }.toImmutableList(), + ) + }.distinctUntilChanged() + .stateIn(scope, SharingStarted.Eagerly, GroupedFeeds.EMPTY) + + val pinnedFeeds: StateFlow> = + groupedFeeds + .map { it.pinned } + .distinctUntilChanged() + .stateIn(scope, SharingStarted.Eagerly, persistentListOf()) + + private val _events = MutableSharedFlow(replay = 0) + val events: SharedFlow = _events.asSharedFlow() + + fun load(feeds: List) { + _feeds.value = feeds.toImmutableList() + } + + fun snapshot(): List = _feeds.value + + suspend fun add(feed: FeedDefinition) { + _feeds.value = (_feeds.value + feed).toImmutableList() + _events.emit(FeedEvent.Created(feed)) + } + + suspend fun update(feed: FeedDefinition) { + _feeds.value = + _feeds.value + .map { if (it.id == feed.id) feed else it } + .toImmutableList() + } + + suspend fun delete(id: String) { + _feeds.value = _feeds.value.filter { it.id != id }.toImmutableList() + } + + suspend fun pin(id: String): Boolean { + val currentPinned = _feeds.value.count { it.pinned } + if (currentPinned >= MAX_PINNED_FEEDS) { + _events.emit(FeedEvent.PinLimitReached(MAX_PINNED_FEEDS)) + return false + } + _feeds.value = + _feeds.value + .map { + if (it.id == id) it.copy(pinned = true, pinOrder = currentPinned) else it + }.toImmutableList() + return true + } + + suspend fun unpin(id: String) { + _feeds.value = + _feeds.value + .map { if (it.id == id) it.copy(pinned = false, pinOrder = Int.MAX_VALUE) else it } + .toImmutableList() + // Reindex remaining pinned + reindexPinned() + } + + suspend fun reorderPinned( + fromIndex: Int, + toIndex: Int, + ) { + val pinned = + _feeds.value + .filter { it.pinned } + .sortedBy { it.pinOrder } + .toMutableList() + if (fromIndex !in pinned.indices || toIndex !in pinned.indices) return + val item = pinned.removeAt(fromIndex) + pinned.add(toIndex, item) + val reindexed = pinned.mapIndexed { i, feed -> feed.copy(pinOrder = i) }.associateBy { it.id } + _feeds.value = + _feeds.value + .map { reindexed[it.id] ?: it } + .toImmutableList() + } + + private fun reindexPinned() { + val pinned = _feeds.value.filter { it.pinned }.sortedBy { it.pinOrder } + val reindexed = pinned.mapIndexed { i, feed -> feed.copy(pinOrder = i) }.associateBy { it.id } + _feeds.value = + _feeds.value + .map { reindexed[it.id] ?: it } + .toImmutableList() + } +} + +sealed interface FeedEvent { + data class Created( + val feed: FeedDefinition, + ) : FeedEvent + + data class PinLimitReached( + val max: Int, + ) : FeedEvent +} + +@Immutable +data class GroupedFeeds( + val pinned: ImmutableList, + val myFeeds: ImmutableList, + val algoFeeds: ImmutableList, +) { + companion object { + val EMPTY = GroupedFeeds(persistentListOf(), persistentListOf(), persistentListOf()) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt new file mode 100644 index 000000000..397491563 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.feeds.custom + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import kotlinx.collections.immutable.toImmutableList + +object FeedDefinitionSerializer { + private val mapper = ObjectMapper() + + fun serializeList(feeds: List): String { + val array = mapper.createArrayNode() + feeds.forEach { feed -> array.add(serializeFeed(feed)) } + return mapper.writeValueAsString(array) + } + + fun deserializeList(json: String): List { + if (json.isBlank()) return emptyList() + val array = mapper.readTree(json) as? ArrayNode ?: return emptyList() + return array.mapNotNull { node -> deserializeFeed(node) } + } + + private fun serializeFeed(feed: FeedDefinition): ObjectNode = + mapper.createObjectNode().apply { + put("id", feed.id) + put("name", feed.name) + put("emoji", feed.emoji) + put("pinned", feed.pinned) + put("pinOrder", feed.pinOrder) + put("refreshMode", feed.refreshMode.name) + put("createdAt", feed.createdAt) + set("source", serializeSource(feed.source)) + } + + private fun deserializeFeed(node: JsonNode): FeedDefinition? { + val id = node.get("id")?.asText() ?: return null + val name = node.get("name")?.asText() ?: return null + val emoji = node.get("emoji")?.asText() ?: "" + val pinned = node.get("pinned")?.asBoolean() ?: false + val pinOrder = node.get("pinOrder")?.asInt() ?: Int.MAX_VALUE + val refreshMode = + node.get("refreshMode")?.asText()?.let { + try { + RefreshMode.valueOf(it) + } catch (_: Exception) { + RefreshMode.LIVE_STREAM + } + } ?: RefreshMode.LIVE_STREAM + val createdAt = node.get("createdAt")?.asLong() ?: 0L + val source = node.get("source")?.let { deserializeSource(it) } ?: return null + + return FeedDefinition( + id = id, + name = name, + emoji = emoji, + pinned = pinned, + pinOrder = pinOrder, + source = source, + refreshMode = refreshMode, + createdAt = createdAt, + ) + } + + private fun serializeSource(source: FeedSource): ObjectNode = + mapper.createObjectNode().apply { + when (source) { + is FeedSource.Filter -> { + put("type", "filter") + set("hashtags", mapper.valueToTree(source.hashtags.toList())) + set("authors", mapper.valueToTree(source.authors.toList())) + set("relays", mapper.valueToTree(source.relays.toList())) + set("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList())) + set("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList())) + set("kinds", mapper.valueToTree(source.kinds.toList())) + } + + is FeedSource.PeopleList -> { + put("type", "people_list") + put("kind", source.kind) + put("pubkey", source.pubkey) + put("dTag", source.dTag) + } + + is FeedSource.InterestSet -> { + put("type", "interest_set") + put("kind", source.kind) + put("pubkey", source.pubkey) + put("dTag", source.dTag) + } + + is FeedSource.DVM -> { + put("type", "dvm") + put("kind", source.kind) + put("pubkey", source.pubkey) + put("dTag", source.dTag) + } + + is FeedSource.SingleRelay -> { + put("type", "single_relay") + put("url", source.url) + } + + FeedSource.Global -> { + put("type", "global") + } + + FeedSource.Following -> { + put("type", "following") + } + } + } + + private fun deserializeSource(node: JsonNode): FeedSource? { + val type = node.get("type")?.asText() ?: return null + return when (type) { + "filter" -> { + FeedSource.Filter( + hashtags = node.get("hashtags")?.map { it.asText() }?.toImmutableList() ?: return null, + authors = node.get("authors")?.map { it.asText() }?.toImmutableList() ?: return null, + relays = node.get("relays")?.map { it.asText() }?.toImmutableList() ?: return null, + excludeAuthors = node.get("excludeAuthors")?.map { it.asText() }?.toImmutableList() ?: return null, + excludeKeywords = node.get("excludeKeywords")?.map { it.asText() }?.toImmutableList() ?: return null, + kinds = node.get("kinds")?.map { it.asInt() }?.toImmutableList() ?: return null, + ) + } + + "people_list" -> { + FeedSource.PeopleList( + kind = node.get("kind")?.asInt() ?: 30000, + pubkey = node.get("pubkey")?.asText() ?: return null, + dTag = node.get("dTag")?.asText() ?: return null, + ) + } + + "interest_set" -> { + FeedSource.InterestSet( + kind = node.get("kind")?.asInt() ?: 30015, + pubkey = node.get("pubkey")?.asText() ?: return null, + dTag = node.get("dTag")?.asText() ?: return null, + ) + } + + "dvm" -> { + FeedSource.DVM( + kind = node.get("kind")?.asInt() ?: 31990, + pubkey = node.get("pubkey")?.asText() ?: return null, + dTag = node.get("dTag")?.asText() ?: return null, + ) + } + + "single_relay" -> { + FeedSource.SingleRelay( + url = node.get("url")?.asText() ?: return null, + ) + } + + "global" -> { + FeedSource.Global + } + + "following" -> { + FeedSource.Following + } + + else -> { + null + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/SearchQueryToFeed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/SearchQueryToFeed.kt new file mode 100644 index 000000000..068ad65dc --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/SearchQueryToFeed.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.feeds.custom + +import com.vitorpamplona.amethyst.commons.search.SearchQuery + +fun SearchQuery.toFeedDefinition( + name: String, + emoji: String = "", +): FeedDefinition = + feedDefinition { + this.name = name + this.emoji = emoji + filter { + hashtags += this@toFeedDefinition.hashtags + authors += this@toFeedDefinition.authors + excludeKeywords += this@toFeedDefinition.excludeTerms + kinds += this@toFeedDefinition.kinds + } + } + +fun SearchQuery.canBecomeFeed(): Boolean = hashtags.isNotEmpty() || authors.isNotEmpty() || kinds.isNotEmpty() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/UserSearchEngine.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/UserSearchEngine.kt new file mode 100644 index 000000000..d094da60a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/UserSearchEngine.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Delegate for searching users via relays (NIP-50 or other mechanism). + * Platform-specific: desktop uses relay manager, Android could use its own. + */ +interface RelayUserSearchDelegate { + fun searchPeople( + query: String, + limit: Int, + onResult: (User) -> Unit, + onComplete: () -> Unit, + ): Job +} + +/** + * Reusable user search engine that combines local cache search with optional relay search. + * Platform-agnostic — lives in commons, relay interaction via delegate. + * + * Usage: + * ``` + * val engine = UserSearchEngine(cache, scope) + * engine.relayDelegate = myRelayDelegate // optional + * engine.search("fiatjaf") + * // collect engine.results, engine.isSearching + * ``` + */ +class UserSearchEngine( + private val cache: ICacheProvider, + private val scope: CoroutineScope, + private val debounceMs: Long = 300L, + private val localLimit: Int = 10, + private val relayLimit: Int = 10, +) { + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + private val _localResults = MutableStateFlow>(emptyList()) + val localResults: StateFlow> = _localResults.asStateFlow() + + private val _relayResults = MutableStateFlow>(emptyList()) + val relayResults: StateFlow> = _relayResults.asStateFlow() + + private val _isSearching = MutableStateFlow(false) + val isSearching: StateFlow = _isSearching.asStateFlow() + + var relayDelegate: RelayUserSearchDelegate? = null + + private var relaySearchJob: Job? = null + + init { + setupDebouncedSearch() + } + + fun search(text: String) { + _query.value = text + _relayResults.value = emptyList() + relaySearchJob?.cancel() + + if (text.length < 2) { + _localResults.value = emptyList() + _isSearching.value = false + } + } + + fun clear() = search("") + + /** + * Resolves an input string to a hex pubkey. + * Handles npub, nprofile, and raw hex. + */ + fun resolveToHex(input: String): String = decodePublicKeyAsHexOrNull(input.trim()) ?: input.trim() + + @OptIn(FlowPreview::class) + private fun setupDebouncedSearch() { + _query + .debounce(debounceMs) + .onEach { text -> + if (text.length >= 2) { + // Local cache search (instant) + _localResults.value = cache.findUsersStartingWith(text, localLimit) + + // Relay search (async, via delegate) + startRelaySearch(text) + } else { + _localResults.value = emptyList() + _isSearching.value = false + } + }.launchIn(scope) + } + + private fun startRelaySearch(text: String) { + val delegate = relayDelegate ?: return + relaySearchJob?.cancel() + _isSearching.value = true + _relayResults.value = emptyList() + + relaySearchJob = + delegate.searchPeople( + query = text, + limit = relayLimit, + onResult = { user -> + // Deduplicate against local results and existing relay results + val isDuplicate = + _localResults.value.any { it.pubkeyHex == user.pubkeyHex } || + _relayResults.value.any { it.pubkeyHex == user.pubkeyHex } + if (!isDuplicate) { + _relayResults.value = _relayResults.value + user + } + }, + onComplete = { + _isSearching.value = false + }, + ) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt new file mode 100644 index 000000000..0badc90af --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.feeds.custom + +import kotlinx.collections.immutable.persistentListOf +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FeedDefinitionSerializerTest { + @Test + fun roundTripFilterSource() { + val feed = + FeedDefinition( + id = "test-1", + name = "Bitcoin", + emoji = "\u20BF", + pinned = true, + pinOrder = 0, + source = + FeedSource.Filter( + hashtags = persistentListOf("bitcoin", "btc"), + authors = persistentListOf("abc123"), + relays = persistentListOf("wss://relay.damus.io"), + excludeAuthors = persistentListOf("spammer"), + excludeKeywords = persistentListOf("scam"), + kinds = persistentListOf(1, 6), + ), + refreshMode = RefreshMode.LIVE_STREAM, + createdAt = 1000L, + ) + + val json = FeedDefinitionSerializer.serializeList(listOf(feed)) + val deserialized = FeedDefinitionSerializer.deserializeList(json) + + assertEquals(1, deserialized.size) + assertEquals(feed, deserialized[0]) + } + + @Test + fun roundTripGlobalSource() { + val feed = + FeedDefinition( + id = "test-global", + name = "Global", + emoji = "\uD83C\uDF10", + pinned = true, + pinOrder = 1, + source = FeedSource.Global, + refreshMode = RefreshMode.LIVE_STREAM, + createdAt = 2000L, + ) + + val json = FeedDefinitionSerializer.serializeList(listOf(feed)) + val deserialized = FeedDefinitionSerializer.deserializeList(json) + + assertEquals(1, deserialized.size) + assertEquals(feed, deserialized[0]) + } + + @Test + fun roundTripFollowingSource() { + val feed = + FeedDefinition( + id = "test-following", + name = "Following", + emoji = "\uD83C\uDFE0", + pinned = false, + pinOrder = Int.MAX_VALUE, + source = FeedSource.Following, + refreshMode = RefreshMode.LIVE_STREAM, + createdAt = 3000L, + ) + + val json = FeedDefinitionSerializer.serializeList(listOf(feed)) + val deserialized = FeedDefinitionSerializer.deserializeList(json) + + assertEquals(feed, deserialized[0]) + } + + @Test + fun roundTripDvmSource() { + val feed = + FeedDefinition( + id = "test-dvm", + name = "Trending", + emoji = "\uD83D\uDD25", + pinned = true, + pinOrder = 2, + source = FeedSource.DVM(kind = 31990, pubkey = "dvmpub123", dTag = "trending"), + refreshMode = RefreshMode.POLL_5MIN, + createdAt = 4000L, + ) + + val json = FeedDefinitionSerializer.serializeList(listOf(feed)) + val deserialized = FeedDefinitionSerializer.deserializeList(json) + + assertEquals(feed, deserialized[0]) + } + + @Test + fun roundTripPeopleListSource() { + val feed = + FeedDefinition( + id = "test-people", + name = "Dev Friends", + emoji = "\uD83D\uDC65", + pinned = false, + pinOrder = Int.MAX_VALUE, + source = FeedSource.PeopleList(kind = 30000, pubkey = "mypub", dTag = "devs"), + refreshMode = RefreshMode.LIVE_STREAM, + createdAt = 5000L, + ) + + val json = FeedDefinitionSerializer.serializeList(listOf(feed)) + val deserialized = FeedDefinitionSerializer.deserializeList(json) + + assertEquals(feed, deserialized[0]) + } + + @Test + fun roundTripInterestSetSource() { + val feed = + FeedDefinition( + id = "test-interest", + name = "Nostr Dev", + emoji = "\uD83D\uDEE0", + pinned = false, + pinOrder = Int.MAX_VALUE, + source = FeedSource.InterestSet(kind = 30015, pubkey = "mypub", dTag = "nostrdev"), + refreshMode = RefreshMode.LIVE_STREAM, + createdAt = 6000L, + ) + + val json = FeedDefinitionSerializer.serializeList(listOf(feed)) + val deserialized = FeedDefinitionSerializer.deserializeList(json) + + assertEquals(feed, deserialized[0]) + } + + @Test + fun roundTripSingleRelaySource() { + val feed = + FeedDefinition( + id = "test-relay", + name = "Damus Relay", + emoji = "\uD83D\uDCE1", + pinned = false, + pinOrder = Int.MAX_VALUE, + source = FeedSource.SingleRelay(url = "wss://relay.damus.io"), + refreshMode = RefreshMode.LIVE_STREAM, + createdAt = 7000L, + ) + + val json = FeedDefinitionSerializer.serializeList(listOf(feed)) + val deserialized = FeedDefinitionSerializer.deserializeList(json) + + assertEquals(feed, deserialized[0]) + } + + @Test + fun multipleFeeds() { + val feeds = + listOf( + feedDefinition { + name = "Bitcoin" + emoji = "\u20BF" + filter { + hashtags += "bitcoin" + kinds += 1 + } + }, + feedDefinition { + name = "Lightning" + emoji = "\u26A1" + filter { + hashtags += "lightning" + hashtags += "ln" + } + }, + ) + + val json = FeedDefinitionSerializer.serializeList(feeds) + val deserialized = FeedDefinitionSerializer.deserializeList(json) + + assertEquals(2, deserialized.size) + assertEquals("Bitcoin", deserialized[0].name) + assertEquals("Lightning", deserialized[1].name) + } + + @Test + fun emptyJsonReturnsEmptyList() { + assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList("")) + assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" ")) + } + + @Test + fun defaultFeedsAreValid() { + val defaults = defaultFeeds() + assertEquals(2, defaults.size) + assertTrue(defaults[0].pinned) + assertTrue(defaults[1].pinned) + assertEquals(FeedSource.Following, defaults[0].source) + assertEquals(FeedSource.Global, defaults[1].source) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/SearchQueryToFeedTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/SearchQueryToFeedTest.kt new file mode 100644 index 000000000..34c09d20c --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/SearchQueryToFeedTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.feeds.custom + +import com.vitorpamplona.amethyst.commons.search.SearchQuery +import kotlinx.collections.immutable.persistentListOf +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SearchQueryToFeedTest { + @Test + fun convertsHashtagsToFeedFilter() { + val query = + SearchQuery( + hashtags = persistentListOf("bitcoin", "nostr"), + ) + + val feed = query.toFeedDefinition(name = "BTC + Nostr", emoji = "\u20BF") + + assertEquals("BTC + Nostr", feed.name) + assertEquals("\u20BF", feed.emoji) + val source = feed.source as FeedSource.Filter + assertEquals(listOf("bitcoin", "nostr"), source.hashtags) + } + + @Test + fun convertsAuthorsToFeedFilter() { + val query = + SearchQuery( + authors = persistentListOf("abc123", "def456"), + ) + + val feed = query.toFeedDefinition(name = "Devs") + val source = feed.source as FeedSource.Filter + assertEquals(listOf("abc123", "def456"), source.authors) + } + + @Test + fun convertsExcludeTerms() { + val query = + SearchQuery( + hashtags = persistentListOf("bitcoin"), + excludeTerms = persistentListOf("scam", "spam"), + ) + + val feed = query.toFeedDefinition(name = "Clean BTC") + val source = feed.source as FeedSource.Filter + assertEquals(listOf("scam", "spam"), source.excludeKeywords) + } + + @Test + fun convertsKinds() { + val query = + SearchQuery( + kinds = persistentListOf(1, 30023), + hashtags = persistentListOf("dev"), + ) + + val feed = query.toFeedDefinition(name = "Dev Articles") + val source = feed.source as FeedSource.Filter + assertEquals(listOf(1, 30023), source.kinds) + } + + @Test + fun canBecomeFeedWithHashtags() { + assertTrue(SearchQuery(hashtags = persistentListOf("btc")).canBecomeFeed()) + } + + @Test + fun canBecomeFeedWithAuthors() { + assertTrue(SearchQuery(authors = persistentListOf("abc")).canBecomeFeed()) + } + + @Test + fun canNotBecomeFeedEmpty() { + assertFalse(SearchQuery.EMPTY.canBecomeFeed()) + } + + @Test + fun canNotBecomeFeedTextOnly() { + assertFalse(SearchQuery(text = "hello").canBecomeFeed()) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index c5039e8ba..2de05e998 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -690,6 +690,10 @@ fun App( return // Nothing below runs until Tor is Active } + var appDrawerInitialTab by remember { + mutableStateOf(null) + } + val localCache = remember { DesktopLocalCache() } val accountState by accountManager.accountState.collectAsState() val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } @@ -872,149 +876,163 @@ fun App( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, ) { - when (accountState) { - is AccountState.LoggedOut -> { - LoginScreen( - accountManager = accountManager, - onLoginSuccess = { - // Start heartbeat if bunker account - val current = accountManager.currentAccount() - if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) { - accountManager.startHeartbeat(scope) - } - // Ensure account is in multi-account storage + refresh list - scope.launch(Dispatchers.IO) { - accountManager.ensureCurrentAccountInStorage() - accountManager.refreshAccountList() - } - }, - ) - } - - is AccountState.ConnectingRelays -> { - val relays by relayManager.relayStatuses.collectAsState() - ConnectingRelaysScreen( - subtitle = "Restoring remote signer session", - relayStatuses = relays, - ) - } - - is AccountState.LoggedIn -> { - val account = accountState as AccountState.LoggedIn - val nwcConnection by accountManager.nwcConnection.collectAsState() - - // Load NWC connection on first composition - LaunchedEffect(Unit) { - accountManager.loadNwcConnection() - } - - val currentTorStatus = torManager.status.collectAsState().value - androidx.compose.runtime.CompositionLocalProvider( - com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides - com.vitorpamplona.amethyst.desktop.ui.tor.TorState( - status = currentTorStatus, - settings = torSettings, - onSettingsChanged = { newSettings -> - torSettings = newSettings - com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences - .save(newSettings) - torTypeFlow.value = newSettings.torType - externalPortFlow.value = newSettings.externalSocksPort - // Rebuild app to apply Tor changes - onRestartApp() - }, - ), - ) { - MainContent( - layoutMode = layoutMode, - deckState = deckState, - workspaceManager = workspaceManager, - singlePaneState = singlePaneState, - pinnedNavBarState = pinnedNavBarState, - relayManager = relayManager, - localCache = localCache, + CompositionLocalProvider( + com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, + com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, + ) { + when (accountState) { + is AccountState.LoggedOut -> { + LoginScreen( accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - nip11Fetcher = nip11Fetcher, - appScope = scope, - torStatus = currentTorStatus, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onShowAppDrawer = onShowAppDrawer, - ) - } - - // Compose dialog - if (showComposeDialog) { - ComposeNoteDialog( - onDismiss = onDismissComposeDialog, - relayManager = relayManager, - account = account, - replyTo = replyToNote, - ) - } - - // App Drawer overlay - if (showAppDrawer) { - val openColumns by deckState.columns.collectAsState() - AppDrawer( - openColumnTypes = - if (layoutMode == LayoutMode.DECK) { - openColumns.map { it.type.typeKey() }.toSet() - } else { - emptySet() - }, - pinnedNavBarState = pinnedNavBarState, - workspaceManager = workspaceManager, - onSwitchWorkspace = { ws -> - // Switch layout mode to match workspace - onLayoutModeChange(ws.layoutMode) - // Load columns or single pane screen - when (ws.layoutMode) { - LayoutMode.DECK -> { - deckState.loadFromWorkspace(ws.columns) - } - - LayoutMode.SINGLE_PANE -> { - // Load nav bar from workspace + navigate to first screen - pinnedNavBarState.loadFromWorkspace() - val firstKey = - ws.singlePaneScreens.firstOrNull() ?: "home" - val type = DeckState.parseColumnTypeFromKey(firstKey) - if (type != null) singlePaneState.navigate(type) - } + onLoginSuccess = { + // Start heartbeat if bunker account + val current = accountManager.currentAccount() + if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) { + accountManager.startHeartbeat(scope) + } + // Ensure account is in multi-account storage + refresh list + scope.launch(Dispatchers.IO) { + accountManager.ensureCurrentAccountInStorage() + accountManager.refreshAccountList() } }, - onSelectScreen = { type -> - when (layoutMode) { - LayoutMode.DECK -> { - if (deckState.hasColumnOfType(type)) { - deckState.focusExistingColumn(type) - } else { - deckState.addColumn(type) + ) + } + + is AccountState.ConnectingRelays -> { + val relays by relayManager.relayStatuses.collectAsState() + ConnectingRelaysScreen( + subtitle = "Restoring remote signer session", + relayStatuses = relays, + ) + } + + is AccountState.LoggedIn -> { + val account = accountState as AccountState.LoggedIn + val nwcConnection by accountManager.nwcConnection.collectAsState() + + // Load NWC connection on first composition + LaunchedEffect(Unit) { + accountManager.loadNwcConnection() + } + + val currentTorStatus = torManager.status.collectAsState().value + androidx.compose.runtime.CompositionLocalProvider( + com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides + com.vitorpamplona.amethyst.desktop.ui.tor.TorState( + status = currentTorStatus, + settings = torSettings, + onSettingsChanged = { newSettings -> + torSettings = newSettings + com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences + .save(newSettings) + torTypeFlow.value = newSettings.torType + externalPortFlow.value = newSettings.externalSocksPort + // Rebuild app to apply Tor changes + onRestartApp() + }, + ), + ) { + MainContent( + layoutMode = layoutMode, + deckState = deckState, + workspaceManager = workspaceManager, + singlePaneState = singlePaneState, + pinnedNavBarState = pinnedNavBarState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + nip11Fetcher = nip11Fetcher, + appScope = scope, + torStatus = currentTorStatus, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onShowAppDrawer = onShowAppDrawer, + onOpenFeedsDrawer = { + appDrawerInitialTab = + com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS + onShowAppDrawer() + }, + ) + } + + // Compose dialog + if (showComposeDialog) { + ComposeNoteDialog( + onDismiss = onDismissComposeDialog, + relayManager = relayManager, + account = account, + replyTo = replyToNote, + ) + } + + // App Drawer overlay + if (showAppDrawer) { + val openColumns by deckState.columns.collectAsState() + AppDrawer( + initialTab = appDrawerInitialTab, + openColumnTypes = + if (layoutMode == LayoutMode.DECK) { + openColumns.map { it.type.typeKey() }.toSet() + } else { + emptySet() + }, + pinnedNavBarState = pinnedNavBarState, + workspaceManager = workspaceManager, + onSwitchWorkspace = { ws -> + // Switch layout mode to match workspace + onLayoutModeChange(ws.layoutMode) + // Load columns or single pane screen + when (ws.layoutMode) { + LayoutMode.DECK -> { + deckState.loadFromWorkspace(ws.columns) + } + + LayoutMode.SINGLE_PANE -> { + // Load nav bar from workspace + navigate to first screen + pinnedNavBarState.loadFromWorkspace() + val firstKey = + ws.singlePaneScreens.firstOrNull() ?: "home" + val type = DeckState.parseColumnTypeFromKey(firstKey) + if (type != null) singlePaneState.navigate(type) } } + }, + onSelectScreen = { type -> + when (layoutMode) { + LayoutMode.DECK -> { + if (deckState.hasColumnOfType(type)) { + deckState.focusExistingColumn(type) + } else { + deckState.addColumn(type) + } + } - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(type) + LayoutMode.SINGLE_PANE -> { + singlePaneState.navigate(type) + } } - } - }, - onDismiss = onDismissAppDrawer, - ) + }, + onDismiss = { + appDrawerInitialTab = null + onDismissAppDrawer() + }, + ) + } } } - } - // Force logout dialog overlay - val forceLogoutReason by accountManager.forceLogoutReason.collectAsState() - forceLogoutReason?.let { reason -> - ForceLogoutDialog( - reason = reason, - onDismiss = { accountManager.clearForceLogoutReason() }, - ) + // Force logout dialog overlay + val forceLogoutReason by accountManager.forceLogoutReason.collectAsState() + forceLogoutReason?.let { reason -> + ForceLogoutDialog( + reason = reason, + onDismiss = { accountManager.clearForceLogoutReason() }, + ) + } } } } @@ -1040,6 +1058,7 @@ fun MainContent( onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onShowAppDrawer: () -> Unit, + onOpenFeedsDrawer: () -> Unit = onShowAppDrawer, ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -1230,6 +1249,8 @@ fun MainContent( CompositionLocalProvider( LocalRelayCategories provides relayCategories, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, + com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, + com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, ) { Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { @@ -1252,6 +1273,7 @@ fun MainContent( singlePaneState = singlePaneState, pinnedNavBarState = pinnedNavBarState, onOpenAppDrawer = onShowAppDrawer, + onOpenFeedsDrawer = onOpenFeedsDrawer, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index 04ab81356..b4e84d361 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.feeds +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder @@ -102,6 +103,60 @@ class DesktopFollowingFeedFilter( override fun limit(): Int = 2500 } +/** + * Custom feed filter: matches events based on FeedSource.Filter criteria. + * Excludes are applied client-side (relay can't express NOT filters). + */ +class DesktopCustomFeedFilter( + private val cache: DesktopLocalCache, + private val feedId: String, + private val source: FeedSource.Filter, +) : AdditiveFeedFilter() { + override fun feedKey(): String = "custom-$feedId" + + private fun matchesSource(note: Note): Boolean { + val event = note.event ?: return false + if (!isFeedNote(event)) return false + + // Kind filter + if (source.kinds.isNotEmpty() && event.kind !in source.kinds) return false + + // Author filter (if specified, note must be from one of these authors) + if (source.authors.isNotEmpty() && note.author?.pubkeyHex !in source.authors) return false + + // Hashtag filter (if specified, event must contain at least one) + if (source.hashtags.isNotEmpty()) { + val eventTags = + event.tags + .filter { it.size >= 2 && it[0] == "t" } + .map { it[1].lowercase() } + if (source.hashtags.none { it.lowercase() in eventTags }) return false + } + + // Exclusions + if (source.excludeAuthors.isNotEmpty() && note.author?.pubkeyHex in source.excludeAuthors) return false + if (source.excludeKeywords.isNotEmpty()) { + val content = event.content.lowercase() + if (source.excludeKeywords.any { content.contains(it.lowercase()) }) return false + } + + return true + } + + override fun feed(): List = + cache.notes + .filterIntoSet { _, note -> matchesSource(note) } + .sortedWith(DefaultFeedOrder) + .deduplicateReposts() + .take(limit()) + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { matchesSource(it) } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() + + override fun limit(): Int = 2500 +} + /** * Thread feed: root note + all replies (graph walk via Note.replies). */ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/search/DesktopRelayUserSearchDelegate.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/search/DesktopRelayUserSearchDelegate.kt new file mode 100644 index 000000000..a58d2bad7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/search/DesktopRelayUserSearchDelegate.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.search + +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.search.RelayUserSearchDelegate +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders +import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class DesktopRelayUserSearchDelegate( + private val relayManager: RelayConnectionManager, + private val localCache: DesktopLocalCache, + private val searchRelays: () -> Set, + private val scope: CoroutineScope, +) : RelayUserSearchDelegate { + private var currentSubId: String? = null + + override fun searchPeople( + query: String, + limit: Int, + onResult: (User) -> Unit, + onComplete: () -> Unit, + ): Job = + scope.launch { + // Cancel previous subscription + currentSubId?.let { relayManager.unsubscribe(it) } + + val relays = searchRelays() + if (relays.isEmpty()) { + onComplete() + return@launch + } + + val subId = generateSubId("author-search") + currentSubId = subId + + relayManager.subscribe( + subId = subId, + filters = listOf(FilterBuilders.searchPeople(query, limit)), + relays = relays, + listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + val user = localCache.getUserIfExists(event.pubKey) + if (user != null) { + onResult(user) + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onComplete() + } + }, + ) + + // Timeout after 8 seconds + delay(8000) + relayManager.unsubscribe(subId) + onComplete() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index 589e500cf..f27a843bb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.desktop.subscriptions +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer /** * Feed mode for feed subscriptions. @@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl enum class FeedMode { GLOBAL, FOLLOWING, + CUSTOM, } /** @@ -362,3 +365,77 @@ fun createChessSubscription( onEvent = onEvent, onEose = onEose, ) + +/** + * Creates a subscription config for a custom feed based on FeedSource.Filter. + */ +fun createCustomFeedSubscription( + source: FeedSource.Filter, + relays: Set, + limit: Int = 200, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + val filters = mutableListOf() + + val kinds = source.kinds.ifEmpty { listOf(1, 6, 16) } + + when { + source.authors.isNotEmpty() && source.hashtags.isNotEmpty() -> { + // Authors + hashtags: two separate filters (relay does OR between filters) + filters.add( + Filter( + kinds = kinds, + authors = source.authors.toList(), + limit = limit, + ), + ) + filters.add( + Filter( + kinds = kinds, + tags = mapOf("t" to source.hashtags.toList()), + limit = limit, + ), + ) + } + + source.authors.isNotEmpty() -> { + filters.add( + Filter( + kinds = kinds, + authors = source.authors.toList(), + limit = limit, + ), + ) + } + + source.hashtags.isNotEmpty() -> { + filters.add( + Filter( + kinds = kinds, + tags = mapOf("t" to source.hashtags.toList()), + limit = limit, + ), + ) + } + + else -> { + return null + } + } + + if (filters.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("custom-feed"), + filters = filters, + relays = + if (source.relays.isNotEmpty()) { + source.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + } else { + relays + }, + onEvent = onEvent, + onEose = onEose, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/CustomFeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/CustomFeedScreen.kt new file mode 100644 index 000000000..b9514a1e3 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/CustomFeedScreen.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun CustomFeedScreen( + feedId: String, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onZapFeedback: (ZapFeedback) -> Unit = {}, +) { + val feedRepository = LocalFeedRepository.current + val feeds by feedRepository.feeds.collectAsState() + val feedDef = feeds.firstOrNull { it.id == feedId } + + if (feedDef == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Feed not found", style = MaterialTheme.typography.bodyLarge) + } + return + } + + when (val source = feedDef.source) { + is FeedSource.Filter -> { + Column(Modifier.fillMaxSize()) { + Text( + "${feedDef.emoji} ${feedDef.name}", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 8.dp), + ) + FeedScreen( + relayManager = relayManager, + localCache = localCache, + subscriptionsCoordinator = subscriptionsCoordinator, + customFeedId = feedId, + customFeedSource = source, + initialFeedMode = FeedMode.CUSTOM, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + } + + is FeedSource.Following -> { + FeedScreen( + relayManager = relayManager, + localCache = localCache, + subscriptionsCoordinator = subscriptionsCoordinator, + initialFeedMode = FeedMode.FOLLOWING, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + + is FeedSource.Global -> { + FeedScreen( + relayManager = relayManager, + localCache = localCache, + subscriptionsCoordinator = subscriptionsCoordinator, + initialFeedMode = FeedMode.GLOBAL, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + + is FeedSource.SingleRelay -> { + FeedScreen( + relayManager = relayManager, + localCache = localCache, + subscriptionsCoordinator = subscriptionsCoordinator, + customFeedId = feedId, + customFeedSource = FeedSource.Filter(relays = persistentListOf(source.url)), + initialFeedMode = FeedMode.CUSTOM, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + + is FeedSource.DVM, + is FeedSource.PeopleList, + is FeedSource.InterestSet, + -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("${feedDef.name} — coming soon", style = MaterialTheme.typography.bodyLarge) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 5ebe9d3de..b8acdd51a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.feeds.DesktopCustomFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager @@ -75,6 +76,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createCustomFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId @@ -274,6 +276,9 @@ fun FeedScreen( localCache: DesktopLocalCache, account: AccountState.LoggedIn? = null, iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount? = null, + customFeedId: String? = null, + customFeedSource: com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter? = null, + onOpenFeedsDrawer: () -> Unit = {}, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, initialFeedMode: FeedMode? = null, @@ -297,7 +302,15 @@ fun FeedScreen( var replyToEvent by remember { mutableStateOf(null) } var lightboxState by remember { mutableStateOf(null) } var showRelayPicker by remember { mutableStateOf(false) } - var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } + var activeFeedId by remember { mutableStateOf(customFeedId) } + var activeFeedSource by remember { + mutableStateOf(customFeedSource) + } + var feedMode by remember { + mutableStateOf( + if (customFeedSource != null) FeedMode.CUSTOM else (initialFeedMode ?: DesktopPreferences.feedMode), + ) + } // Subscribe to contact list (kind 3) — populates localCache.followedUsers rememberSubscription(allRelayUrls, account, relayManager = relayManager) { @@ -315,7 +328,7 @@ fun FeedScreen( } // Subscribe to feed events (kind 1) — populates cache via coordinator - rememberSubscription(feedRelays, feedMode, followedUsers, relayManager = relayManager) { + rememberSubscription(feedRelays, feedMode, followedUsers, activeFeedSource, relayManager = relayManager) { if (feedRelays.isEmpty()) return@rememberSubscription null when (feedMode) { @@ -342,12 +355,27 @@ fun FeedScreen( null } } + + FeedMode.CUSTOM -> { + val src = activeFeedSource + if (src != null) { + createCustomFeedSubscription( + source = src, + relays = feedRelays, + onEvent = { event, _, relay, _ -> + subscriptionsCoordinator?.consumeEvent(event, relay) + }, + ) + } else { + null + } + } } } // DesktopFeedViewModel keyed on feedMode — recreated on mode switch val viewModel = - remember(feedMode) { + remember(feedMode, activeFeedId) { val filter = when (feedMode) { FeedMode.GLOBAL -> { @@ -359,6 +387,15 @@ fun FeedScreen( localCache.followedUsers.value } } + + FeedMode.CUSTOM -> { + DesktopCustomFeedFilter( + localCache, + activeFeedId ?: "custom", + activeFeedSource ?: com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource + .Filter(), + ) + } } DesktopFeedViewModel(filter, localCache) } @@ -489,20 +526,28 @@ fun FeedScreen( Box(modifier = Modifier.fillMaxSize()) { ReadingColumn { - // Header with compose button - FeedHeader( + // Header: pinned feed tabs + "Show More +" + FeedTabsHeader( feedMode = feedMode, - account = account, - feedRelays = feedRelays, - followedUsersCount = followedUsers.size, + activeFeedId = activeFeedId, onFeedModeChange = { mode -> feedMode = mode - DesktopPreferences.feedMode = mode + activeFeedId = null + activeFeedSource = null + if (mode != FeedMode.CUSTOM) { + DesktopPreferences.feedMode = mode + } }, - onRefresh = { relayManager.connect() }, + onNavigateToFeed = { feed -> + val source = feed.source + if (source is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter) { + activeFeedId = feed.id + activeFeedSource = source + feedMode = FeedMode.CUSTOM + } + }, + onOpenFeedsDrawer = onOpenFeedsDrawer, onCompose = onCompose, - onNavigateToRelays = onNavigateToRelays, - onOpenRelayPicker = { showRelayPicker = true }, ) Spacer(Modifier.height(8.dp)) @@ -665,6 +710,83 @@ fun FeedScreen( * the relays icon (desktop convention \u2014 the at-a-glance info is preserved * without stealing header real estate). */ +@Composable +private fun FeedTabsHeader( + feedMode: FeedMode, + activeFeedId: String? = null, + onFeedModeChange: (FeedMode) -> Unit, + onNavigateToFeed: (com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition) -> Unit = {}, + onOpenFeedsDrawer: () -> Unit, + onCompose: () -> Unit, +) { + val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current + val pinnedFeeds by feedRepo.pinnedFeeds.collectAsState() + val sidePadding = LocalReadingSidePadding.current + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = sidePadding + 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Pinned feed tabs + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + pinnedFeeds.forEach { feed -> + val isSelected = + when (feed.source) { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> { + feedMode == FeedMode.FOLLOWING + } + + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> { + feedMode == FeedMode.GLOBAL + } + + else -> { + activeFeedId == feed.id + } + } + FilterChip( + selected = isSelected, + onClick = { + when (feed.source) { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> { + onFeedModeChange(FeedMode.FOLLOWING) + } + + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> { + onFeedModeChange(FeedMode.GLOBAL) + } + + else -> { + onNavigateToFeed(feed) + } + } + }, + label = { Text("${feed.emoji} ${feed.name}") }, + ) + } + // "Show More +" button + FilterChip( + selected = false, + onClick = onOpenFeedsDrawer, + label = { Text("+ More") }, + ) + } + + // Compose button + IconButton(onClick = onCompose) { + Icon( + MaterialSymbols.Edit, + contentDescription = "Compose", + modifier = Modifier.size(20.dp), + ) + } + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun FeedHeader( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 37cb44f28..0706f93fb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -281,6 +281,10 @@ fun ReadsScreen( null } } + + FeedMode.CUSTOM -> { + null + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 094d457f9..dede95643 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -74,6 +74,8 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus +import com.vitorpamplona.amethyst.commons.feeds.custom.canBecomeFeed +import com.vitorpamplona.amethyst.commons.feeds.custom.toFeedDefinition import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState @@ -102,6 +104,7 @@ import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import kotlinx.coroutines.launch @Composable fun SearchScreen( @@ -459,6 +462,29 @@ fun SearchScreen( }, ) } + // "Save as Feed" button — visible when query has feed-able criteria + if (query.canBecomeFeed()) { + val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current + var showFeedBuilder by remember { mutableStateOf(false) } + IconButton(onClick = { showFeedBuilder = true }) { + Icon( + MaterialSymbols.Bookmark, + contentDescription = "Save as Feed", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (showFeedBuilder) { + com.vitorpamplona.amethyst.desktop.ui.deck.FeedBuilderDialog( + initial = query.toFeedDefinition(name = ""), + localCache = localCache, + onSave = { feed -> + scope.launch { feedRepo.add(feed) } + showFeedBuilder = false + }, + onDismiss = { showFeedBuilder = false }, + ) + } + } } // Search relay picker dialog @@ -510,7 +536,7 @@ fun SearchScreen( onExcludeRemoved = { state.removeExcludeTerm(it) }, onLanguageChanged = { state.updateLanguage(it) }, onClear = { state.clearSearch() }, - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier.padding(top = 8.dp, start = sidePadding, end = sidePadding), ) } @@ -522,7 +548,10 @@ fun SearchScreen( if (bech32Results.isNotEmpty()) { // Show bech32 results (exact lookup) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = sidePadding), + ) { Text( "Direct lookup", style = MaterialTheme.typography.labelMedium, @@ -544,12 +573,14 @@ fun SearchScreen( onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, localCache = localCache, + modifier = Modifier.padding(horizontal = sidePadding), ) } else if (!debouncedQuery.isEmpty && !isSearching) { Text( "No results found. Try broader terms or fewer filters.", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = sidePadding), ) } else if (!isSearching) { // Empty state: show history + saved searches + operator hints diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt index e2c9a5df0..27b578dfc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AppDrawer.kt @@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape @@ -137,6 +138,7 @@ fun DeckColumnType.category(): ScreenCategory = is DeckColumnType.Profile, is DeckColumnType.Thread, is DeckColumnType.Article, + is DeckColumnType.CustomFeed, -> ScreenCategory.SOCIAL } @@ -153,6 +155,7 @@ fun DeckColumnType.param(): String? = is DeckColumnType.Hashtag -> tag is DeckColumnType.Editor -> draftSlug is DeckColumnType.Article -> addressTag + is DeckColumnType.CustomFeed -> feedId else -> null } @@ -180,6 +183,7 @@ val LAUNCHABLE_SCREENS: List = enum class AppDrawerTab { SCREENS, WORKSPACES, + FEEDS, } // -- State -- @@ -284,6 +288,10 @@ private class AppDrawerState { onDismiss() } } + + AppDrawerTab.FEEDS -> { + // Feed selection handled by FeedsDrawerTab's own click handlers + } } } } @@ -309,6 +317,7 @@ fun AppDrawer( onSwitchWorkspace: (Workspace) -> Unit, onSelectScreen: (DeckColumnType) -> Unit, onDismiss: () -> Unit, + initialTab: AppDrawerTab? = null, ) { val state = remember { AppDrawerState() } val searchFocusRequester = remember { FocusRequester() } @@ -317,6 +326,11 @@ fun AppDrawer( derivedStateOf { state.filteredWorkspaces(allWorkspaces) } } + // Set initial tab if specified + LaunchedEffect(initialTab) { + if (initialTab != null) state.switchTab(initialTab) + } + LaunchedEffect(Unit) { delay(50) searchFocusRequester.requestFocus() @@ -454,6 +468,22 @@ fun AppDrawer( onDismiss = onDismiss, ) } + + AppDrawerTab.FEEDS -> { + FeedsDrawerTab( + onSelectFeed = { feedDef -> + onSelectScreen( + DeckColumnType.CustomFeed( + feedId = feedDef.id, + feedName = feedDef.name, + feedEmoji = feedDef.emoji, + ), + ) + onDismiss() + }, + onDismiss = onDismiss, + ) + } } } } @@ -1099,6 +1129,14 @@ private fun UnifiedSearchResults( val activeIndex by workspaceManager.activeIndex.collectAsState() val allWorkspaces by workspaceManager.workspaces.collectAsState() + val feedRepo = LocalFeedRepository.current + val allFeeds by feedRepo.feeds.collectAsState() + val filteredFeeds = + allFeeds.filter { feed -> + feed.name.contains(state.searchQuery, ignoreCase = true) || + feed.emoji.contains(state.searchQuery) + } + LazyColumn(Modifier.padding(8.dp)) { // Workspace results first if (filteredWs.isNotEmpty()) { @@ -1147,6 +1185,46 @@ private fun UnifiedSearchResults( } } } + // Feed results + if (filteredFeeds.isNotEmpty()) { + stickyHeader { + Text( + "Feeds", + style = MaterialTheme.typography.titleSmall, + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + items(filteredFeeds, key = { "feed-${it.id}" }) { feed -> + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .clickable { + onSelectScreen( + DeckColumnType.CustomFeed(feed.id, feed.name, feed.emoji), + ) + onDismiss() + }, + tonalElevation = 0.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(feed.emoji.ifEmpty { "\uD83D\uDCCB" }, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.width(12.dp)) + Text(feed.name, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + // Screen results below if (state.filteredScreens.isNotEmpty()) { stickyHeader { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt index 018ebd94f..e2dbe9136 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -128,4 +128,5 @@ fun DeckColumnType.icon(): MaterialSymbol = is DeckColumnType.Profile -> MaterialSymbols.Person is DeckColumnType.Thread -> MaterialSymbols.AutoMirrored.Article is DeckColumnType.Hashtag -> MaterialSymbols.Tag + is DeckColumnType.CustomFeed -> MaterialSymbols.Tune } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index d41b1de86..911eb32e1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen +import com.vitorpamplona.amethyst.desktop.ui.CustomFeedScreen import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen @@ -208,6 +209,7 @@ internal fun RootContent( onNavigateToArticle: (String) -> Unit = {}, onNavigateToEditor: (String?) -> Unit = {}, onNavigateToRelays: () -> Unit = {}, + onOpenFeedsDrawer: () -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -226,6 +228,7 @@ internal fun RootContent( onNavigateToThread = onNavigateToThread, onZapFeedback = onZapFeedback, onNavigateToRelays = onNavigateToRelays, + onOpenFeedsDrawer = onOpenFeedsDrawer, ) } @@ -429,6 +432,18 @@ internal fun RootContent( onNavigateToThread = onNavigateToThread, ) } + + is DeckColumnType.CustomFeed -> { + CustomFeedScreen( + feedId = columnType.feedId, + relayManager = relayManager, + localCache = localCache, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt index 408fb5837..fd76311ba 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -69,6 +69,12 @@ sealed class DeckColumnType { val tag: String, ) : DeckColumnType() + data class CustomFeed( + val feedId: String, + val feedName: String = "", + val feedEmoji: String = "", + ) : DeckColumnType() + fun title(): String = when (this) { HomeFeed -> "Home" @@ -89,6 +95,7 @@ sealed class DeckColumnType { is Profile -> "Profile" is Thread -> "Thread" is Hashtag -> "#$tag" + is CustomFeed -> feedName.ifEmpty { "Feed" } } fun typeKey(): String = @@ -111,6 +118,7 @@ sealed class DeckColumnType { is Profile -> "profile" is Thread -> "thread" is Hashtag -> "hashtag" + is CustomFeed -> "custom_feed" } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt index 945680d37..8ff785dff 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -306,6 +306,7 @@ class DeckState( "profile" -> param?.let { DeckColumnType.Profile(it) } "thread" -> param?.let { DeckColumnType.Thread(it) } "hashtag" -> param?.let { DeckColumnType.Hashtag(it) } + "custom_feed" -> param?.let { DeckColumnType.CustomFeed(feedId = it) } else -> null } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedBuilderDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedBuilderDialog.kt new file mode 100644 index 000000000..d76c59611 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedBuilderDialog.kt @@ -0,0 +1,503 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.deck + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.InputChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedBuilderState +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition +import com.vitorpamplona.amethyst.commons.feeds.custom.RefreshMode +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun FeedBuilderDialog( + initial: FeedDefinition? = null, + localCache: DesktopLocalCache? = null, + authorQuery: String = "", + onAuthorQueryChange: (String) -> Unit = {}, + authorSuggestions: List = emptyList(), + authorRelayResults: List = emptyList(), + authorSearching: Boolean = false, + onSave: (FeedDefinition) -> Unit, + onDismiss: () -> Unit, +) { + val state = remember(initial) { FeedBuilderState(initial) } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = + Modifier.padding(horizontal = 32.dp, vertical = 32.dp).onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && + event.key == Key.S && + (event.isMetaPressed || event.isCtrlPressed) + ) { + if (state.isValid) onSave(state.toDefinition()) + true + } else { + false + } + }, + title = { Text(if (initial != null) "Edit Feed" else "Create Feed") }, + text = { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Name + emoji + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = state.emoji, + onValueChange = { state.emoji = it.take(2) }, + label = { Text("Icon") }, + modifier = Modifier.width(72.dp), + singleLine = true, + ) + Spacer(Modifier.width(8.dp)) + OutlinedTextField( + value = state.name, + onValueChange = { state.name = it }, + label = { Text("Name") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + } + + // Authors (with search + npub decode) + AuthorInputSection( + authors = state.authors, + localCache = localCache, + query = authorQuery, + onQueryChange = onAuthorQueryChange, + suggestions = authorSuggestions, + relayResults = authorRelayResults, + isSearching = authorSearching, + onAdd = { hex -> + if (hex !in state.authors) state.authors.add(hex) + onAuthorQueryChange("") + }, + onRemove = { state.authors.remove(it) }, + ) + + // Hashtags + ChipInputField( + label = "Hashtags", + items = state.hashtags, + placeholder = "Add hashtag...", + onAdd = { state.hashtags.add(it.removePrefix("#").lowercase()) }, + onRemove = { state.hashtags.remove(it) }, + ) + + // Relays + ChipInputField( + label = "Relays", + items = state.relays, + placeholder = "wss://...", + onAdd = { state.relays.add(it) }, + onRemove = { state.relays.remove(it) }, + ) + + // Kind filter + KindFilterSection(kinds = state.kinds) + + // Exclude authors + if (localCache != null) { + AuthorInputSection( + label = "Exclude Authors", + authors = state.excludeAuthors, + localCache = localCache, + onAdd = { hex -> if (hex !in state.excludeAuthors) state.excludeAuthors.add(hex) }, + onRemove = { state.excludeAuthors.remove(it) }, + ) + } + + // Exclude keywords + ChipInputField( + label = "Exclude keywords", + items = state.excludeKeywords, + placeholder = "Add keyword to exclude...", + onAdd = { state.excludeKeywords.add(it) }, + onRemove = { state.excludeKeywords.remove(it) }, + ) + + // Refresh mode + Text("Refresh", style = MaterialTheme.typography.labelMedium) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + RefreshMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + selected = state.refreshMode == mode, + onClick = { state.refreshMode = mode }, + shape = SegmentedButtonDefaults.itemShape(index, RefreshMode.entries.size), + ) { + Text( + when (mode) { + RefreshMode.LIVE_STREAM -> "Live" + RefreshMode.POLL_5MIN -> "Every 5 min" + }, + ) + } + } + } + } + }, + confirmButton = { + Button( + onClick = { onSave(state.toDefinition()) }, + enabled = state.isValid, + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +// -- Author search field with npub decode + profile lookup -- + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AuthorInputSection( + label: String = "Authors", + authors: List, + localCache: DesktopLocalCache?, + query: String = "", + onQueryChange: (String) -> Unit = {}, + suggestions: List = emptyList(), + relayResults: List = emptyList(), + isSearching: Boolean = false, + onAdd: (String) -> Unit, + onRemove: (String) -> Unit, +) { + Column { + Text(label, style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.height(4.dp)) + + if (authors.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + authors.forEach { hex -> + val user = localCache?.getUserIfExists(hex) + val displayName = user?.toBestDisplayName() ?: hex.take(12) + "..." + InputChip( + selected = true, + onClick = { onRemove(hex) }, + label = { Text(displayName, style = MaterialTheme.typography.bodySmall) }, + ) + } + } + Spacer(Modifier.height(4.dp)) + } + + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("Search name or paste npub...") }, + modifier = + Modifier.fillMaxWidth().onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) { + if (query.isNotBlank()) { + val top = + suggestions.firstOrNull { it.pubkeyHex !in authors } + ?: relayResults.firstOrNull { it.pubkeyHex !in authors } + if (top != null) { + onAdd(top.pubkeyHex) + } else { + onAdd(decodePublicKeyAsHexOrNull(query.trim()) ?: query.trim()) + } + } + true + } else { + false + } + }, + singleLine = true, + ) + + val filteredLocal = suggestions.filter { it.pubkeyHex !in authors } + val filteredRelay = + relayResults.filter { r -> + r.pubkeyHex !in authors && filteredLocal.none { it.pubkeyHex == r.pubkeyHex } + } + + val hasResults = filteredLocal.isNotEmpty() || filteredRelay.isNotEmpty() + val showNoResults = query.length >= 2 && !hasResults && !isSearching + + if (hasResults || isSearching || showNoResults) { + Spacer(Modifier.height(4.dp)) + Surface( + tonalElevation = 4.dp, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp), + ) { + LazyColumn { + items(filteredLocal, key = { "c-${it.pubkeyHex}" }) { user -> + AuthorRow(user) { onAdd(user.pubkeyHex) } + } + if (filteredRelay.isNotEmpty()) { + item { + Text( + "From relays", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + items(filteredRelay, key = { "r-${it.pubkeyHex}" }) { user -> + AuthorRow(user) { onAdd(user.pubkeyHex) } + } + } + if (isSearching) { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Searching relays...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (showNoResults) { + item { + Text( + "No users found", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } + } + } + } + } + } +} + +@Composable +private fun AuthorRow( + user: com.vitorpamplona.amethyst.commons.model.User, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + com.vitorpamplona.amethyst.commons.ui.components.UserAvatar( + userHex = user.pubkeyHex, + pictureUrl = user.profilePicture(), + size = 28.dp, + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + user.toBestDisplayName(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + user.pubkeyNpub().take(20) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// -- Kind filter checkboxes -- + +private data class KindOption( + val label: String, + val kinds: List, +) + +private val KIND_OPTIONS = + listOf( + KindOption("Notes", listOf(1)), + KindOption("Reposts", listOf(6, 16)), + KindOption("Articles", listOf(30023)), + KindOption("Highlights", listOf(9802)), + ) + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun KindFilterSection(kinds: MutableList) { + Column { + Text("Event kinds", style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.height(4.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + KIND_OPTIONS.forEach { option -> + val isSelected = option.kinds.any { it in kinds } + FilterChip( + selected = isSelected, + onClick = { + if (isSelected) { + kinds.removeAll(option.kinds) + } else { + kinds.addAll(option.kinds) + } + }, + label = { Text(option.label) }, + ) + } + } + if (kinds.isEmpty()) { + Text( + "No filter = all kinds", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// -- Simple chip input field (hashtags, relays, keywords) -- + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ChipInputField( + label: String, + items: List, + placeholder: String, + onAdd: (String) -> Unit, + onRemove: (String) -> Unit, +) { + Column { + Text(label, style = MaterialTheme.typography.labelMedium) + Spacer(Modifier.height(4.dp)) + if (items.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items.forEach { item -> + InputChip( + selected = true, + onClick = { onRemove(item) }, + label = { Text(item, style = MaterialTheme.typography.bodySmall) }, + ) + } + } + Spacer(Modifier.height(4.dp)) + } + var input by remember { mutableStateOf("") } + OutlinedTextField( + value = input, + onValueChange = { input = it }, + placeholder = { Text(placeholder) }, + modifier = + Modifier.fillMaxWidth().onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) { + if (input.isNotBlank()) { + onAdd(input.trim()) + input = "" + } + true + } else { + false + } + }, + singleLine = true, + trailingIcon = { + if (input.isNotBlank()) { + TextButton( + onClick = { + onAdd(input.trim()) + input = "" + }, + ) { + Text("Add") + } + } + }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt new file mode 100644 index 000000000..bcfc46cd5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/FeedsDrawerTab.kt @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.deck + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinitionRepository +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource +import com.vitorpamplona.amethyst.commons.feeds.custom.MAX_PINNED_FEEDS +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun FeedsDrawerTab( + onSelectFeed: (FeedDefinition) -> Unit, + onDismiss: () -> Unit, + localCache: DesktopLocalCache? = LocalDesktopCache.current, + relayManager: com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager? = LocalRelayManager.current, + feedRepository: FeedDefinitionRepository = LocalFeedRepository.current, + scope: CoroutineScope = LocalFeedScope.current, +) { + val grouped by feedRepository.groupedFeeds.collectAsState() + var showBuilder by remember { mutableStateOf(false) } + var editingFeed by remember { mutableStateOf(null) } + var deletingFeed by remember { mutableStateOf(null) } + + // Author search — same pattern as NewDmDialog: SearchBarState + rememberSubscription + val searchState = + remember(localCache) { + localCache?.let { + com.vitorpamplona.amethyst.commons.viewmodels + .SearchBarState(it, scope) + } + } + val authorQuery = searchState?.searchText?.collectAsState()?.value ?: "" + val authorLocal = searchState?.cachedUserResults?.collectAsState()?.value ?: emptyList() + val authorRelay = searchState?.relaySearchResults?.collectAsState()?.value ?: emptyList() + val authorSearching = searchState?.isSearchingRelays?.collectAsState()?.value ?: false + + // NIP-50 relay search — fires when local cache has few results (same as NewDmDialog) + if (relayManager != null && searchState != null) { + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays = relayStatuses.keys + + com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription( + connectedRelays, + authorQuery, + authorLocal.size, + relayManager = relayManager, + ) { + if (connectedRelays.isEmpty()) return@rememberSubscription null + if (!searchState.shouldSearchRelays) return@rememberSubscription null + + searchState.startRelaySearch() + com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription( + relays = connectedRelays, + searchQuery = authorQuery, + limit = 30, + onEvent = { event, _, _, _ -> + if (event is com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent && + localCache != null + ) { + localCache.consumeMetadata(event) + localCache.getUserIfExists(event.pubKey)?.let { + searchState.addRelaySearchResult(it) + } + } + }, + onEose = { _, _ -> searchState.endRelaySearch() }, + ) + } + } + + // Reset search when dialogs close + LaunchedEffect(showBuilder, editingFeed) { + if (!showBuilder && editingFeed == null) { + searchState?.clearSearch() + } + } + + // Create dialog + if (showBuilder) { + FeedBuilderDialog( + localCache = localCache, + authorQuery = authorQuery, + onAuthorQueryChange = { searchState?.updateSearchText(it) }, + authorSuggestions = authorLocal, + authorRelayResults = authorRelay, + authorSearching = authorSearching, + onSave = { feed -> + scope.launch { feedRepository.add(feed) } + showBuilder = false + }, + onDismiss = { showBuilder = false }, + ) + } + + // Edit dialog + editingFeed?.let { feed -> + FeedBuilderDialog( + initial = feed, + localCache = localCache, + authorQuery = authorQuery, + onAuthorQueryChange = { searchState?.updateSearchText(it) }, + authorSuggestions = authorLocal, + authorRelayResults = authorRelay, + authorSearching = authorSearching, + onSave = { updated -> + scope.launch { feedRepository.update(updated) } + editingFeed = null + }, + onDismiss = { editingFeed = null }, + ) + } + + // Delete confirmation + deletingFeed?.let { feed -> + AlertDialog( + onDismissRequest = { deletingFeed = null }, + title = { Text("Delete Feed") }, + text = { Text("Delete \"${feed.name}\"? This cannot be undone.") }, + confirmButton = { + TextButton(onClick = { + scope.launch { feedRepository.delete(feed.id) } + deletingFeed = null + }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { deletingFeed = null }) { Text("Cancel") } + }, + ) + } + + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (grouped.pinned.isNotEmpty()) { + item(key = "pinned-header") { + SectionHeader("Pinned (${grouped.pinned.size}/$MAX_PINNED_FEEDS)") + } + items(grouped.pinned, key = { "pinned-${it.id}" }) { feed -> + FeedRow( + feed = feed, + onSelect = { onSelectFeed(feed) }, + onPin = null, + onUnpin = { scope.launch { feedRepository.unpin(feed.id) } }, + onEdit = + if (feed.source is FeedSource.Filter) { + { editingFeed = feed } + } else { + null + }, + onDelete = + if (feed.id.startsWith("default-")) { + null + } else { + { deletingFeed = feed } + }, + ) + } + } + + if (grouped.myFeeds.isNotEmpty()) { + item(key = "my-header") { + SectionHeader("My Feeds") + } + items(grouped.myFeeds, key = { "my-${it.id}" }) { feed -> + FeedRow( + feed = feed, + onSelect = { onSelectFeed(feed) }, + onPin = { scope.launch { feedRepository.pin(feed.id) } }, + onUnpin = null, + onEdit = + if (feed.source is FeedSource.Filter) { + { editingFeed = feed } + } else { + null + }, + onDelete = { deletingFeed = feed }, + ) + } + } + + if (grouped.algoFeeds.isNotEmpty()) { + item(key = "algo-header") { + SectionHeader("Algo Feeds") + } + items(grouped.algoFeeds, key = { "algo-${it.id}" }) { feed -> + FeedRow( + feed = feed, + onSelect = { onSelectFeed(feed) }, + onPin = { scope.launch { feedRepository.pin(feed.id) } }, + onUnpin = + if (feed.pinned) { + { scope.launch { feedRepository.unpin(feed.id) } } + } else { + null + }, + onEdit = null, + onDelete = { deletingFeed = feed }, + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton(onClick = { showBuilder = true }) { + Text("+ Create Feed") + } + OutlinedButton(onClick = { /* TODO: browse DVMs */ }) { + Text("Browse DVMs") + } + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) +} + +@Composable +private fun FeedRow( + feed: FeedDefinition, + onSelect: () -> Unit, + onPin: (() -> Unit)?, + onUnpin: (() -> Unit)?, + onEdit: (() -> Unit)? = null, + onDelete: (() -> Unit)? = null, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onSelect), + shape = RoundedCornerShape(8.dp), + tonalElevation = 1.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = feed.emoji, fontSize = 20.sp) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = feed.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (onEdit != null) { + IconButton(onClick = onEdit, modifier = Modifier.size(28.dp)) { + Icon(MaterialSymbols.Edit, contentDescription = "Edit", modifier = Modifier.size(16.dp)) + } + } + if (onDelete != null) { + IconButton(onClick = onDelete, modifier = Modifier.size(28.dp)) { + Icon( + MaterialSymbols.Close, + contentDescription = "Delete", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + if (onUnpin != null) { + TextButton(onClick = onUnpin, modifier = Modifier.size(height = 32.dp, width = 60.dp)) { + Text("Unpin", style = MaterialTheme.typography.labelSmall) + } + } + if (onPin != null) { + TextButton(onClick = onPin, modifier = Modifier.size(height = 32.dp, width = 48.dp)) { + Text("Pin", style = MaterialTheme.typography.labelSmall) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt new file mode 100644 index 000000000..651db0f91 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.deck + +import androidx.compose.runtime.compositionLocalOf +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinitionRepository +import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinitionSerializer +import com.vitorpamplona.amethyst.commons.feeds.custom.defaultFeeds +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import java.util.prefs.Preferences + +private const val FEEDS_PREFS_KEY = "custom_feeds_json" + +private val feedPrefs: Preferences by lazy { + Preferences.userRoot().node("amethyst/feeds") +} + +private val defaultRepository by lazy { + val repo = FeedDefinitionRepository(GlobalScope) + + // Load persisted feeds (or defaults on first run) + val json = feedPrefs.get(FEEDS_PREFS_KEY, "") + val persisted = FeedDefinitionSerializer.deserializeList(json) + if (persisted.isNotEmpty()) { + repo.load(persisted) + } else { + repo.load(defaultFeeds()) + } + + // Auto-persist on every change + repo.feeds + .onEach { feeds -> + val serialized = FeedDefinitionSerializer.serializeList(feeds) + feedPrefs.put(FEEDS_PREFS_KEY, serialized) + feedPrefs.flush() + }.launchIn(GlobalScope) + + repo +} + +val LocalFeedRepository = + compositionLocalOf { + defaultRepository + } + +val LocalFeedScope = + compositionLocalOf { + GlobalScope + } + +val LocalDesktopCache = + compositionLocalOf { + null + } + +val LocalRelayManager = + compositionLocalOf { + null + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 16d68457f..72a21bc76 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -87,6 +87,7 @@ fun SinglePaneLayout( singlePaneState: SinglePaneState, pinnedNavBarState: PinnedNavBarState, onOpenAppDrawer: () -> Unit, + onOpenFeedsDrawer: () -> Unit = onOpenAppDrawer, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, @@ -112,6 +113,8 @@ fun SinglePaneLayout( ) { val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState() pinnedScreens.forEach { screenType -> + // Rename "Home" to "Feeds" in the nav rail + val label = if (screenType == DeckColumnType.HomeFeed) "Feeds" else screenType.title() NavigationRailItem( selected = currentColumnType == screenType && navStack.isEmpty(), onClick = { @@ -121,13 +124,13 @@ fun SinglePaneLayout( icon = { Icon( screenType.icon(), - contentDescription = screenType.title(), + contentDescription = label, modifier = Modifier.size(22.dp), ) }, label = { Text( - screenType.title(), + label, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -249,6 +252,7 @@ fun SinglePaneLayout( onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) }, onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) }, onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) }, + onOpenFeedsDrawer = onOpenFeedsDrawer, ) if (currentOverlay != null) { Surface( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt index ee5f44cac..5c65f075e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AnimatedGifImage.kt @@ -88,23 +88,26 @@ fun AnimatedGifImage( val data = gifFrames when { data != null && data.frames.size > 1 -> { + val safeFrame = currentFrame.coerceIn(0, data.frames.size - 1) + LaunchedEffect(data) { while (isActive) { - val duration = data.durations[currentFrame].coerceAtLeast(MIN_FRAME_DURATION_MS) + val frameIdx = currentFrame.coerceIn(0, data.frames.size - 1) + val duration = data.durations[frameIdx].coerceAtLeast(MIN_FRAME_DURATION_MS) delay(duration.toLong()) currentFrame = (currentFrame + 1) % data.frames.size } } Image( - bitmap = data.frames[currentFrame], + bitmap = data.frames[safeFrame], contentDescription = contentDescription, modifier = modifier, contentScale = contentScale, ) } - data != null -> { + data != null && data.frames.isNotEmpty() -> { Image( bitmap = data.frames[0], contentDescription = contentDescription, diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/FindUsersTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/FindUsersTest.kt new file mode 100644 index 000000000..ed79daee0 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/FindUsersTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.cache + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FindUsersTest { + private fun createCache() = DesktopLocalCache() + + private fun fakeMetadata( + pubKey: String, + name: String, + displayName: String = name, + ): MetadataEvent = + MetadataEvent( + id = (pubKey.take(16) + "meta").padEnd(64, '0'), + pubKey = pubKey, + createdAt = System.currentTimeMillis() / 1000, + tags = emptyArray(), + content = """{"name":"$name","display_name":"$displayName"}""", + sig = "0".repeat(128), + ) + + @Test + fun userWithoutMetadataNotFoundByName() { + val cache = createCache() + val pubkey = KeyPair().pubKey.toHexKey() + + cache.getOrCreateUser(pubkey) + + val results = cache.findUsersStartingWith("test", 10) + assertTrue(results.isEmpty(), "User without metadata should not match name search") + } + + @Test + fun userWithMetadataFoundByDisplayName() { + val cache = createCache() + val pubkey = KeyPair().pubKey.toHexKey() + + cache.consumeMetadata(fakeMetadata(pubkey, "vitor", "Vitor Pamplona")) + + val results = cache.findUsersStartingWith("Vitor", 10) + assertEquals(1, results.size, "Should find user by display name") + assertEquals(pubkey, results[0].pubkeyHex) + } + + @Test + fun userWithMetadataFoundByName() { + val cache = createCache() + val pubkey = KeyPair().pubKey.toHexKey() + + cache.consumeMetadata(fakeMetadata(pubkey, "vitor")) + + val results = cache.findUsersStartingWith("vit", 10) + assertEquals(1, results.size, "Should find user by name prefix") + } + + @Test + fun userWithMetadataFoundCaseInsensitive() { + val cache = createCache() + val pubkey = KeyPair().pubKey.toHexKey() + + cache.consumeMetadata(fakeMetadata(pubkey, "Vitor", "Vitor Pamplona")) + + val lower = cache.findUsersStartingWith("vitor", 10) + assertEquals(1, lower.size, "Should find case-insensitively (lowercase)") + + val upper = cache.findUsersStartingWith("VITOR", 10) + assertEquals(1, upper.size, "Should find case-insensitively (uppercase)") + } + + @Test + fun userWithoutMetadataFoundByPubkey() { + val cache = createCache() + val pubkey = KeyPair().pubKey.toHexKey() + + cache.getOrCreateUser(pubkey) + + val results = cache.findUsersStartingWith(pubkey.take(8), 10) + assertEquals(1, results.size, "Should find user by pubkey prefix") + } + + @Test + fun multipleUsersWithMetadata() { + val cache = createCache() + + cache.consumeMetadata(fakeMetadata(KeyPair().pubKey.toHexKey(), "alice")) + cache.consumeMetadata(fakeMetadata(KeyPair().pubKey.toHexKey(), "bob")) + cache.consumeMetadata(fakeMetadata(KeyPair().pubKey.toHexKey(), "alex")) + + val results = cache.findUsersStartingWith("al", 10) + assertEquals(2, results.size, "Should find alice and alex") + } + + @Test + fun usersFromNotesWithoutMetadataNotMatchNameSearch() { + val cache = createCache() + + // Simulate users created from kind 1 notes (no metadata) + repeat(10) { cache.getOrCreateUser(KeyPair().pubKey.toHexKey()) } + + assertEquals(10, cache.userCount()) + + val results = cache.findUsersStartingWith("test", 10) + assertEquals(0, results.size, "Users without metadata should not match name search") + } + + @Test + fun verifyMetadataIsActuallyParsed() { + val cache = createCache() + val pubkey = KeyPair().pubKey.toHexKey() + + cache.consumeMetadata(fakeMetadata(pubkey, "testuser", "Test User")) + + val user = cache.getUserIfExists(pubkey) + val metadata = user?.metadataOrNull() + + assertTrue(metadata != null, "Metadata should exist after consumeMetadata") + assertTrue( + metadata.anyNameOrAddressContains( + listOf( + com.vitorpamplona.quartz.utils + .DualCase("test", "TEST"), + ), + ), + "Metadata should match 'test' search", + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/feedDefinition/FeedDefinitionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/feedDefinition/FeedDefinitionEvent.kt new file mode 100644 index 000000000..cdc6245a6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/feedDefinition/FeedDefinitionEvent.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.feedDefinition + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +/** + * Kind 31890: Feed Definition Event (draft NIP, PR #1181). + * + * Addressable event that stores a custom feed definition. + * Content contains JSON-serialized feed source configuration. + * Tags include discoverability metadata (hashtags, authors, relays). + */ +@Immutable +class FeedDefinitionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: TagArray, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "title" }?.get(1) + + fun emoji(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "emoji" }?.get(1) + + fun feedConfigJson(): String = content + + companion object { + const val KIND = 31890 + } +}