Skip to content

Commit ca7fa32

Browse files
ben-kaufmanclaude
andcommitted
fix: harden pubky contact flows
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ad1e8f4 commit ca7fa32

12 files changed

Lines changed: 435 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Show loading state on Spending tab when node is not running #875
1414

1515
### Added
16+
- Pubky profile onboarding with contact sync, import, and editing
1617
- Lightning Connections empty state with onboarding screen #857
1718
- Unified PIN management screen (enable/disable/change in one place) #857
1819
- Support entry in drawer menu #857

app/src/main/java/to/bitkit/di/HttpModule.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@file:Suppress("ImportOrdering")
2+
13
package to.bitkit.di
24

35
import dagger.Module
@@ -18,9 +20,9 @@ import io.ktor.http.contentType
1820
import io.ktor.http.isSuccess
1921
import io.ktor.serialization.kotlinx.json.json
2022
import kotlinx.serialization.json.Json
21-
import to.bitkit.utils.UrlValidator
2223
import to.bitkit.utils.AppError
2324
import to.bitkit.utils.Logger
25+
import to.bitkit.utils.UrlValidator
2426
import javax.inject.Singleton
2527
import io.ktor.client.plugins.logging.Logger as KtorLogger
2628

app/src/main/java/to/bitkit/repositories/PubkyRepo.kt

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,10 @@ class PubkyRepo @Inject constructor(
243243
}
244244
}
245245
}.onSuccess { loadedProfile ->
246-
if (_publicKey.value == null) return@onSuccess
246+
if (_publicKey.value != pk) {
247+
Logger.debug("Skipped stale profile load for '$pk'", context = TAG)
248+
return@onSuccess
249+
}
247250
_profile.update { loadedProfile }
248251
cacheMetadata(loadedProfile)
249252
}.onFailure {
@@ -460,7 +463,10 @@ class PubkyRepo @Inject constructor(
460463
}
461464
}
462465
}.onSuccess { loadedContacts ->
463-
if (_publicKey.value == null) return@onSuccess
466+
if (_publicKey.value != pk) {
467+
Logger.debug("Skipped stale contacts load for '$pk'", context = TAG)
468+
return@onSuccess
469+
}
464470
_contacts.update { loadedContacts }
465471
}.onFailure {
466472
Logger.error("Failed to load contacts", it, context = TAG)
@@ -487,14 +493,15 @@ class PubkyRepo @Inject constructor(
487493
"No session available"
488494
}
489495
val prefixedKey = publicKey.ensurePubkyPrefix()
490-
val profile = existingProfile ?: run {
496+
val profile = existingProfile?.copy(publicKey = prefixedKey) ?: run {
491497
val ffiProfile = pubkyService.getProfile(prefixedKey)
492498
PubkyProfile.fromFfi(prefixedKey, ffiProfile)
493499
}
494500
val data = profile.toProfileData().encode()
495501
pubkyService.sessionPut(session, "${Env.contactsBasePath}$prefixedKey", data)
496502
_contacts.update { current ->
497-
(current + profile).sortedBy { it.name.lowercase() }
503+
(current.filter { it.publicKey != prefixedKey } + profile)
504+
.sortedBy { it.name.lowercase() }
498505
}
499506
Logger.info("Added contact '$prefixedKey'", context = TAG)
500507
}

app/src/main/java/to/bitkit/ui/ContentView.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1426,7 +1426,7 @@ private fun NavGraphBuilder.widgets(
14261426
fiatSymbol = LocalCurrencies.current.currencySymbol,
14271427
onBackClick = { navController.popBackStack() },
14281428
showWidgets = showWidgets,
1429-
onEnableInSettingsClick = { navController.navigate(Routes.WidgetsSettings) },
1429+
onEnableInSettingsClick = { navController.navigateTo(Routes.WidgetsSettings) },
14301430
)
14311431
}
14321432
composableWithDefaultTransitions<Routes.SuggestionsPreview> {

app/src/main/java/to/bitkit/ui/screens/contacts/EditContactScreen.kt

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,36 @@
11
package to.bitkit.ui.screens.contacts
22

3+
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.fillMaxSize
6+
import androidx.compose.foundation.layout.padding
7+
import androidx.compose.foundation.layout.size
38
import androidx.compose.runtime.Composable
49
import androidx.compose.runtime.LaunchedEffect
510
import androidx.compose.runtime.getValue
11+
import androidx.compose.ui.Alignment
12+
import androidx.compose.ui.Modifier
613
import androidx.compose.ui.res.stringResource
714
import androidx.compose.ui.tooling.preview.Preview
15+
import androidx.compose.ui.unit.dp
816
import androidx.lifecycle.compose.collectAsStateWithLifecycle
917
import kotlinx.collections.immutable.persistentListOf
1018
import to.bitkit.R
1119
import to.bitkit.ui.components.AddLinkSheet
1220
import to.bitkit.ui.components.AddTagSheet
21+
import to.bitkit.ui.components.BodyM
1322
import to.bitkit.ui.components.CenteredProfileHeader
23+
import to.bitkit.ui.components.GradientCircularProgressIndicator
1424
import to.bitkit.ui.components.ProfileEditForm
1525
import to.bitkit.ui.components.ProfileEditLink
26+
import to.bitkit.ui.components.SecondaryButton
27+
import to.bitkit.ui.components.VerticalSpacer
1628
import to.bitkit.ui.scaffold.AppAlertDialog
1729
import to.bitkit.ui.scaffold.AppTopBar
1830
import to.bitkit.ui.scaffold.DrawerNavIcon
1931
import to.bitkit.ui.scaffold.ScreenColumn
2032
import to.bitkit.ui.theme.AppThemeSurface
33+
import to.bitkit.ui.theme.Colors
2134

2235
@Composable
2336
fun EditContactScreen(
@@ -39,6 +52,7 @@ fun EditContactScreen(
3952
Content(
4053
uiState = uiState,
4154
onBackClick = onBackClick,
55+
onRetryClick = { viewModel.retryLoadContact() },
4256
onNameChange = { viewModel.onNameChange(it) },
4357
onBioChange = { viewModel.onBioChange(it) },
4458
onRemoveLink = { viewModel.removeLink(it) },
@@ -60,6 +74,7 @@ fun EditContactScreen(
6074
private fun Content(
6175
uiState: EditContactUiState,
6276
onBackClick: () -> Unit,
77+
onRetryClick: () -> Unit,
6378
onNameChange: (String) -> Unit,
6479
onBioChange: (String) -> Unit,
6580
onRemoveLink: (Int) -> Unit,
@@ -82,32 +97,36 @@ private fun Content(
8297
actions = { DrawerNavIcon() },
8398
)
8499

85-
ProfileEditForm(
86-
name = uiState.name,
87-
onNameChange = onNameChange,
88-
publicKey = uiState.publicKey,
89-
bio = uiState.bio,
90-
onBioChange = onBioChange,
91-
links = uiState.links,
92-
onRemoveLink = onRemoveLink,
93-
onAddLink = onAddLink,
94-
tags = uiState.tags,
95-
onRemoveTag = onRemoveTag,
96-
onAddTag = onAddTag,
97-
onSave = onSave,
98-
onCancel = onBackClick,
99-
isSaveEnabled = uiState.name.isNotBlank() && !uiState.isSaving,
100-
avatarContent = {
101-
CenteredProfileHeader(
102-
publicKey = uiState.publicKey,
103-
name = "",
104-
bio = "",
105-
imageUrl = uiState.imageUrl,
106-
)
107-
},
108-
onDelete = onDelete,
109-
deleteLabel = stringResource(R.string.contacts__delete_contact),
110-
)
100+
when {
101+
uiState.isLoading -> LoadingState()
102+
uiState.isMissing -> EmptyState(onRetryClick = onRetryClick)
103+
else -> ProfileEditForm(
104+
name = uiState.name,
105+
onNameChange = onNameChange,
106+
publicKey = uiState.publicKey,
107+
bio = uiState.bio,
108+
onBioChange = onBioChange,
109+
links = uiState.links,
110+
onRemoveLink = onRemoveLink,
111+
onAddLink = onAddLink,
112+
tags = uiState.tags,
113+
onRemoveTag = onRemoveTag,
114+
onAddTag = onAddTag,
115+
onSave = onSave,
116+
onCancel = onBackClick,
117+
isSaveEnabled = uiState.name.isNotBlank() && !uiState.isSaving,
118+
avatarContent = {
119+
CenteredProfileHeader(
120+
publicKey = uiState.publicKey,
121+
name = "",
122+
bio = "",
123+
imageUrl = uiState.imageUrl,
124+
)
125+
},
126+
onDelete = onDelete,
127+
deleteLabel = stringResource(R.string.contacts__delete_contact),
128+
)
129+
}
111130
}
112131

113132
if (uiState.showDeleteDialog) {
@@ -135,6 +154,34 @@ private fun Content(
135154
}
136155
}
137156

157+
@Composable
158+
private fun LoadingState() {
159+
Box(
160+
contentAlignment = Alignment.Center,
161+
modifier = Modifier.fillMaxSize()
162+
) {
163+
GradientCircularProgressIndicator(modifier = Modifier.size(24.dp))
164+
}
165+
}
166+
167+
@Composable
168+
private fun EmptyState(onRetryClick: () -> Unit) {
169+
Column(
170+
horizontalAlignment = Alignment.CenterHorizontally,
171+
modifier = Modifier
172+
.fillMaxSize()
173+
.padding(horizontal = 32.dp)
174+
) {
175+
VerticalSpacer(48.dp)
176+
BodyM(text = stringResource(R.string.contacts__detail_empty_state), color = Colors.White64)
177+
VerticalSpacer(16.dp)
178+
SecondaryButton(
179+
text = stringResource(R.string.profile__retry_load),
180+
onClick = onRetryClick,
181+
)
182+
}
183+
}
184+
138185
@Preview(showSystemUi = true)
139186
@Composable
140187
private fun Preview() {
@@ -152,6 +199,7 @@ private fun Preview() {
152199
isLoading = false,
153200
),
154201
onBackClick = {},
202+
onRetryClick = {},
155203
onNameChange = {},
156204
onBioChange = {},
157205
onRemoveLink = {},

app/src/main/java/to/bitkit/ui/screens/contacts/EditContactViewModel.kt

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
1515
import kotlinx.coroutines.flow.StateFlow
1616
import kotlinx.coroutines.flow.asSharedFlow
1717
import kotlinx.coroutines.flow.asStateFlow
18+
import kotlinx.coroutines.flow.collectLatest
1819
import kotlinx.coroutines.flow.update
1920
import kotlinx.coroutines.launch
2021
import to.bitkit.R
22+
import to.bitkit.models.PubkyProfile
2123
import to.bitkit.models.PubkyProfileLink
2224
import to.bitkit.models.Toast
2325
import to.bitkit.repositories.PubkyRepo
@@ -49,15 +51,52 @@ class EditContactViewModel @Inject constructor(
4951
val effects = _effects.asSharedFlow()
5052

5153
init {
52-
loadContact()
54+
observeContactUpdates()
55+
retryLoadContact()
5356
}
5457

55-
private fun loadContact() {
56-
val contact = pubkyRepo.contacts.value.find { it.publicKey == publicKey }
57-
if (contact == null) {
58-
Logger.warn("Contact '$publicKey' not found in local contacts", context = TAG)
59-
return
58+
fun retryLoadContact() {
59+
viewModelScope.launch {
60+
val cachedContact = pubkyRepo.contacts.value.find { it.publicKey == publicKey }
61+
if (cachedContact != null) {
62+
applyContact(cachedContact)
63+
return@launch
64+
}
65+
66+
_uiState.update {
67+
it.copy(
68+
isLoading = true,
69+
isMissing = false,
70+
)
71+
}
72+
73+
pubkyRepo.loadContacts()
74+
75+
val refreshedContact = pubkyRepo.contacts.value.find { it.publicKey == publicKey }
76+
if (refreshedContact != null) {
77+
applyContact(refreshedContact)
78+
return@launch
79+
}
80+
81+
Logger.warn("Failed to find contact '$publicKey' after refresh", context = TAG)
82+
_uiState.update {
83+
it.copy(
84+
isLoading = false,
85+
isMissing = true,
86+
)
87+
}
6088
}
89+
}
90+
91+
private fun observeContactUpdates() {
92+
viewModelScope.launch {
93+
pubkyRepo.contacts.collectLatest { contacts ->
94+
contacts.find { it.publicKey == publicKey }?.let { applyContact(it) }
95+
}
96+
}
97+
}
98+
99+
private fun applyContact(contact: PubkyProfile) {
61100
_uiState.update {
62101
it.copy(
63102
name = contact.name,
@@ -68,6 +107,7 @@ class EditContactViewModel @Inject constructor(
68107
}.toImmutableList(),
69108
tags = contact.tags.toImmutableList(),
70109
isLoading = false,
110+
isMissing = false,
71111
)
72112
}
73113
}
@@ -182,6 +222,7 @@ data class EditContactUiState(
182222
val links: ImmutableList<ProfileEditLink> = persistentListOf(),
183223
val tags: ImmutableList<String> = persistentListOf(),
184224
val isLoading: Boolean = true,
225+
val isMissing: Boolean = false,
185226
val isSaving: Boolean = false,
186227
val showDeleteDialog: Boolean = false,
187228
val showAddLinkSheet: Boolean = false,

app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package to.bitkit.ui.screens.profile
33
import android.content.Context
44
import android.content.Intent
55
import android.net.Uri
6+
import androidx.annotation.VisibleForTesting
67
import androidx.compose.runtime.Immutable
78
import androidx.lifecycle.ViewModel
89
import androidx.lifecycle.viewModelScope
@@ -83,21 +84,33 @@ class PubkyChoiceViewModel @Inject constructor(
8384
}
8485
}
8586

86-
private fun waitForApproval() {
87+
@VisibleForTesting
88+
internal fun waitForApproval() {
8789
if (approvalJob?.isActive == true) return
8890

8991
approvalJob = viewModelScope.launch {
9092
pubkyRepo.completeAuthentication()
9193
.onSuccess {
9294
_uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = true) }
9395
pubkyRepo.prepareImport()
94-
_uiState.update { it.copy(isLoadingAfterAuth = false) }
95-
val hasContacts = pubkyRepo.pendingImportContacts.value.isNotEmpty()
96-
if (hasContacts) {
97-
_effects.emit(PubkyChoiceEffect.NavigateToContactImportOverview)
98-
} else {
99-
_effects.emit(PubkyChoiceEffect.NavigateToPayContacts)
100-
}
96+
.onSuccess {
97+
_uiState.update { state -> state.copy(isLoadingAfterAuth = false) }
98+
val hasContacts = pubkyRepo.pendingImportContacts.value.isNotEmpty()
99+
if (hasContacts) {
100+
_effects.emit(PubkyChoiceEffect.NavigateToContactImportOverview)
101+
} else {
102+
_effects.emit(PubkyChoiceEffect.NavigateToPayContacts)
103+
}
104+
}
105+
.onFailure {
106+
Logger.error("Preparing contact import failed", it, context = TAG)
107+
_uiState.update { state -> state.copy(isLoadingAfterAuth = false) }
108+
ToastEventBus.send(
109+
type = Toast.ToastType.ERROR,
110+
title = context.getString(R.string.common__error),
111+
description = it.message,
112+
)
113+
}
101114
}
102115
.onFailure {
103116
Logger.error("Auth approval failed", it, context = TAG)

app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import to.bitkit.ui.screens.widgets.blocks.toWeatherModel
3939
import javax.inject.Inject
4040
import kotlin.time.Duration.Companion.seconds
4141

42-
@Suppress("TooManyFunctions")
42+
@Suppress("TooManyFunctions", "LongParameterList")
4343
@HiltViewModel
4444
class HomeViewModel @Inject constructor(
4545
@ApplicationContext private val context: Context,

app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ class WalletViewModel @Inject constructor(
6262
companion object {
6363
private const val TAG = "WalletViewModel"
6464
private val TIMEOUT_RESTORE_WAIT = 30.seconds
65-
private const val CHANNEL_RECOVERY_RESTART_DELAY_MS = 500L
6665
}
6766

6867
val lightningState = lightningRepo.lightningState

0 commit comments

Comments
 (0)