fix(desktop): use SearchBarState + rememberSubscription for author search

Replace manual relay subscribe + Channel approach with the proven
SearchBarState + rememberSubscription pattern (same as NewDmDialog).
This properly handles relay connection lifecycle and NIP-50 search,
returning results from all connected relays.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-08 07:32:20 +03:00
parent 41920a363a
commit de879b9472
2 changed files with 448 additions and 288 deletions
@@ -20,46 +20,52 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.material.icons.outlined.DeleteForever
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.MilitaryTech
import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.ThumbUp
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -69,6 +75,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Preview
@Composable
@@ -86,223 +94,418 @@ fun AllSettingsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val tint = MaterialTheme.colorScheme.onBackground
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showResetMarmotDialog by remember { mutableStateOf(false) }
var isResettingMarmot by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val hasPrivateKey = accountViewModel.account.settings.keyPair.privKey != null
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(id = R.string.settings), nav::popBack)
TopBarWithBackButton(stringRes(id = R.string.settings), nav)
},
bottomBar = {
AppBottomBar(Route.AllSettings, nav, accountViewModel) { route ->
if (route == Route.AllSettings) {
scope.launch { scrollState.animateScrollTo(0) }
} else {
nav.navBottomBar(route)
}
}
},
) { padding ->
Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) {
SettingsSectionHeader(R.string.account_settings)
SettingsNavigationRow(
Column(
modifier =
Modifier
.padding(padding)
.verticalScroll(scrollState)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
SettingsSection(R.string.account_settings) {
SettingsItem(
title = R.string.relay_setup,
iconPainter = R.drawable.relays,
iconPainterRef = 4,
tint = tint,
onClick = { nav.nav(Route.EditRelays) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.event_sync_title,
icon = Icons.Outlined.Sync,
tint = tint,
icon = MaterialSymbols.Sync,
onClick = { nav.nav(Route.EventSync) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.route_import_follows,
icon = Icons.Outlined.GroupAdd,
tint = tint,
icon = MaterialSymbols.GroupAdd,
onClick = { nav.nav(Route.ImportFollowsSelectUser) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.media_servers,
icon = Icons.Outlined.CloudUpload,
tint = tint,
icon = MaterialSymbols.CloudUpload,
onClick = { nav.nav(Route.EditMediaServers) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.nests_servers_title,
icon = MaterialSymbols.CloudUpload,
onClick = { nav.nav(Route.EditNestsServers) },
)
SettingsDivider()
SettingsItem(
title = R.string.profile_badges_title,
icon = Icons.Outlined.MilitaryTech,
tint = tint,
icon = MaterialSymbols.MilitaryTech,
onClick = { nav.nav(Route.ProfileBadges) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.favorite_dvms_title,
icon = Icons.Outlined.AutoAwesome,
tint = tint,
icon = MaterialSymbols.AutoAwesome,
onClick = { nav.nav(Route.EditFavoriteAlgoFeeds) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.reactions,
icon = Icons.Outlined.FavoriteBorder,
tint = tint,
icon = MaterialSymbols.FavoriteBorder,
onClick = { nav.nav(Route.UpdateReactionType) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.video_player_settings,
icon = MaterialSymbols.VideoSettings,
onClick = { nav.nav(Route.VideoPlayerSettings) },
)
SettingsDivider()
SettingsItem(
title = R.string.zaps,
icon = Icons.Outlined.Bolt,
tint = tint,
icon = MaterialSymbols.Bolt,
onClick = { nav.nav(Route.UpdateZapAmount()) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.payment_targets,
icon = MaterialSymbols.Payment,
onClick = { nav.nav(Route.EditPaymentTargets) },
)
SettingsDivider()
SettingsItem(
title = R.string.security_filters,
icon = Icons.Outlined.Security,
tint = tint,
icon = MaterialSymbols.Security,
onClick = { nav.nav(Route.SecurityFilters) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.call_settings,
icon = Icons.Outlined.Phone,
tint = tint,
icon = MaterialSymbols.Phone,
onClick = { nav.nav(Route.CallSettings) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.translations,
icon = Icons.Outlined.Translate,
tint = tint,
icon = MaterialSymbols.Translate,
onClick = { nav.nav(Route.UserSettings) },
)
HorizontalDivider(thickness = 4.dp)
SettingsSectionHeader(R.string.app_settings)
SettingsNavigationRow(
}
SettingsSection(R.string.app_settings) {
SettingsItem(
title = R.string.privacy_options,
iconPainter = R.drawable.ic_tor,
iconPainterRef = 1,
tint = tint,
onClick = { nav.nav(Route.PrivacyOptions) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.ots_explorer_settings,
icon = Icons.Outlined.Search,
tint = tint,
icon = MaterialSymbols.Search,
onClick = { nav.nav(Route.OtsSettings) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.namecoin_settings,
icon = Icons.Outlined.Security,
tint = tint,
icon = MaterialSymbols.Security,
onClick = { nav.nav(Route.NamecoinSettings) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.ui_preferences,
icon = Icons.Outlined.Settings,
tint = tint,
icon = MaterialSymbols.Settings,
onClick = { nav.nav(Route.Settings) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.reactions_settings,
icon = Icons.Outlined.ThumbUp,
tint = tint,
icon = MaterialSymbols.ThumbUp,
onClick = { nav.nav(Route.ReactionsSettings) },
)
HorizontalDivider(thickness = 4.dp)
SettingsSectionHeader(R.string.danger_zone)
accountViewModel.account.settings.keyPair.privKey?.let {
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.bottom_bar_settings,
icon = MaterialSymbols.Dashboard,
onClick = { nav.nav(Route.BottomBarSettings) },
)
SettingsDivider()
SettingsItem(
title = R.string.home_tabs_settings,
icon = MaterialSymbols.Home,
onClick = { nav.nav(Route.HomeTabsSettings) },
)
}
SettingsSection(R.string.danger_zone, isDanger = true) {
if (hasPrivateKey) {
SettingsItem(
title = R.string.backup_keys,
icon = Icons.Outlined.Key,
tint = tint,
icon = MaterialSymbols.Key,
isDanger = true,
onClick = { nav.nav(Route.AccountBackup) },
)
HorizontalDivider()
SettingsNavigationRow(
SettingsDivider()
SettingsItem(
title = R.string.request_to_vanish,
icon = Icons.Outlined.DeleteForever,
tint = tint,
icon = MaterialSymbols.DeleteForever,
isDanger = true,
onClick = { nav.nav(Route.RequestToVanish) },
)
HorizontalDivider()
SettingsDivider()
}
SettingsNavigationRow(
SettingsItem(
title = R.string.vanish_history,
icon = Icons.Outlined.History,
tint = tint,
icon = MaterialSymbols.History,
isDanger = true,
onClick = { nav.nav(Route.VanishEvents) },
)
SettingsDivider()
SettingsItem(
title = R.string.reset_marmot_state,
icon = MaterialSymbols.DeleteSweep,
isDanger = true,
onClick = { if (!isResettingMarmot) showResetMarmotDialog = true },
)
}
}
}
if (showResetMarmotDialog) {
ResetMarmotStateDialog(
onConfirm = {
showResetMarmotDialog = false
isResettingMarmot = true
scope.launch(Dispatchers.IO) {
val successMessage = stringRes(context, R.string.reset_marmot_success)
try {
accountViewModel.resetMarmotState()
launch(Dispatchers.Main) {
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
val failureMessage =
stringRes(context, R.string.reset_marmot_failure, e.message ?: "")
launch(Dispatchers.Main) {
Toast.makeText(context, failureMessage, Toast.LENGTH_LONG).show()
}
} finally {
isResettingMarmot = false
}
}
},
onDismiss = { showResetMarmotDialog = false },
)
}
}
@Composable
private fun SettingsSectionHeader(title: Int) {
private fun ResetMarmotStateDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(32.dp),
)
},
title = {
Text(
text = stringRes(title),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
text = stringRes(R.string.reset_marmot_confirm_title),
textAlign = TextAlign.Center,
)
},
text = {
Text(text = stringRes(R.string.reset_marmot_confirm_body))
},
confirmButton = {
Button(
onClick = onConfirm,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text(stringRes(R.string.reset_marmot_confirm_action))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.cancel))
}
},
)
}
@Composable
private fun SettingsNavigationRow(
private fun SettingsSection(
title: Int,
icon: ImageVector,
tint: Color,
isDanger: Boolean = false,
content: @Composable ColumnScope.() -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringRes(title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color =
if (isDanger) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
},
modifier = Modifier.padding(horizontal = 4.dp),
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Column(content = content)
}
}
}
@Composable
private fun SettingsDivider() {
HorizontalDivider(
modifier = Modifier.padding(start = 68.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant,
)
}
@Composable
private fun SettingsItem(
title: Int,
icon: MaterialSymbol,
isDanger: Boolean = false,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 16.dp, horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SettingsItemRow(
title = title,
isDanger = isDanger,
onClick = onClick,
leadingIcon = { tint ->
Icon(
imageVector = icon,
symbol = icon,
contentDescription = stringRes(title),
modifier = Modifier.size(24.dp),
modifier = Modifier.size(20.dp),
tint = tint,
)
Text(
text = stringRes(title),
fontSize = 18.sp,
modifier = Modifier.padding(start = 16.dp),
},
)
}
}
@Composable
private fun SettingsNavigationRow(
private fun SettingsItem(
title: Int,
iconPainter: Int,
iconPainterRef: Int,
tint: Color,
isDanger: Boolean = false,
onClick: () -> Unit,
) {
val painter: Painter = painterRes(iconPainter, iconPainterRef)
SettingsItemRow(
title = title,
isDanger = isDanger,
onClick = onClick,
leadingIcon = { tint ->
Icon(
painter = painter,
contentDescription = stringRes(title),
modifier = Modifier.size(20.dp),
tint = tint,
)
},
)
}
@Composable
private fun SettingsItemRow(
title: Int,
isDanger: Boolean,
onClick: () -> Unit,
leadingIcon: @Composable (tint: Color) -> Unit,
) {
val containerColor =
if (isDanger) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.primaryContainer
}
val iconTint =
if (isDanger) {
MaterialTheme.colorScheme.onErrorContainer
} else {
MaterialTheme.colorScheme.onPrimaryContainer
}
val textColor =
if (isDanger) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
}
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 16.dp, horizontal = 24.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterRes(iconPainter, iconPainterRef),
contentDescription = stringRes(title),
modifier = Modifier.size(24.dp),
tint = tint,
)
Box(
modifier =
Modifier
.size(36.dp)
.clip(RoundedCornerShape(10.dp))
.background(containerColor),
contentAlignment = Alignment.Center,
) {
leadingIcon(iconTint)
}
Text(
text = stringRes(title),
fontSize = 18.sp,
modifier = Modifier.padding(start = 16.dp),
style = MaterialTheme.typography.bodyLarge,
color = textColor,
modifier =
Modifier
.weight(1f)
.padding(start = 16.dp),
)
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -77,100 +77,57 @@ fun FeedsDrawerTab(
var editingFeed by remember { mutableStateOf<FeedDefinition?>(null) }
var deletingFeed by remember { mutableStateOf<FeedDefinition?>(null) }
// Author search state — hoisted here because AlertDialog can't run LaunchedEffect
var authorQuery by remember { mutableStateOf("") }
var authorLocal by remember {
mutableStateOf(emptyList<com.vitorpamplona.amethyst.commons.model.User>())
// Author search — same pattern as NewDmDialog: SearchBarState + rememberSubscription
val searchState =
remember(localCache) {
localCache?.let {
com.vitorpamplona.amethyst.commons.viewmodels
.SearchBarState(it, scope)
}
var authorRelay by remember {
mutableStateOf(emptyList<com.vitorpamplona.amethyst.commons.model.User>())
}
var authorSearching by remember { mutableStateOf(false) }
val authorQuery = searchState?.searchText?.collectAsState()?.value ?: ""
val authorLocal = searchState?.cachedUserResults?.collectAsState()?.value ?: emptyList()
val authorRelay = searchState?.relaySearchResults?.collectAsState()?.value ?: emptyList()
val authorSearching = searchState?.isSearchingRelays?.collectAsState()?.value ?: false
LaunchedEffect(authorQuery) {
if (authorQuery.length < 2 || localCache == null) {
authorLocal = emptyList()
authorRelay = emptyList()
authorSearching = false
return@LaunchedEffect
}
kotlinx.coroutines.delay(300)
val results = localCache.findUsersStartingWith(authorQuery, 10)
authorLocal = results
// NIP-50 relay search — fires when local cache has few results (same as NewDmDialog)
if (relayManager != null && searchState != null) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays = relayStatuses.keys
if (relayManager != null) {
// Use all connected relays — some support NIP-50 search
val relays = relayManager.connectedRelays.value
if (relays.isNotEmpty()) {
authorSearching = true
authorRelay = emptyList()
val ch = kotlinx.coroutines.channels.Channel<com.vitorpamplona.amethyst.commons.model.User>(64)
val subId =
com.vitorpamplona.amethyst.desktop.subscriptions
.generateSubId("author-search")
relayManager.subscribe(
subId = subId,
filters =
listOf(
com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
.searchPeople(authorQuery, 30),
),
relays = relays,
listener =
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<com.vitorpamplona.quartz.nip01Core.relay.filters.Filter>?,
com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription(
connectedRelays,
authorQuery,
authorLocal.size,
relayManager = relayManager,
) {
if (connectedRelays.isEmpty()) return@rememberSubscription null
if (!searchState.shouldSearchRelays) return@rememberSubscription null
searchState.startRelaySearch()
com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription(
relays = connectedRelays,
searchQuery = authorQuery,
limit = 30,
onEvent = { event, _, _, _ ->
if (event is com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent &&
localCache != null
) {
if (event is com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent) {
localCache.consumeMetadata(event)
localCache.getUserIfExists(event.pubKey)?.let {
ch.trySend(it)
searchState.addRelaySearchResult(it)
}
}
}
override fun onEose(
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<com.vitorpamplona.quartz.nip01Core.relay.filters.Filter>?,
) {
ch.close()
}
override fun onClosed(
message: String,
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<com.vitorpamplona.quartz.nip01Core.relay.filters.Filter>?,
) {
ch.close()
}
},
onEose = { _, _ -> searchState.endRelaySearch() },
)
try {
kotlinx.coroutines.withTimeoutOrNull(8000) {
for (user in ch) {
if (authorRelay.none { it.pubkeyHex == user.pubkeyHex }) {
authorRelay = authorRelay + user
}
}
}
} finally {
authorSearching = false
relayManager.unsubscribe(subId)
}
}
}
}
// Reset search state when dialogs close
// Reset search when dialogs close
LaunchedEffect(showBuilder, editingFeed) {
if (!showBuilder && editingFeed == null) {
authorQuery = ""
authorLocal = emptyList()
authorRelay = emptyList()
authorSearching = false
searchState?.clearSearch()
}
}
@@ -179,7 +136,7 @@ fun FeedsDrawerTab(
FeedBuilderDialog(
localCache = localCache,
authorQuery = authorQuery,
onAuthorQueryChange = { authorQuery = it },
onAuthorQueryChange = { searchState?.updateSearchText(it) },
authorSuggestions = authorLocal,
authorRelayResults = authorRelay,
authorSearching = authorSearching,
@@ -197,7 +154,7 @@ fun FeedsDrawerTab(
initial = feed,
localCache = localCache,
authorQuery = authorQuery,
onAuthorQueryChange = { authorQuery = it },
onAuthorQueryChange = { searchState?.updateSearchText(it) },
authorSuggestions = authorLocal,
authorRelayResults = authorRelay,
authorSearching = authorSearching,