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:
@@ -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,
|
||||
|
||||
+55
@@ -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).
|
||||
*/
|
||||
|
||||
+77
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
-12
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-2
@@ -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 {
|
||||
|
||||
+1
@@ -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
|
||||
}
|
||||
|
||||
+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.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+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,
|
||||
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(
|
||||
|
||||
+6
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user