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:
nrobi144
2026-05-05 14:48:39 +03:00
parent 0874706b31
commit 4010a57e68
29 changed files with 3262 additions and 20 deletions
@@ -690,6 +690,10 @@ fun App(
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 accountState by accountManager.accountState.collectAsState()
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
@@ -943,6 +947,11 @@ fun App(
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onShowAppDrawer = onShowAppDrawer,
onOpenFeedsDrawer = {
appDrawerInitialTab =
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
onShowAppDrawer()
},
)
}
@@ -960,6 +969,7 @@ fun App(
if (showAppDrawer) {
val openColumns by deckState.columns.collectAsState()
AppDrawer(
initialTab = appDrawerInitialTab,
openColumnTypes =
if (layoutMode == LayoutMode.DECK) {
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,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onShowAppDrawer: () -> Unit,
onOpenFeedsDrawer: () -> Unit = onShowAppDrawer,
) {
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
@@ -1230,6 +1244,7 @@ fun MainContent(
CompositionLocalProvider(
LocalRelayCategories provides relayCategories,
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
@@ -1252,6 +1267,7 @@ fun MainContent(
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
onOpenAppDrawer = onShowAppDrawer,
onOpenFeedsDrawer = onOpenFeedsDrawer,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onZapFeedback = onZapFeedback,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.desktop.feeds
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.ui.feeds.AdditiveFeedFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.DefaultFeedOrder
@@ -102,6 +103,60 @@ class DesktopFollowingFeedFilter(
override fun limit(): Int = 2500
}
/**
* Custom feed filter: matches events based on FeedSource.Filter criteria.
* Excludes are applied client-side (relay can't express NOT filters).
*/
class DesktopCustomFeedFilter(
private val cache: DesktopLocalCache,
private val feedId: String,
private val source: FeedSource.Filter,
) : AdditiveFeedFilter<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).
*/
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.amethyst.desktop.subscriptions
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
/**
* Feed mode for feed subscriptions.
@@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
enum class FeedMode {
GLOBAL,
FOLLOWING,
CUSTOM,
}
/**
@@ -362,3 +365,77 @@ fun createChessSubscription(
onEvent = onEvent,
onEose = onEose,
)
/**
* Creates a subscription config for a custom feed based on FeedSource.Filter.
*/
fun createCustomFeedSubscription(
source: FeedSource.Filter,
relays: Set<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,
)
}
@@ -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)
}
}
}
}
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopCustomFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopFollowingFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopGlobalFeedFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
@@ -75,6 +76,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createCustomFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createFollowingFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createGlobalFeedSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
@@ -274,6 +276,9 @@ fun FeedScreen(
localCache: DesktopLocalCache,
account: AccountState.LoggedIn? = null,
iAccount: com.vitorpamplona.amethyst.desktop.model.DesktopIAccount? = null,
customFeedId: String? = null,
customFeedSource: com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter? = null,
onOpenFeedsDrawer: () -> Unit = {},
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
initialFeedMode: FeedMode? = null,
@@ -297,7 +302,15 @@ fun FeedScreen(
var replyToEvent by remember { mutableStateOf<Event?>(null) }
var lightboxState by remember { mutableStateOf<LightboxState?>(null) }
var showRelayPicker by remember { mutableStateOf(false) }
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
var activeFeedId by remember { mutableStateOf(customFeedId) }
var activeFeedSource by remember {
mutableStateOf(customFeedSource)
}
var feedMode by remember {
mutableStateOf(
if (customFeedSource != null) FeedMode.CUSTOM else (initialFeedMode ?: DesktopPreferences.feedMode),
)
}
// Subscribe to contact list (kind 3) — populates localCache.followedUsers
rememberSubscription(allRelayUrls, account, relayManager = relayManager) {
@@ -315,7 +328,7 @@ fun FeedScreen(
}
// Subscribe to feed events (kind 1) — populates cache via coordinator
rememberSubscription(feedRelays, feedMode, followedUsers, relayManager = relayManager) {
rememberSubscription(feedRelays, feedMode, followedUsers, activeFeedSource, relayManager = relayManager) {
if (feedRelays.isEmpty()) return@rememberSubscription null
when (feedMode) {
@@ -342,12 +355,27 @@ fun FeedScreen(
null
}
}
FeedMode.CUSTOM -> {
val src = activeFeedSource
if (src != null) {
createCustomFeedSubscription(
source = src,
relays = feedRelays,
onEvent = { event, _, relay, _ ->
subscriptionsCoordinator?.consumeEvent(event, relay)
},
)
} else {
null
}
}
}
}
// DesktopFeedViewModel keyed on feedMode — recreated on mode switch
val viewModel =
remember(feedMode) {
remember(feedMode, activeFeedId) {
val filter =
when (feedMode) {
FeedMode.GLOBAL -> {
@@ -359,6 +387,15 @@ fun FeedScreen(
localCache.followedUsers.value
}
}
FeedMode.CUSTOM -> {
DesktopCustomFeedFilter(
localCache,
activeFeedId ?: "custom",
activeFeedSource ?: com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource
.Filter(),
)
}
}
DesktopFeedViewModel(filter, localCache)
}
@@ -489,20 +526,28 @@ fun FeedScreen(
Box(modifier = Modifier.fillMaxSize()) {
ReadingColumn {
// Header with compose button
FeedHeader(
// Header: pinned feed tabs + "Show More +"
FeedTabsHeader(
feedMode = feedMode,
account = account,
feedRelays = feedRelays,
followedUsersCount = followedUsers.size,
activeFeedId = activeFeedId,
onFeedModeChange = { mode ->
feedMode = mode
DesktopPreferences.feedMode = mode
activeFeedId = null
activeFeedSource = null
if (mode != FeedMode.CUSTOM) {
DesktopPreferences.feedMode = mode
}
},
onRefresh = { relayManager.connect() },
onNavigateToFeed = { feed ->
val source = feed.source
if (source is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Filter) {
activeFeedId = feed.id
activeFeedSource = source
feedMode = FeedMode.CUSTOM
}
},
onOpenFeedsDrawer = onOpenFeedsDrawer,
onCompose = onCompose,
onNavigateToRelays = onNavigateToRelays,
onOpenRelayPicker = { showRelayPicker = true },
)
Spacer(Modifier.height(8.dp))
@@ -665,6 +710,83 @@ fun FeedScreen(
* the relays icon (desktop convention \u2014 the at-a-glance info is preserved
* without stealing header real estate).
*/
@Composable
private fun FeedTabsHeader(
feedMode: FeedMode,
activeFeedId: String? = null,
onFeedModeChange: (FeedMode) -> Unit,
onNavigateToFeed: (com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition) -> Unit = {},
onOpenFeedsDrawer: () -> Unit,
onCompose: () -> Unit,
) {
val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current
val pinnedFeeds by feedRepo.pinnedFeeds.collectAsState()
val sidePadding = LocalReadingSidePadding.current
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = sidePadding + 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
// Pinned feed tabs
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
pinnedFeeds.forEach { feed ->
val isSelected =
when (feed.source) {
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> {
feedMode == FeedMode.FOLLOWING
}
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> {
feedMode == FeedMode.GLOBAL
}
else -> {
activeFeedId == feed.id
}
}
FilterChip(
selected = isSelected,
onClick = {
when (feed.source) {
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> {
onFeedModeChange(FeedMode.FOLLOWING)
}
is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> {
onFeedModeChange(FeedMode.GLOBAL)
}
else -> {
onNavigateToFeed(feed)
}
}
},
label = { Text("${feed.emoji} ${feed.name}") },
)
}
// "Show More +" button
FilterChip(
selected = false,
onClick = onOpenFeedsDrawer,
label = { Text("+ More") },
)
}
// Compose button
IconButton(onClick = onCompose) {
Icon(
MaterialSymbols.Edit,
contentDescription = "Compose",
modifier = Modifier.size(20.dp),
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FeedHeader(
@@ -281,6 +281,10 @@ fun ReadsScreen(
null
}
}
FeedMode.CUSTOM -> {
null
}
}
}
@@ -74,6 +74,8 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
import com.vitorpamplona.amethyst.commons.feeds.custom.canBecomeFeed
import com.vitorpamplona.amethyst.commons.feeds.custom.toFeedDefinition
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
@@ -102,6 +104,7 @@ import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.launch
@Composable
fun SearchScreen(
@@ -459,6 +462,29 @@ fun SearchScreen(
},
)
}
// "Save as Feed" button — visible when query has feed-able criteria
if (query.canBecomeFeed()) {
val feedRepo = com.vitorpamplona.amethyst.desktop.ui.deck.LocalFeedRepository.current
var showFeedBuilder by remember { mutableStateOf(false) }
IconButton(onClick = { showFeedBuilder = true }) {
Icon(
MaterialSymbols.Bookmark,
contentDescription = "Save as Feed",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (showFeedBuilder) {
com.vitorpamplona.amethyst.desktop.ui.deck.FeedBuilderDialog(
initial = query.toFeedDefinition(name = ""),
localCache = localCache,
onSave = { feed ->
scope.launch { feedRepo.add(feed) }
showFeedBuilder = false
},
onDismiss = { showFeedBuilder = false },
)
}
}
}
// Search relay picker dialog
@@ -510,7 +536,7 @@ fun SearchScreen(
onExcludeRemoved = { state.removeExcludeTerm(it) },
onLanguageChanged = { state.updateLanguage(it) },
onClear = { state.clearSearch() },
modifier = Modifier.padding(top = 8.dp),
modifier = Modifier.padding(top = 8.dp, start = sidePadding, end = sidePadding),
)
}
@@ -522,7 +548,10 @@ fun SearchScreen(
if (bech32Results.isNotEmpty()) {
// Show bech32 results (exact lookup)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = sidePadding),
) {
Text(
"Direct lookup",
style = MaterialTheme.typography.labelMedium,
@@ -544,12 +573,14 @@ fun SearchScreen(
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
localCache = localCache,
modifier = Modifier.padding(horizontal = sidePadding),
)
} else if (!debouncedQuery.isEmpty && !isSearching) {
Text(
"No results found. Try broader terms or fewer filters.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = sidePadding),
)
} else if (!isSearching) {
// Empty state: show history + saved searches + operator hints
@@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
@@ -137,6 +138,7 @@ fun DeckColumnType.category(): ScreenCategory =
is DeckColumnType.Profile,
is DeckColumnType.Thread,
is DeckColumnType.Article,
is DeckColumnType.CustomFeed,
-> ScreenCategory.SOCIAL
}
@@ -153,6 +155,7 @@ fun DeckColumnType.param(): String? =
is DeckColumnType.Hashtag -> tag
is DeckColumnType.Editor -> draftSlug
is DeckColumnType.Article -> addressTag
is DeckColumnType.CustomFeed -> feedId
else -> null
}
@@ -180,6 +183,7 @@ val LAUNCHABLE_SCREENS: List<DeckColumnType> =
enum class AppDrawerTab {
SCREENS,
WORKSPACES,
FEEDS,
}
// -- State --
@@ -284,6 +288,10 @@ private class AppDrawerState {
onDismiss()
}
}
AppDrawerTab.FEEDS -> {
// Feed selection handled by FeedsDrawerTab's own click handlers
}
}
}
}
@@ -309,6 +317,7 @@ fun AppDrawer(
onSwitchWorkspace: (Workspace) -> Unit,
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
initialTab: AppDrawerTab? = null,
) {
val state = remember { AppDrawerState() }
val searchFocusRequester = remember { FocusRequester() }
@@ -317,6 +326,11 @@ fun AppDrawer(
derivedStateOf { state.filteredWorkspaces(allWorkspaces) }
}
// Set initial tab if specified
LaunchedEffect(initialTab) {
if (initialTab != null) state.switchTab(initialTab)
}
LaunchedEffect(Unit) {
delay(50)
searchFocusRequester.requestFocus()
@@ -454,6 +468,22 @@ fun AppDrawer(
onDismiss = onDismiss,
)
}
AppDrawerTab.FEEDS -> {
FeedsDrawerTab(
onSelectFeed = { feedDef ->
onSelectScreen(
DeckColumnType.CustomFeed(
feedId = feedDef.id,
feedName = feedDef.name,
feedEmoji = feedDef.emoji,
),
)
onDismiss()
},
onDismiss = onDismiss,
)
}
}
}
}
@@ -1099,6 +1129,14 @@ private fun UnifiedSearchResults(
val activeIndex by workspaceManager.activeIndex.collectAsState()
val allWorkspaces by workspaceManager.workspaces.collectAsState()
val feedRepo = LocalFeedRepository.current
val allFeeds by feedRepo.feeds.collectAsState()
val filteredFeeds =
allFeeds.filter { feed ->
feed.name.contains(state.searchQuery, ignoreCase = true) ||
feed.emoji.contains(state.searchQuery)
}
LazyColumn(Modifier.padding(8.dp)) {
// Workspace results first
if (filteredWs.isNotEmpty()) {
@@ -1147,6 +1185,46 @@ private fun UnifiedSearchResults(
}
}
}
// Feed results
if (filteredFeeds.isNotEmpty()) {
stickyHeader {
Text(
"Feeds",
style = MaterialTheme.typography.titleSmall,
modifier =
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(filteredFeeds, key = { "feed-${it.id}" }) { feed ->
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.clickable {
onSelectScreen(
DeckColumnType.CustomFeed(feed.id, feed.name, feed.emoji),
)
onDismiss()
},
tonalElevation = 0.dp,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(feed.emoji.ifEmpty { "\uD83D\uDCCB" }, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.width(12.dp))
Text(feed.name, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
// Screen results below
if (state.filteredScreens.isNotEmpty()) {
stickyHeader {
@@ -128,4 +128,5 @@ fun DeckColumnType.icon(): MaterialSymbol =
is DeckColumnType.Profile -> MaterialSymbols.Person
is DeckColumnType.Thread -> MaterialSymbols.AutoMirrored.Article
is DeckColumnType.Hashtag -> MaterialSymbols.Tag
is DeckColumnType.CustomFeed -> MaterialSymbols.Tune
}
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode
import com.vitorpamplona.amethyst.desktop.ui.ArticleEditorScreen
import com.vitorpamplona.amethyst.desktop.ui.ArticleReaderScreen
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
import com.vitorpamplona.amethyst.desktop.ui.CustomFeedScreen
import com.vitorpamplona.amethyst.desktop.ui.DraftsScreen
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen
@@ -208,6 +209,7 @@ internal fun RootContent(
onNavigateToArticle: (String) -> Unit = {},
onNavigateToEditor: (String?) -> Unit = {},
onNavigateToRelays: () -> Unit = {},
onOpenFeedsDrawer: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
@@ -226,6 +228,7 @@ internal fun RootContent(
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
onNavigateToRelays = onNavigateToRelays,
onOpenFeedsDrawer = onOpenFeedsDrawer,
)
}
@@ -429,6 +432,18 @@ internal fun RootContent(
onNavigateToThread = onNavigateToThread,
)
}
is DeckColumnType.CustomFeed -> {
CustomFeedScreen(
feedId = columnType.feedId,
relayManager = relayManager,
localCache = localCache,
subscriptionsCoordinator = subscriptionsCoordinator,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onZapFeedback = onZapFeedback,
)
}
}
}
@@ -69,6 +69,12 @@ sealed class DeckColumnType {
val tag: String,
) : DeckColumnType()
data class CustomFeed(
val feedId: String,
val feedName: String = "",
val feedEmoji: String = "",
) : DeckColumnType()
fun title(): String =
when (this) {
HomeFeed -> "Home"
@@ -89,6 +95,7 @@ sealed class DeckColumnType {
is Profile -> "Profile"
is Thread -> "Thread"
is Hashtag -> "#$tag"
is CustomFeed -> feedName.ifEmpty { "Feed" }
}
fun typeKey(): String =
@@ -111,6 +118,7 @@ sealed class DeckColumnType {
is Profile -> "profile"
is Thread -> "thread"
is Hashtag -> "hashtag"
is CustomFeed -> "custom_feed"
}
}
@@ -306,6 +306,7 @@ class DeckState(
"profile" -> param?.let { DeckColumnType.Profile(it) }
"thread" -> param?.let { DeckColumnType.Thread(it) }
"hashtag" -> param?.let { DeckColumnType.Hashtag(it) }
"custom_feed" -> param?.let { DeckColumnType.CustomFeed(feedId = it) }
else -> null
}
}
@@ -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")
}
}
},
)
}
}
@@ -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)
}
}
}
}
}
@@ -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
}
@@ -87,6 +87,7 @@ fun SinglePaneLayout(
singlePaneState: SinglePaneState,
pinnedNavBarState: PinnedNavBarState,
onOpenAppDrawer: () -> Unit,
onOpenFeedsDrawer: () -> Unit = onOpenAppDrawer,
onShowComposeDialog: () -> Unit,
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
@@ -112,6 +113,8 @@ fun SinglePaneLayout(
) {
val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState()
pinnedScreens.forEach { screenType ->
// Rename "Home" to "Feeds" in the nav rail
val label = if (screenType == DeckColumnType.HomeFeed) "Feeds" else screenType.title()
NavigationRailItem(
selected = currentColumnType == screenType && navStack.isEmpty(),
onClick = {
@@ -121,13 +124,13 @@ fun SinglePaneLayout(
icon = {
Icon(
screenType.icon(),
contentDescription = screenType.title(),
contentDescription = label,
modifier = Modifier.size(22.dp),
)
},
label = {
Text(
screenType.title(),
label,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -249,6 +252,7 @@ fun SinglePaneLayout(
onNavigateToArticle = { navState.push(DesktopScreen.Article(it)) },
onNavigateToEditor = { navState.push(DesktopScreen.Editor(it)) },
onNavigateToRelays = { singlePaneState.navigate(DeckColumnType.Relays) },
onOpenFeedsDrawer = onOpenFeedsDrawer,
)
if (currentOverlay != null) {
Surface(
@@ -88,23 +88,26 @@ fun AnimatedGifImage(
val data = gifFrames
when {
data != null && data.frames.size > 1 -> {
val safeFrame = currentFrame.coerceIn(0, data.frames.size - 1)
LaunchedEffect(data) {
while (isActive) {
val duration = data.durations[currentFrame].coerceAtLeast(MIN_FRAME_DURATION_MS)
val frameIdx = currentFrame.coerceIn(0, data.frames.size - 1)
val duration = data.durations[frameIdx].coerceAtLeast(MIN_FRAME_DURATION_MS)
delay(duration.toLong())
currentFrame = (currentFrame + 1) % data.frames.size
}
}
Image(
bitmap = data.frames[currentFrame],
bitmap = data.frames[safeFrame],
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
)
}
data != null -> {
data != null && data.frames.isNotEmpty() -> {
Image(
bitmap = data.frames[0],
contentDescription = contentDescription,