feat(desktop): add UserSearchEngine + CompositionLocal providers for cache/relay

- UserSearchEngine in commons (reusable, platform-agnostic) with RelayUserSearchDelegate interface
- DesktopRelayUserSearchDelegate using direct relay manager subscribe
- LocalDesktopCache + LocalRelayManager composition locals
- Provide cache/relay at App() level so AppDrawer and dialogs can access them
- FeedsDrawerTab reads LocalDesktopCache for author display names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-06 10:40:11 +03:00
parent 4010a57e68
commit 447f89d2c1
6 changed files with 617 additions and 556 deletions
@@ -876,158 +876,163 @@ fun App(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
when (accountState) {
is AccountState.LoggedOut -> {
LoginScreen(
accountManager = accountManager,
onLoginSuccess = {
// Start heartbeat if bunker account
val current = accountManager.currentAccount()
if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) {
accountManager.startHeartbeat(scope)
}
// Ensure account is in multi-account storage + refresh list
scope.launch(Dispatchers.IO) {
accountManager.ensureCurrentAccountInStorage()
accountManager.refreshAccountList()
}
},
)
}
is AccountState.ConnectingRelays -> {
val relays by relayManager.relayStatuses.collectAsState()
ConnectingRelaysScreen(
subtitle = "Restoring remote signer session",
relayStatuses = relays,
)
}
is AccountState.LoggedIn -> {
val account = accountState as AccountState.LoggedIn
val nwcConnection by accountManager.nwcConnection.collectAsState()
// Load NWC connection on first composition
LaunchedEffect(Unit) {
accountManager.loadNwcConnection()
}
val currentTorStatus = torManager.status.collectAsState().value
androidx.compose.runtime.CompositionLocalProvider(
com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides
com.vitorpamplona.amethyst.desktop.ui.tor.TorState(
status = currentTorStatus,
settings = torSettings,
onSettingsChanged = { newSettings ->
torSettings = newSettings
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
.save(newSettings)
torTypeFlow.value = newSettings.torType
externalPortFlow.value = newSettings.externalSocksPort
// Rebuild app to apply Tor changes
onRestartApp()
},
),
) {
MainContent(
layoutMode = layoutMode,
deckState = deckState,
workspaceManager = workspaceManager,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
relayManager = relayManager,
localCache = localCache,
CompositionLocalProvider(
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
) {
when (accountState) {
is AccountState.LoggedOut -> {
LoginScreen(
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
nip11Fetcher = nip11Fetcher,
appScope = scope,
torStatus = currentTorStatus,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onShowAppDrawer = onShowAppDrawer,
onOpenFeedsDrawer = {
appDrawerInitialTab =
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
onShowAppDrawer()
},
)
}
// Compose dialog
if (showComposeDialog) {
ComposeNoteDialog(
onDismiss = onDismissComposeDialog,
relayManager = relayManager,
account = account,
replyTo = replyToNote,
)
}
// App Drawer overlay
if (showAppDrawer) {
val openColumns by deckState.columns.collectAsState()
AppDrawer(
initialTab = appDrawerInitialTab,
openColumnTypes =
if (layoutMode == LayoutMode.DECK) {
openColumns.map { it.type.typeKey() }.toSet()
} else {
emptySet()
},
pinnedNavBarState = pinnedNavBarState,
workspaceManager = workspaceManager,
onSwitchWorkspace = { ws ->
// Switch layout mode to match workspace
onLayoutModeChange(ws.layoutMode)
// Load columns or single pane screen
when (ws.layoutMode) {
LayoutMode.DECK -> {
deckState.loadFromWorkspace(ws.columns)
}
LayoutMode.SINGLE_PANE -> {
// Load nav bar from workspace + navigate to first screen
pinnedNavBarState.loadFromWorkspace()
val firstKey =
ws.singlePaneScreens.firstOrNull() ?: "home"
val type = DeckState.parseColumnTypeFromKey(firstKey)
if (type != null) singlePaneState.navigate(type)
}
onLoginSuccess = {
// Start heartbeat if bunker account
val current = accountManager.currentAccount()
if (current?.signerType is com.vitorpamplona.amethyst.commons.model.account.SignerType.Remote) {
accountManager.startHeartbeat(scope)
}
// Ensure account is in multi-account storage + refresh list
scope.launch(Dispatchers.IO) {
accountManager.ensureCurrentAccountInStorage()
accountManager.refreshAccountList()
}
},
onSelectScreen = { type ->
when (layoutMode) {
LayoutMode.DECK -> {
if (deckState.hasColumnOfType(type)) {
deckState.focusExistingColumn(type)
} else {
deckState.addColumn(type)
)
}
is AccountState.ConnectingRelays -> {
val relays by relayManager.relayStatuses.collectAsState()
ConnectingRelaysScreen(
subtitle = "Restoring remote signer session",
relayStatuses = relays,
)
}
is AccountState.LoggedIn -> {
val account = accountState as AccountState.LoggedIn
val nwcConnection by accountManager.nwcConnection.collectAsState()
// Load NWC connection on first composition
LaunchedEffect(Unit) {
accountManager.loadNwcConnection()
}
val currentTorStatus = torManager.status.collectAsState().value
androidx.compose.runtime.CompositionLocalProvider(
com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides
com.vitorpamplona.amethyst.desktop.ui.tor.TorState(
status = currentTorStatus,
settings = torSettings,
onSettingsChanged = { newSettings ->
torSettings = newSettings
com.vitorpamplona.amethyst.desktop.tor.DesktopTorPreferences
.save(newSettings)
torTypeFlow.value = newSettings.torType
externalPortFlow.value = newSettings.externalSocksPort
// Rebuild app to apply Tor changes
onRestartApp()
},
),
) {
MainContent(
layoutMode = layoutMode,
deckState = deckState,
workspaceManager = workspaceManager,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
nip11Fetcher = nip11Fetcher,
appScope = scope,
torStatus = currentTorStatus,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onShowAppDrawer = onShowAppDrawer,
onOpenFeedsDrawer = {
appDrawerInitialTab =
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
onShowAppDrawer()
},
)
}
// Compose dialog
if (showComposeDialog) {
ComposeNoteDialog(
onDismiss = onDismissComposeDialog,
relayManager = relayManager,
account = account,
replyTo = replyToNote,
)
}
// App Drawer overlay
if (showAppDrawer) {
val openColumns by deckState.columns.collectAsState()
AppDrawer(
initialTab = appDrawerInitialTab,
openColumnTypes =
if (layoutMode == LayoutMode.DECK) {
openColumns.map { it.type.typeKey() }.toSet()
} else {
emptySet()
},
pinnedNavBarState = pinnedNavBarState,
workspaceManager = workspaceManager,
onSwitchWorkspace = { ws ->
// Switch layout mode to match workspace
onLayoutModeChange(ws.layoutMode)
// Load columns or single pane screen
when (ws.layoutMode) {
LayoutMode.DECK -> {
deckState.loadFromWorkspace(ws.columns)
}
LayoutMode.SINGLE_PANE -> {
// Load nav bar from workspace + navigate to first screen
pinnedNavBarState.loadFromWorkspace()
val firstKey =
ws.singlePaneScreens.firstOrNull() ?: "home"
val type = DeckState.parseColumnTypeFromKey(firstKey)
if (type != null) singlePaneState.navigate(type)
}
}
},
onSelectScreen = { type ->
when (layoutMode) {
LayoutMode.DECK -> {
if (deckState.hasColumnOfType(type)) {
deckState.focusExistingColumn(type)
} else {
deckState.addColumn(type)
}
}
LayoutMode.SINGLE_PANE -> {
singlePaneState.navigate(type)
LayoutMode.SINGLE_PANE -> {
singlePaneState.navigate(type)
}
}
}
},
onDismiss = {
appDrawerInitialTab = null
onDismissAppDrawer()
},
)
},
onDismiss = {
appDrawerInitialTab = null
onDismissAppDrawer()
},
)
}
}
}
}
// Force logout dialog overlay
val forceLogoutReason by accountManager.forceLogoutReason.collectAsState()
forceLogoutReason?.let { reason ->
ForceLogoutDialog(
reason = reason,
onDismiss = { accountManager.clearForceLogoutReason() },
)
// Force logout dialog overlay
val forceLogoutReason by accountManager.forceLogoutReason.collectAsState()
forceLogoutReason?.let { reason ->
ForceLogoutDialog(
reason = reason,
onDismiss = { accountManager.clearForceLogoutReason() },
)
}
}
}
}
@@ -1245,6 +1250,7 @@ fun MainContent(
LocalRelayCategories provides relayCategories,
com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.search
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.search.RelayUserSearchDelegate
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class DesktopRelayUserSearchDelegate(
private val relayManager: RelayConnectionManager,
private val localCache: DesktopLocalCache,
private val searchRelays: () -> Set<NormalizedRelayUrl>,
private val scope: CoroutineScope,
) : RelayUserSearchDelegate {
private var currentSubId: String? = null
override fun searchPeople(
query: String,
limit: Int,
onResult: (User) -> Unit,
onComplete: () -> Unit,
): Job =
scope.launch {
// Cancel previous subscription
currentSubId?.let { relayManager.unsubscribe(it) }
val relays = searchRelays()
if (relays.isEmpty()) {
onComplete()
return@launch
}
val subId = generateSubId("author-search")
currentSubId = subId
relayManager.subscribe(
subId = subId,
filters = listOf(FilterBuilders.searchPeople(query, limit)),
relays = relays,
listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event is MetadataEvent) {
localCache.consumeMetadata(event)
val user = localCache.getUserIfExists(event.pubKey)
if (user != null) {
onResult(user)
}
}
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
onComplete()
}
},
)
// Timeout after 8 seconds
delay(8000)
relayManager.unsubscribe(subId)
onComplete()
}
}
@@ -67,6 +67,7 @@ fun FeedsDrawerTab(
onSelectFeed: (FeedDefinition) -> Unit,
onDismiss: () -> Unit,
localCache: DesktopLocalCache? = LocalDesktopCache.current,
relayManager: com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager? = LocalRelayManager.current,
feedRepository: FeedDefinitionRepository = LocalFeedRepository.current,
scope: CoroutineScope = LocalFeedScope.current,
) {
@@ -79,6 +80,7 @@ fun FeedsDrawerTab(
if (showBuilder) {
FeedBuilderDialog(
localCache = localCache,
relayManager = relayManager,
onSave = { feed ->
scope.launch { feedRepository.add(feed) }
showBuilder = false
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinitionRepository
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinitionSerializer
import com.vitorpamplona.amethyst.commons.feeds.custom.defaultFeeds
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn
@@ -74,3 +75,8 @@ val LocalDesktopCache =
compositionLocalOf<DesktopLocalCache?> {
null
}
val LocalRelayManager =
compositionLocalOf<DesktopRelayConnectionManager?> {
null
}