Moves the account import idea to a route after login

This commit is contained in:
Vitor Pamplona
2026-03-10 10:19:18 -04:00
parent ff4356ce31
commit b8f9b18506
7 changed files with 115 additions and 167 deletions
@@ -97,6 +97,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.FollowPackMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen
@@ -177,6 +178,8 @@ fun AppNavigation(
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ImportFollows> { ImportFollowListScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
@@ -53,6 +53,8 @@ sealed class Route {
@Serializable object BookmarkGroups : Route()
@Serializable object ImportFollows : Route()
@Serializable data class BookmarkGroupView(
val dTag: String,
val bookmarkType: BookmarkType,
@@ -29,27 +29,16 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.followimport.Kind3EventData
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen
import com.vitorpamplona.amethyst.ui.screen.signup.ImportFollowListSection
import com.vitorpamplona.amethyst.ui.screen.signup.ImportFollowListViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun AccountScreen(accountSessionManager: AccountSessionManager) {
@@ -75,11 +64,7 @@ fun AccountScreen(accountSessionManager: AccountSessionManager) {
}
is AccountState.LoggedIn -> {
if (state.isNewAccount) {
NewAccountImportFollowsSetup(state.account, accountSessionManager)
} else {
LoggedInSetup(state, accountSessionManager)
}
LoggedInSetup(state, accountSessionManager)
}
}
}
@@ -139,80 +124,3 @@ fun LoggedInSetup(
)
}
}
@Composable
fun NewAccountImportFollowsSetup(
account: Account,
accountSessionManager: AccountSessionManager,
) {
val importViewModel: ImportFollowListViewModel = viewModel()
LaunchedEffect(account) {
importViewModel.configure(
fetchEvent = { kind, author, limit, onEvent ->
val filter =
Filter(
kinds = listOf(kind),
authors = listOf(author),
limit = limit,
)
val relayUrls =
listOf(
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band",
"wss://purplepag.es",
)
val filterMap =
relayUrls.associate { url ->
RelayUrlNormalizer.normalize(url) to listOf(filter)
}
val listener =
object : com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener {
override fun onEvent(
event: com.vitorpamplona.quartz.nip01Core.core.Event,
isLive: Boolean,
relay: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (event is ContactListEvent) {
onEvent(
Kind3EventData(
pTags =
event.tags
.filter { it.size >= 2 && it[0] == "p" }
.map { it.drop(1) },
createdAt = event.createdAt,
),
)
}
}
}
val subId =
com.vitorpamplona.quartz.nip01Core.relay.client.single
.newSubId()
Amethyst.instance.client.openReqSubscription(subId, filterMap, listener)
AutoCloseable { Amethyst.instance.client.close(subId) }
},
)
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
ImportFollowListSection(
onFollowsApplied = { entries ->
withContext(Dispatchers.IO) {
for (entry in entries) {
val user = account.cache.getOrCreateUser(entry.pubkeyHex)
account.follow(user)
}
}
},
onSkip = { accountSessionManager.finishNewAccountSetup() },
onDone = { accountSessionManager.finishNewAccountSetup() },
viewModel = importViewModel,
)
}
}
@@ -87,7 +87,6 @@ sealed class AccountState {
class LoggedIn(
val account: Account,
var route: Route? = null,
val isNewAccount: Boolean = false,
) : AccountState()
}
@@ -184,20 +183,10 @@ class AccountSessionManager(
fun startUI(
accountSettings: AccountSettings,
route: Route? = null,
isNewAccount: Boolean = false,
) {
val account = accountsCache.loadAccount(accountSettings)
_accountContent.update {
AccountState.LoggedIn(account, route, isNewAccount = isNewAccount)
}
}
fun finishNewAccountSetup() {
val current = _accountContent.value
if (current is AccountState.LoggedIn && current.isNewAccount) {
_accountContent.update {
AccountState.LoggedIn(current.account, current.route, isNewAccount = false)
}
AccountState.LoggedIn(account, route)
}
}
@@ -289,7 +278,7 @@ class AccountSessionManager(
localPreferences.setDefaultAccount(accountSettings)
startUI(accountSettings, isNewAccount = true)
startUI(accountSettings, route = Route.ImportFollows)
scope.launch(Dispatchers.IO) {
delay(2000) // waits for the new user to connect to the new relays.
@@ -18,7 +18,7 @@
* 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.ui.screen.signup
package com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
@@ -50,8 +50,6 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
@@ -59,6 +57,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -72,20 +71,59 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.followimport.FollowEntry
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// ── Public entry points ────────────────────────────────────────────────
@Composable
fun ImportFollowListScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val importViewModel: ImportFollowListViewModel = viewModel()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopBarExtensibleWithBackButton(
title = {},
popBack = nav::popBack,
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding),
) {
ImportFollowListSection(
onFollowsApplied = { entries ->
withContext(Dispatchers.IO) {
for (entry in entries) {
val user = accountViewModel.getOrCreateUser(entry.pubkeyHex)
accountViewModel.follow(user)
}
}
},
onSkip = nav::popBack,
onDone = nav::popBack,
viewModel = importViewModel,
)
}
}
}
/**
* Embeddable section for the signup wizard.
*/
@Composable
fun ImportFollowListSection(
onFollowsApplied: suspend (List<FollowEntry>) -> Unit,
@@ -117,11 +155,11 @@ fun ImportFollowListSection(
}
is ImportFollowState.Resolving -> {
LoadingIndicator("Resolving ${s.identifier}")
LoadingIndicator(stringResource(R.string.resolving, s.identifier))
}
is ImportFollowState.Fetching -> {
LoadingIndicator("Fetching follow list…")
LoadingIndicator(stringResource(R.string.fetching_follow_list))
}
is ImportFollowState.Preview -> {
@@ -133,7 +171,7 @@ fun ImportFollowListSection(
}
is ImportFollowState.Applying -> {
LoadingIndicator("Following ${s.count} accounts…")
LoadingIndicator(stringResource(R.string.following_accounts, s.count))
}
is ImportFollowState.Done -> {
@@ -151,30 +189,6 @@ fun ImportFollowListSection(
}
}
/**
* Standalone dialog for post-signup use (settings / profile screen).
*/
@Composable
fun ImportFollowListDialog(
onDismiss: () -> Unit,
onFollowsApplied: suspend (List<FollowEntry>) -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
) {
ImportFollowListSection(
onFollowsApplied = onFollowsApplied,
onSkip = onDismiss,
onDone = onDismiss,
modifier = Modifier.height(600.dp),
)
}
}
}
// ── Internal composables ───────────────────────────────────────────────
@Composable
@@ -189,14 +203,14 @@ private fun ImportHeader() {
)
Spacer(Modifier.width(10.dp))
Text(
"Import Follow List",
stringResource(R.string.import_follow_list),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(8.dp))
Text(
"Start with a great feed by following the same people as someone you trust.",
stringResource(R.string.start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -215,15 +229,15 @@ private fun InputSection(
OutlinedTextField(
value = identifier,
onValueChange = { identifier = it },
label = { Text("Profile to import from") },
placeholder = { Text("npub1…, alice@example.com, or example.bit") },
label = { Text(stringResource(R.string.profile_to_import_from)) },
placeholder = { Text(stringResource(R.string.name_search_npub1_alice_example_com)) },
singleLine = true,
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
supportingText = {
Text(
"Supports npub, NIP-05, hex, and Namecoin (.bit / d/ / id/)",
stringResource(R.string.supports_npub_nip_05_hex_and_namecoin_bit_d_id),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
@@ -244,7 +258,7 @@ private fun InputSection(
enabled = enabled && identifier.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
) { Text("Look Up Follow List") }
) { Text(stringResource(R.string.look_up_follow_list)) }
}
}
@@ -253,16 +267,14 @@ private fun IdleHint() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(24.dp)) {
Text(
"Tip",
stringResource(R.string.tip),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(4.dp))
Text(
"Enter the profile of a friend or community leader. " +
"You can use their npub, NIP-05 address, or a Namecoin name " +
"like alice@example.bit or id/alice for blockchain-verified identities.",
stringResource(R.string.import_follows_tips),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -303,17 +315,19 @@ private fun PreviewList(
// Summary
Row(
Modifier.fillMaxWidth().padding(vertical = 4.dp),
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${state.totalCount} accounts found",
stringResource(R.string.accounts_found, state.totalCount),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
"${state.selectedCount} selected",
stringResource(R.string.selected, state.selectedCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
@@ -321,7 +335,10 @@ private fun PreviewList(
// Select all
Row(
Modifier.fillMaxWidth().clickable { onSelectAll(state.selectedCount < state.totalCount) }.padding(vertical = 8.dp),
Modifier
.fillMaxWidth()
.clickable { onSelectAll(state.selectedCount < state.totalCount) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
@@ -362,7 +379,7 @@ private fun NamecoinResolvedBadge(namecoinSource: String) {
Spacer(Modifier.width(8.dp))
Column {
Text(
"Resolved via Namecoin",
stringResource(R.string.resolved_via_namecoin),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF4A90D9),
@@ -383,7 +400,10 @@ private fun FollowEntryRow(
onToggle: () -> Unit,
) {
Row(
Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 6.dp, horizontal = 4.dp),
Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(vertical = 6.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
@@ -399,7 +419,10 @@ private fun FollowEntryRow(
)
Spacer(Modifier.width(10.dp))
Box(
Modifier.size(32.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
Modifier
.size(32.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
@@ -455,13 +478,13 @@ private fun DoneMessage(
)
Spacer(Modifier.height(12.dp))
Text(
"Now following $count accounts",
stringResource(R.string.now_following_accounts, count),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(6.dp))
Text(
"Your feed is ready.",
stringResource(R.string.your_feed_is_ready),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -499,14 +522,14 @@ private fun BottomActions(
when (state) {
is ImportFollowState.Preview -> {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
TextButton(onClick = onSkip) { Text("Skip") }
TextButton(onClick = onSkip) { Text(stringResource(R.string.skip)) }
Row {
OutlinedButton(onClick = onSearchAnother, shape = RoundedCornerShape(12.dp)) {
Text("Search Another")
Text(stringResource(R.string.search_another))
}
Spacer(Modifier.width(8.dp))
Button(onClick = onApply, enabled = state.selectedCount > 0, shape = RoundedCornerShape(12.dp)) {
Text("Follow ${state.selectedCount} accounts")
Text(stringResource(R.string.follow_accounts, state.selectedCount))
}
}
}
@@ -515,15 +538,15 @@ private fun BottomActions(
is ImportFollowState.Done -> {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedButton(onClick = onSearchAnother, shape = RoundedCornerShape(12.dp)) {
Text("Import More")
Text(stringResource(R.string.import_more))
}
Button(onClick = onDone, shape = RoundedCornerShape(12.dp)) { Text("Continue") }
Button(onClick = onDone, shape = RoundedCornerShape(12.dp)) { Text(stringResource(R.string.continue_txt)) }
}
}
is ImportFollowState.Idle, is ImportFollowState.Error -> {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onSkip) { Text("Skip for now") }
TextButton(onClick = onSkip) { Text(stringResource(R.string.skip_for_now)) }
}
}
@@ -18,7 +18,7 @@
* 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.ui.screen.signup
package com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
+23
View File
@@ -1229,6 +1229,7 @@
<string name="route_video">Shorts</string>
<string name="route_chess">Chess</string>
<string name="route_security_filters">Security Filters</string>
<string name="route_import_follows">Import Follows</string>
<string name="new_post">New Post</string>
<string name="new_short">New Shorts: images or videos</string>
@@ -1715,4 +1716,26 @@
<string name="kind_voice_msg">Voice Msg</string>
<string name="kind_voice_reply">Voice Reply</string>
<string name="kind_wiki">Wiki</string>
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Start with a great feed by following the same people as someone you trust.</string>
<string name="import_follow_list">Import Follow List</string>
<string name="profile_to_import_from">Profile to import from</string>
<string name="name_search_npub1_alice_example_com">name search, npub1…, alice@example.com</string>
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Supports npub, NIP-05, hex, and Namecoin (.bit / d/ / id/)</string>
<string name="look_up_follow_list">Look Up Follow List</string>
<string name="tip">Tip</string>
<string name="accounts_found">%1$d accounts found</string>
<string name="selected">%1$d selected</string>
<string name="resolved_via_namecoin">Resolved via Namecoin</string>
<string name="now_following_accounts">Now following %1$d accounts</string>
<string name="your_feed_is_ready">Your feed is ready.</string>
<string name="skip">Skip</string>
<string name="search_another">Search Another</string>
<string name="follow_accounts">Follow %1$d accounts</string>
<string name="import_more">Import More</string>
<string name="continue_txt">Continue</string>
<string name="skip_for_now">Skip for now</string>
<string name="resolving">Resolving %1$s…</string>
<string name="fetching_follow_list">Fetching follow list…</string>
<string name="following_accounts">Following %1$d accounts…</string>
<string name="import_follows_tips">"Enter the profile of a friend or community leader. You can use their npub, NIP-05 address, or a Namecoin name like alice@example.bit or id/alice for blockchain-verified identities."</string>
</resources>