feat(desktop): add custom feeds system with creation, pinning, and inline switching
- FeedDefinition data model with FeedSource sealed interface (Filter, Following, Global, DVM, PeopleList, InterestSet, SingleRelay) - FeedDefinitionRepository with StateFlow, groupedFeeds, pin/unpin/reorder (max 3 pinned) - JSON serialization via Jackson with round-trip tests (18 unit tests) - SearchQuery.toFeedDefinition() bridge for creating feeds from search - FeedBuilderDialog with author search (cache lookup + npub decode), hashtag/relay inputs, kind filter checkboxes, exclude authors/keywords, Cmd+S save, Enter to add - FeedsDrawerTab in app drawer with edit/delete/pin/unpin actions - Feeds tab in top header (FilterChips) with inline mode switching - CustomFeedScreen + DesktopCustomFeedFilter + createCustomFeedSubscription for relay-based custom feed content - FeedDefinitionEvent (kind 31890) in quartz for future publish/import - Local persistence via java.util.prefs.Preferences (auto-save on change) - DeckColumnType.CustomFeed for navigation integration - Renamed Home to Feeds in nav rail - Fix: AnimatedGifImage race condition (coerceIn on frame index) - Fix: search results margins (sidePadding applied consistently) - Fix: app drawer search includes feeds in results Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>,
|
||||||
|
val authors: ImmutableList<HexKey>,
|
||||||
|
val relays: ImmutableList<String>,
|
||||||
|
val excludeAuthors: ImmutableList<HexKey>,
|
||||||
|
val excludeKeywords: ImmutableList<String>,
|
||||||
|
val kinds: ImmutableList<Int>,
|
||||||
|
) : 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<ImmutableList<FeedDefinition>>(persistentListOf())
|
||||||
|
val feeds: StateFlow<ImmutableList<FeedDefinition>> = _feeds.asStateFlow()
|
||||||
|
|
||||||
|
// Pre-computed grouped view for drawer UI
|
||||||
|
val groupedFeeds: StateFlow<GroupedFeeds> = _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<ImmutableList<FeedDefinition>> = groupedFeeds.map { it.pinned }
|
||||||
|
.distinctUntilChanged().stateIn(scope, SharingStarted.Eagerly, persistentListOf())
|
||||||
|
|
||||||
|
// Transient UI events
|
||||||
|
private val _events = MutableSharedFlow<FeedEvent>(replay = 0)
|
||||||
|
val events: SharedFlow<FeedEvent> = _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<FeedDefinition>,
|
||||||
|
val myFeeds: ImmutableList<FeedDefinition>,
|
||||||
|
val algoFeeds: ImmutableList<FeedDefinition>,
|
||||||
|
) {
|
||||||
|
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", "<feed-id>"]
|
||||||
|
["title", "<feed name>"]
|
||||||
|
["emoji", "<single emoji>"]
|
||||||
|
["alt", "Feed definition: <name>"]
|
||||||
|
// Discoverability tags (duplicated from content for relay filtering):
|
||||||
|
["t", "<hashtag>"] // for each hashtag in filter
|
||||||
|
["p", "<author-hex>"] // for each author in filter
|
||||||
|
["relay", "<relay-url>"] // for relay-based feeds
|
||||||
|
["a", "31990:<dvm-pubkey>:<d>"] // DVM reference
|
||||||
|
["a", "30000:<pubkey>:<d>"] // PeopleList reference
|
||||||
|
["a", "30015:<pubkey>:<d>"] // 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<ImmutableList<FeedDefinition>>` + `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<Note> = 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<List<Note>>,
|
||||||
|
val eoseReceived: StateFlow<Boolean>,
|
||||||
|
val lastRefreshed: StateFlow<Instant?>,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<String>()
|
||||||
|
val authors = mutableStateListOf<HexKey>()
|
||||||
|
val relays = mutableStateListOf<String>()
|
||||||
|
val excludeAuthors = mutableStateListOf<HexKey>()
|
||||||
|
val excludeKeywords = mutableStateListOf<String>()
|
||||||
|
// ...
|
||||||
|
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<Note> 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 |
|
||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
+93
@@ -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<String>().apply {
|
||||||
|
(initial?.source as? FeedSource.Filter)?.hashtags?.let { addAll(it) }
|
||||||
|
}
|
||||||
|
val authors =
|
||||||
|
mutableStateListOf<HexKey>().apply {
|
||||||
|
(initial?.source as? FeedSource.Filter)?.authors?.let { addAll(it) }
|
||||||
|
}
|
||||||
|
val relays =
|
||||||
|
mutableStateListOf<String>().apply {
|
||||||
|
(initial?.source as? FeedSource.Filter)?.relays?.let { addAll(it) }
|
||||||
|
}
|
||||||
|
val excludeAuthors =
|
||||||
|
mutableStateListOf<HexKey>().apply {
|
||||||
|
(initial?.source as? FeedSource.Filter)?.excludeAuthors?.let { addAll(it) }
|
||||||
|
}
|
||||||
|
val excludeKeywords =
|
||||||
|
mutableStateListOf<String>().apply {
|
||||||
|
(initial?.source as? FeedSource.Filter)?.excludeKeywords?.let { addAll(it) }
|
||||||
|
}
|
||||||
|
val kinds =
|
||||||
|
mutableStateListOf<Int>().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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -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<String> = persistentListOf(),
|
||||||
|
val authors: ImmutableList<HexKey> = persistentListOf(),
|
||||||
|
val relays: ImmutableList<String> = persistentListOf(),
|
||||||
|
val excludeAuthors: ImmutableList<HexKey> = persistentListOf(),
|
||||||
|
val excludeKeywords: ImmutableList<String> = persistentListOf(),
|
||||||
|
val kinds: ImmutableList<Int> = 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,
|
||||||
|
}
|
||||||
+125
@@ -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<String>()
|
||||||
|
val authors = mutableListOf<HexKey>()
|
||||||
|
val relays = mutableListOf<String>()
|
||||||
|
val excludeAuthors = mutableListOf<HexKey>()
|
||||||
|
val excludeKeywords = mutableListOf<String>()
|
||||||
|
val kinds = mutableListOf<Int>()
|
||||||
|
|
||||||
|
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<FeedDefinition> =
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
+162
@@ -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<ImmutableList<FeedDefinition>>(persistentListOf())
|
||||||
|
val feeds: StateFlow<ImmutableList<FeedDefinition>> = _feeds.asStateFlow()
|
||||||
|
|
||||||
|
val groupedFeeds: StateFlow<GroupedFeeds> =
|
||||||
|
_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<ImmutableList<FeedDefinition>> =
|
||||||
|
groupedFeeds
|
||||||
|
.map { it.pinned }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<FeedEvent>(replay = 0)
|
||||||
|
val events: SharedFlow<FeedEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
fun load(feeds: List<FeedDefinition>) {
|
||||||
|
_feeds.value = feeds.toImmutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(): List<FeedDefinition> = _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<FeedDefinition>,
|
||||||
|
val myFeeds: ImmutableList<FeedDefinition>,
|
||||||
|
val algoFeeds: ImmutableList<FeedDefinition>,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = GroupedFeeds(persistentListOf(), persistentListOf(), persistentListOf())
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -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<FeedDefinition>): String {
|
||||||
|
val array = mapper.createArrayNode()
|
||||||
|
feeds.forEach { feed -> array.add(serializeFeed(feed)) }
|
||||||
|
return mapper.writeValueAsString(array)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deserializeList(json: String): List<FeedDefinition> {
|
||||||
|
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<ObjectNode>("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<ArrayNode>("hashtags", mapper.valueToTree(source.hashtags.toList()))
|
||||||
|
set<ArrayNode>("authors", mapper.valueToTree(source.authors.toList()))
|
||||||
|
set<ArrayNode>("relays", mapper.valueToTree(source.relays.toList()))
|
||||||
|
set<ArrayNode>("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList()))
|
||||||
|
set<ArrayNode>("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList()))
|
||||||
|
set<ArrayNode>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -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()
|
||||||
+224
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -690,6 +690,10 @@ fun App(
|
|||||||
return // Nothing below runs until Tor is Active
|
return // Nothing below runs until Tor is Active
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var appDrawerInitialTab by remember {
|
||||||
|
mutableStateOf<com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
val localCache = remember { DesktopLocalCache() }
|
val localCache = remember { DesktopLocalCache() }
|
||||||
val accountState by accountManager.accountState.collectAsState()
|
val accountState by accountManager.accountState.collectAsState()
|
||||||
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
||||||
@@ -943,6 +947,11 @@ fun App(
|
|||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
onShowAppDrawer = onShowAppDrawer,
|
onShowAppDrawer = onShowAppDrawer,
|
||||||
|
onOpenFeedsDrawer = {
|
||||||
|
appDrawerInitialTab =
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
|
||||||
|
onShowAppDrawer()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -960,6 +969,7 @@ fun App(
|
|||||||
if (showAppDrawer) {
|
if (showAppDrawer) {
|
||||||
val openColumns by deckState.columns.collectAsState()
|
val openColumns by deckState.columns.collectAsState()
|
||||||
AppDrawer(
|
AppDrawer(
|
||||||
|
initialTab = appDrawerInitialTab,
|
||||||
openColumnTypes =
|
openColumnTypes =
|
||||||
if (layoutMode == LayoutMode.DECK) {
|
if (layoutMode == LayoutMode.DECK) {
|
||||||
openColumns.map { it.type.typeKey() }.toSet()
|
openColumns.map { it.type.typeKey() }.toSet()
|
||||||
@@ -1002,7 +1012,10 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDismiss = onDismissAppDrawer,
|
onDismiss = {
|
||||||
|
appDrawerInitialTab = null
|
||||||
|
onDismissAppDrawer()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1040,6 +1053,7 @@ fun MainContent(
|
|||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onShowAppDrawer: () -> Unit,
|
onShowAppDrawer: () -> Unit,
|
||||||
|
onOpenFeedsDrawer: () -> Unit = onShowAppDrawer,
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -1230,6 +1244,7 @@ fun MainContent(
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalRelayCategories provides relayCategories,
|
LocalRelayCategories provides relayCategories,
|
||||||
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
|
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
|
||||||
|
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
@@ -1252,6 +1267,7 @@ fun MainContent(
|
|||||||
singlePaneState = singlePaneState,
|
singlePaneState = singlePaneState,
|
||||||
pinnedNavBarState = pinnedNavBarState,
|
pinnedNavBarState = pinnedNavBarState,
|
||||||
onOpenAppDrawer = onShowAppDrawer,
|
onOpenAppDrawer = onShowAppDrawer,
|
||||||
|
onOpenFeedsDrawer = onOpenFeedsDrawer,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
|
|||||||
+55
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.feeds
|
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.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
|
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
|
||||||
import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder
|
import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder
|
||||||
@@ -102,6 +103,60 @@ class DesktopFollowingFeedFilter(
|
|||||||
override fun limit(): Int = 2500
|
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<Note>() {
|
||||||
|
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<Note> =
|
||||||
|
cache.notes
|
||||||
|
.filterIntoSet { _, note -> matchesSource(note) }
|
||||||
|
.sortedWith(DefaultFeedOrder)
|
||||||
|
.deduplicateReposts()
|
||||||
|
.take(limit())
|
||||||
|
|
||||||
|
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { matchesSource(it) }
|
||||||
|
|
||||||
|
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
|
||||||
|
|
||||||
|
override fun limit(): Int = 2500
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thread feed: root note + all replies (graph walk via Note.replies).
|
* Thread feed: root note + all replies (graph walk via Note.replies).
|
||||||
*/
|
*/
|
||||||
|
|||||||
+77
@@ -20,9 +20,11 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.subscriptions
|
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.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feed mode for feed subscriptions.
|
* Feed mode for feed subscriptions.
|
||||||
@@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
|||||||
enum class FeedMode {
|
enum class FeedMode {
|
||||||
GLOBAL,
|
GLOBAL,
|
||||||
FOLLOWING,
|
FOLLOWING,
|
||||||
|
CUSTOM,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -362,3 +365,77 @@ fun createChessSubscription(
|
|||||||
onEvent = onEvent,
|
onEvent = onEvent,
|
||||||
onEose = onEose,
|
onEose = onEose,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a subscription config for a custom feed based on FeedSource.Filter.
|
||||||
|
*/
|
||||||
|
fun createCustomFeedSubscription(
|
||||||
|
source: FeedSource.Filter,
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
limit: Int = 200,
|
||||||
|
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||||
|
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||||
|
): SubscriptionConfig? {
|
||||||
|
val filters = mutableListOf<Filter>()
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+133
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
-11
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
|||||||
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
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.DesktopFollowingFeedFilter
|
||||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
|
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
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.FilterBuilders
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
|
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.createFollowingFeedSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||||
@@ -274,6 +276,9 @@ fun FeedScreen(
|
|||||||
localCache: DesktopLocalCache,
|
localCache: DesktopLocalCache,
|
||||||
account: AccountState.LoggedIn? = null,
|
account: AccountState.LoggedIn? = null,
|
||||||
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount? = 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,
|
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
||||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||||
initialFeedMode: FeedMode? = null,
|
initialFeedMode: FeedMode? = null,
|
||||||
@@ -297,7 +302,15 @@ fun FeedScreen(
|
|||||||
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
var replyToEvent by remember { mutableStateOf<Event?>(null) }
|
||||||
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
|
||||||
var showRelayPicker by remember { mutableStateOf(false) }
|
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
|
// Subscribe to contact list (kind 3) — populates localCache.followedUsers
|
||||||
rememberSubscription(allRelayUrls, account, relayManager = relayManager) {
|
rememberSubscription(allRelayUrls, account, relayManager = relayManager) {
|
||||||
@@ -315,7 +328,7 @@ fun FeedScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to feed events (kind 1) — populates cache via coordinator
|
// 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
|
if (feedRelays.isEmpty()) return@rememberSubscription null
|
||||||
|
|
||||||
when (feedMode) {
|
when (feedMode) {
|
||||||
@@ -342,12 +355,27 @@ fun FeedScreen(
|
|||||||
null
|
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
|
// DesktopFeedViewModel keyed on feedMode — recreated on mode switch
|
||||||
val viewModel =
|
val viewModel =
|
||||||
remember(feedMode) {
|
remember(feedMode, activeFeedId) {
|
||||||
val filter =
|
val filter =
|
||||||
when (feedMode) {
|
when (feedMode) {
|
||||||
FeedMode.GLOBAL -> {
|
FeedMode.GLOBAL -> {
|
||||||
@@ -359,6 +387,15 @@ fun FeedScreen(
|
|||||||
localCache.followedUsers.value
|
localCache.followedUsers.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FeedMode.CUSTOM -> {
|
||||||
|
DesktopCustomFeedFilter(
|
||||||
|
localCache,
|
||||||
|
activeFeedId ?: "custom",
|
||||||
|
activeFeedSource ?: com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
|
||||||
|
.Filter(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DesktopFeedViewModel(filter, localCache)
|
DesktopFeedViewModel(filter, localCache)
|
||||||
}
|
}
|
||||||
@@ -489,20 +526,28 @@ fun FeedScreen(
|
|||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
ReadingColumn {
|
ReadingColumn {
|
||||||
// Header with compose button
|
// Header: pinned feed tabs + "Show More +"
|
||||||
FeedHeader(
|
FeedTabsHeader(
|
||||||
feedMode = feedMode,
|
feedMode = feedMode,
|
||||||
account = account,
|
activeFeedId = activeFeedId,
|
||||||
feedRelays = feedRelays,
|
|
||||||
followedUsersCount = followedUsers.size,
|
|
||||||
onFeedModeChange = { mode ->
|
onFeedModeChange = { mode ->
|
||||||
feedMode = mode
|
feedMode = mode
|
||||||
|
activeFeedId = null
|
||||||
|
activeFeedSource = null
|
||||||
|
if (mode != FeedMode.CUSTOM) {
|
||||||
DesktopPreferences.feedMode = mode
|
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,
|
onCompose = onCompose,
|
||||||
onNavigateToRelays = onNavigateToRelays,
|
|
||||||
onOpenRelayPicker = { showRelayPicker = true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -665,6 +710,83 @@ fun FeedScreen(
|
|||||||
* the relays icon (desktop convention \u2014 the at-a-glance info is preserved
|
* the relays icon (desktop convention \u2014 the at-a-glance info is preserved
|
||||||
* without stealing header real estate).
|
* 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)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun FeedHeader(
|
private fun FeedHeader(
|
||||||
|
|||||||
@@ -281,6 +281,10 @@ fun ReadsScreen(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FeedMode.CUSTOM -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-2
@@ -74,6 +74,8 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
|
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.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
|
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.amethyst.desktop.ui.search.SearchSyncBanner
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchScreen(
|
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
|
// Search relay picker dialog
|
||||||
@@ -510,7 +536,7 @@ fun SearchScreen(
|
|||||||
onExcludeRemoved = { state.removeExcludeTerm(it) },
|
onExcludeRemoved = { state.removeExcludeTerm(it) },
|
||||||
onLanguageChanged = { state.updateLanguage(it) },
|
onLanguageChanged = { state.updateLanguage(it) },
|
||||||
onClear = { state.clearSearch() },
|
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()) {
|
if (bech32Results.isNotEmpty()) {
|
||||||
// Show bech32 results (exact lookup)
|
// Show bech32 results (exact lookup)
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = sidePadding),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Direct lookup",
|
"Direct lookup",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@@ -544,12 +573,14 @@ fun SearchScreen(
|
|||||||
onNavigateToProfile = onNavigateToProfile,
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
onNavigateToThread = onNavigateToThread,
|
onNavigateToThread = onNavigateToThread,
|
||||||
localCache = localCache,
|
localCache = localCache,
|
||||||
|
modifier = Modifier.padding(horizontal = sidePadding),
|
||||||
)
|
)
|
||||||
} else if (!debouncedQuery.isEmpty && !isSearching) {
|
} else if (!debouncedQuery.isEmpty && !isSearching) {
|
||||||
Text(
|
Text(
|
||||||
"No results found. Try broader terms or fewer filters.",
|
"No results found. Try broader terms or fewer filters.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = sidePadding),
|
||||||
)
|
)
|
||||||
} else if (!isSearching) {
|
} else if (!isSearching) {
|
||||||
// Empty state: show history + saved searches + operator hints
|
// Empty state: show history + saved searches + operator hints
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
@@ -137,6 +138,7 @@ fun DeckColumnType.category(): ScreenCategory =
|
|||||||
is DeckColumnType.Profile,
|
is DeckColumnType.Profile,
|
||||||
is DeckColumnType.Thread,
|
is DeckColumnType.Thread,
|
||||||
is DeckColumnType.Article,
|
is DeckColumnType.Article,
|
||||||
|
is DeckColumnType.CustomFeed,
|
||||||
-> ScreenCategory.SOCIAL
|
-> ScreenCategory.SOCIAL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ fun DeckColumnType.param(): String? =
|
|||||||
is DeckColumnType.Hashtag -> tag
|
is DeckColumnType.Hashtag -> tag
|
||||||
is DeckColumnType.Editor -> draftSlug
|
is DeckColumnType.Editor -> draftSlug
|
||||||
is DeckColumnType.Article -> addressTag
|
is DeckColumnType.Article -> addressTag
|
||||||
|
is DeckColumnType.CustomFeed -> feedId
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +183,7 @@ val LAUNCHABLE_SCREENS: List<DeckColumnType> =
|
|||||||
enum class AppDrawerTab {
|
enum class AppDrawerTab {
|
||||||
SCREENS,
|
SCREENS,
|
||||||
WORKSPACES,
|
WORKSPACES,
|
||||||
|
FEEDS,
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- State --
|
// -- State --
|
||||||
@@ -284,6 +288,10 @@ private class AppDrawerState {
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppDrawerTab.FEEDS -> {
|
||||||
|
// Feed selection handled by FeedsDrawerTab's own click handlers
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,6 +317,7 @@ fun AppDrawer(
|
|||||||
onSwitchWorkspace: (Workspace) -> Unit,
|
onSwitchWorkspace: (Workspace) -> Unit,
|
||||||
onSelectScreen: (DeckColumnType) -> Unit,
|
onSelectScreen: (DeckColumnType) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
initialTab: AppDrawerTab? = null,
|
||||||
) {
|
) {
|
||||||
val state = remember { AppDrawerState() }
|
val state = remember { AppDrawerState() }
|
||||||
val searchFocusRequester = remember { FocusRequester() }
|
val searchFocusRequester = remember { FocusRequester() }
|
||||||
@@ -317,6 +326,11 @@ fun AppDrawer(
|
|||||||
derivedStateOf { state.filteredWorkspaces(allWorkspaces) }
|
derivedStateOf { state.filteredWorkspaces(allWorkspaces) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set initial tab if specified
|
||||||
|
LaunchedEffect(initialTab) {
|
||||||
|
if (initialTab != null) state.switchTab(initialTab)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(50)
|
delay(50)
|
||||||
searchFocusRequester.requestFocus()
|
searchFocusRequester.requestFocus()
|
||||||
@@ -454,6 +468,22 @@ fun AppDrawer(
|
|||||||
onDismiss = onDismiss,
|
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 activeIndex by workspaceManager.activeIndex.collectAsState()
|
||||||
val allWorkspaces by workspaceManager.workspaces.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)) {
|
LazyColumn(Modifier.padding(8.dp)) {
|
||||||
// Workspace results first
|
// Workspace results first
|
||||||
if (filteredWs.isNotEmpty()) {
|
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
|
// Screen results below
|
||||||
if (state.filteredScreens.isNotEmpty()) {
|
if (state.filteredScreens.isNotEmpty()) {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
|
|||||||
+1
@@ -128,4 +128,5 @@ fun DeckColumnType.icon(): MaterialSymbol =
|
|||||||
is DeckColumnType.Profile -> MaterialSymbols.Person
|
is DeckColumnType.Profile -> MaterialSymbols.Person
|
||||||
is DeckColumnType.Thread -> MaterialSymbols.AutoMirrored.Article
|
is DeckColumnType.Thread -> MaterialSymbols.AutoMirrored.Article
|
||||||
is DeckColumnType.Hashtag -> MaterialSymbols.Tag
|
is DeckColumnType.Hashtag -> MaterialSymbols.Tag
|
||||||
|
is DeckColumnType.CustomFeed -> MaterialSymbols.Tune
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
|
|||||||
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
|
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen
|
import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
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.DraftsScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
|
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
|
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
|
||||||
@@ -208,6 +209,7 @@ internal fun RootContent(
|
|||||||
onNavigateToArticle: (String) -> Unit = {},
|
onNavigateToArticle: (String) -> Unit = {},
|
||||||
onNavigateToEditor: (String?) -> Unit = {},
|
onNavigateToEditor: (String?) -> Unit = {},
|
||||||
onNavigateToRelays: () -> Unit = {},
|
onNavigateToRelays: () -> Unit = {},
|
||||||
|
onOpenFeedsDrawer: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -226,6 +228,7 @@ internal fun RootContent(
|
|||||||
onNavigateToThread = onNavigateToThread,
|
onNavigateToThread = onNavigateToThread,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
onNavigateToRelays = onNavigateToRelays,
|
onNavigateToRelays = onNavigateToRelays,
|
||||||
|
onOpenFeedsDrawer = onOpenFeedsDrawer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +432,18 @@ internal fun RootContent(
|
|||||||
onNavigateToThread = onNavigateToThread,
|
onNavigateToThread = onNavigateToThread,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is DeckColumnType.CustomFeed -> {
|
||||||
|
CustomFeedScreen(
|
||||||
|
feedId = columnType.feedId,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
@@ -69,6 +69,12 @@ sealed class DeckColumnType {
|
|||||||
val tag: String,
|
val tag: String,
|
||||||
) : DeckColumnType()
|
) : DeckColumnType()
|
||||||
|
|
||||||
|
data class CustomFeed(
|
||||||
|
val feedId: String,
|
||||||
|
val feedName: String = "",
|
||||||
|
val feedEmoji: String = "",
|
||||||
|
) : DeckColumnType()
|
||||||
|
|
||||||
fun title(): String =
|
fun title(): String =
|
||||||
when (this) {
|
when (this) {
|
||||||
HomeFeed -> "Home"
|
HomeFeed -> "Home"
|
||||||
@@ -89,6 +95,7 @@ sealed class DeckColumnType {
|
|||||||
is Profile -> "Profile"
|
is Profile -> "Profile"
|
||||||
is Thread -> "Thread"
|
is Thread -> "Thread"
|
||||||
is Hashtag -> "#$tag"
|
is Hashtag -> "#$tag"
|
||||||
|
is CustomFeed -> feedName.ifEmpty { "Feed" }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun typeKey(): String =
|
fun typeKey(): String =
|
||||||
@@ -111,6 +118,7 @@ sealed class DeckColumnType {
|
|||||||
is Profile -> "profile"
|
is Profile -> "profile"
|
||||||
is Thread -> "thread"
|
is Thread -> "thread"
|
||||||
is Hashtag -> "hashtag"
|
is Hashtag -> "hashtag"
|
||||||
|
is CustomFeed -> "custom_feed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ class DeckState(
|
|||||||
"profile" -> param?.let { DeckColumnType.Profile(it) }
|
"profile" -> param?.let { DeckColumnType.Profile(it) }
|
||||||
"thread" -> param?.let { DeckColumnType.Thread(it) }
|
"thread" -> param?.let { DeckColumnType.Thread(it) }
|
||||||
"hashtag" -> param?.let { DeckColumnType.Hashtag(it) }
|
"hashtag" -> param?.let { DeckColumnType.Hashtag(it) }
|
||||||
|
"custom_feed" -> param?.let { DeckColumnType.CustomFeed(feedId = it) }
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+427
@@ -0,0 +1,427 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.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,
|
||||||
|
onSave: (FeedDefinition) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val state = remember(initial) { FeedBuilderState(initial) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
modifier =
|
||||||
|
Modifier.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,
|
||||||
|
onAdd = { hex -> if (hex !in state.authors) state.authors.add(hex) },
|
||||||
|
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<String>,
|
||||||
|
localCache: DesktopLocalCache?,
|
||||||
|
onAdd: (String) -> Unit,
|
||||||
|
onRemove: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// Show added authors as chips with display names
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
var input by remember { mutableStateOf("") }
|
||||||
|
val suggestions =
|
||||||
|
remember(input, authors.toList()) {
|
||||||
|
if (input.length < 2 || localCache == null) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
localCache
|
||||||
|
.findUsersStartingWith(input, 8)
|
||||||
|
.filter { it.pubkeyHex !in authors }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = input,
|
||||||
|
onValueChange = { input = it },
|
||||||
|
placeholder = { Text("Search name or paste npub...") },
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth().onPreviewKeyEvent { event ->
|
||||||
|
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter) {
|
||||||
|
if (input.isNotBlank()) {
|
||||||
|
val hex = decodePublicKeyAsHexOrNull(input.trim()) ?: input.trim()
|
||||||
|
onAdd(hex)
|
||||||
|
input = ""
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Suggestions dropdown
|
||||||
|
if (suggestions.isNotEmpty()) {
|
||||||
|
Surface(
|
||||||
|
tonalElevation = 4.dp,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(max = 160.dp),
|
||||||
|
) {
|
||||||
|
LazyColumn {
|
||||||
|
items(suggestions, key = { it.pubkeyHex }) { user ->
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
onAdd(user.pubkeyHex)
|
||||||
|
input = ""
|
||||||
|
}.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
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<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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<Int>) {
|
||||||
|
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<String>,
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
/*
|
||||||
|
* 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.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,
|
||||||
|
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<FeedDefinition?>(null) }
|
||||||
|
var deletingFeed by remember { mutableStateOf<FeedDefinition?>(null) }
|
||||||
|
|
||||||
|
// Create dialog
|
||||||
|
if (showBuilder) {
|
||||||
|
FeedBuilderDialog(
|
||||||
|
localCache = localCache,
|
||||||
|
onSave = { feed ->
|
||||||
|
scope.launch { feedRepository.add(feed) }
|
||||||
|
showBuilder = false
|
||||||
|
},
|
||||||
|
onDismiss = { showBuilder = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit dialog
|
||||||
|
editingFeed?.let { feed ->
|
||||||
|
FeedBuilderDialog(
|
||||||
|
initial = feed,
|
||||||
|
localCache = localCache,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* 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 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<FeedDefinitionRepository> {
|
||||||
|
defaultRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalFeedScope =
|
||||||
|
compositionLocalOf<CoroutineScope> {
|
||||||
|
GlobalScope
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalDesktopCache =
|
||||||
|
compositionLocalOf<DesktopLocalCache?> {
|
||||||
|
null
|
||||||
|
}
|
||||||
+6
-2
@@ -87,6 +87,7 @@ fun SinglePaneLayout(
|
|||||||
singlePaneState: SinglePaneState,
|
singlePaneState: SinglePaneState,
|
||||||
pinnedNavBarState: PinnedNavBarState,
|
pinnedNavBarState: PinnedNavBarState,
|
||||||
onOpenAppDrawer: () -> Unit,
|
onOpenAppDrawer: () -> Unit,
|
||||||
|
onOpenFeedsDrawer: () -> Unit = onOpenAppDrawer,
|
||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onZapFeedback: (ZapFeedback) -> Unit,
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
@@ -112,6 +113,8 @@ fun SinglePaneLayout(
|
|||||||
) {
|
) {
|
||||||
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
|
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
|
||||||
pinnedScreens.forEach { screenType ->
|
pinnedScreens.forEach { screenType ->
|
||||||
|
// Rename "Home" to "Feeds" in the nav rail
|
||||||
|
val label = if (screenType == DeckColumnType.HomeFeed) "Feeds" else screenType.title()
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
selected = currentColumnType == screenType && navStack.isEmpty(),
|
selected = currentColumnType == screenType && navStack.isEmpty(),
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -121,13 +124,13 @@ fun SinglePaneLayout(
|
|||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
screenType.icon(),
|
screenType.icon(),
|
||||||
contentDescription = screenType.title(),
|
contentDescription = label,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
screenType.title(),
|
label,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -249,6 +252,7 @@ fun SinglePaneLayout(
|
|||||||
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
|
||||||
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
|
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
|
||||||
onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) },
|
onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) },
|
||||||
|
onOpenFeedsDrawer = onOpenFeedsDrawer,
|
||||||
)
|
)
|
||||||
if (currentOverlay != null) {
|
if (currentOverlay != null) {
|
||||||
Surface(
|
Surface(
|
||||||
|
|||||||
+6
-3
@@ -88,23 +88,26 @@ fun AnimatedGifImage(
|
|||||||
val data = gifFrames
|
val data = gifFrames
|
||||||
when {
|
when {
|
||||||
data != null && data.frames.size > 1 -> {
|
data != null && data.frames.size > 1 -> {
|
||||||
|
val safeFrame = currentFrame.coerceIn(0, data.frames.size - 1)
|
||||||
|
|
||||||
LaunchedEffect(data) {
|
LaunchedEffect(data) {
|
||||||
while (isActive) {
|
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())
|
delay(duration.toLong())
|
||||||
currentFrame = (currentFrame + 1) % data.frames.size
|
currentFrame = (currentFrame + 1) % data.frames.size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Image(
|
Image(
|
||||||
bitmap = data.frames[currentFrame],
|
bitmap = data.frames[safeFrame],
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data != null -> {
|
data != null && data.frames.isNotEmpty() -> {
|
||||||
Image(
|
Image(
|
||||||
bitmap = data.frames[0],
|
bitmap = data.frames[0],
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
|
|||||||
+53
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user