feat: extend onboarding with topic follows and first-post coach

New flow after profile: People suggestions → Topics → First post → Feed.

- Topic screen fetches a curated 'trending' set and an 'all' set from
  feeds.nostrarchives.com (kind 30015) so users can pick hashtags via
  autocomplete or tap popular chips. Selections are saved through the
  existing InterestRepository / followHashtag path (kind 30015 interest
  set named 'Interests').
- First-post screen pre-fills #introductions and reuses the existing
  ComposeViewModel.publish flow. The feed subscription kicks off on
  screen entry so the feed is ready by the time the user finishes.
- Skip handler on the People screen now calls finishOnboarding with an
  empty selection, so skippers still get a kind 3 event that at least
  self-follows — their own posts will show up in their feed.
This commit is contained in:
Barry Deen
2026-04-16 20:30:16 -04:00
parent 71a2c72ef0
commit 8240c1e5ee
4 changed files with 644 additions and 12 deletions
+71 -12
View File
@@ -95,6 +95,8 @@ import com.wisp.app.ui.component.PipController
import com.wisp.app.ui.component.FullScreenVideoPlayer
import com.wisp.app.ui.component.FullScreenVideoState
import com.wisp.app.ui.screen.OnboardingSuggestionsScreen
import com.wisp.app.ui.screen.OnboardingTopicsScreen
import com.wisp.app.ui.screen.OnboardingFirstPostScreen
import com.wisp.app.ui.screen.RelayDetailScreen
import com.wisp.app.ui.screen.WalletScreen
import com.wisp.app.viewmodel.BlossomServersViewModel
@@ -154,6 +156,8 @@ object Routes {
const val LOADING = "loading"
const val ONBOARDING_PROFILE = "onboarding/profile"
const val ONBOARDING_SUGGESTIONS = "onboarding/suggestions"
const val ONBOARDING_TOPICS = "onboarding/topics"
const val ONBOARDING_FIRST_POST = "onboarding/first-post"
const val RELAY_DETAIL = "relay_detail/{relayUrl}"
const val CUSTOM_EMOJIS = "custom_emojis"
const val HASHTAG_FEED = "hashtag/{tag}"
@@ -210,6 +214,7 @@ fun WispNavHost(
val consoleViewModel: ConsoleViewModel = viewModel()
val relayHealthViewModel: RelayHealthViewModel = viewModel()
val onboardingViewModel: OnboardingViewModel = viewModel()
val topicOnboardingViewModel: com.wisp.app.viewmodel.TopicOnboardingViewModel = viewModel()
val splashViewModel: SplashViewModel = viewModel()
relayViewModel.relayPool = feedViewModel.relayPool
@@ -2978,24 +2983,78 @@ fun WispNavHost(
selectedPubkeys = selectedPubkeys,
signer = activeSigner
)
feedViewModel.initRelays()
navController.navigate(Routes.LOADING) {
popUpTo(0) { inclusive = true }
}
navController.navigate(Routes.ONBOARDING_TOPICS)
}
},
onSkip = {
authViewModel.keyRepo.markOnboardingComplete()
feedViewModel.setFeedType(FeedType.EXTENDED_FOLLOWS)
feedViewModel.reloadForNewAccount()
relayViewModel.reload()
blossomServersViewModel.reload()
composeViewModel.reloadBlossomRepo()
walletViewModel.refreshState()
scope.launch {
feedViewModel.setFeedType(FeedType.EXTENDED_FOLLOWS)
feedViewModel.reloadForNewAccount()
relayViewModel.reload()
blossomServersViewModel.reload()
composeViewModel.reloadBlossomRepo()
walletViewModel.refreshState()
// Publish a kind 3 that at least follows the user themselves so
// their own posts land in their feed — finishOnboarding also
// marks onboarding complete.
onboardingViewModel.finishOnboarding(
relayPool = feedViewModel.relayPool,
contactRepo = feedViewModel.contactRepo,
selectedPubkeys = emptySet(),
signer = activeSigner
)
navController.navigate(Routes.ONBOARDING_TOPICS)
}
}
)
}
composable(Routes.ONBOARDING_TOPICS) {
LaunchedEffect(Unit) {
topicOnboardingViewModel.load(feedViewModel.relayPool)
}
OnboardingTopicsScreen(
viewModel = topicOnboardingViewModel,
onContinue = {
val selected = topicOnboardingViewModel.selectedTopics.value
if (selected.isNotEmpty()) {
// Create the default "Interests" set once, then add each topic to it.
// followHashtag creates-on-first-add if the set does not exist yet,
// but calling createInterestSet first gives it a nice title.
feedViewModel.createInterestSet("Interests")
for (tag in selected) {
feedViewModel.followHashtag(tag, "interests")
}
}
navController.navigate(Routes.ONBOARDING_FIRST_POST)
},
onSkip = {
navController.navigate(Routes.ONBOARDING_FIRST_POST)
}
)
}
composable(Routes.ONBOARDING_FIRST_POST) {
LaunchedEffect(Unit) {
// Kick off the feed subscription while the user composes their intro.
feedViewModel.initRelays()
}
OnboardingFirstPostScreen(
viewModel = composeViewModel,
relayPool = feedViewModel.relayPool,
outboxRouter = feedViewModel.outboxRouter,
signer = activeSigner,
onPosted = {
topicOnboardingViewModel.reset()
navController.navigate(Routes.FEED) {
popUpTo(0) { inclusive = true }
}
},
onSkip = {
topicOnboardingViewModel.reset()
navController.navigate(Routes.FEED) {
popUpTo(0) { inclusive = true }
}
scope.launch { feedViewModel.initRelays() }
}
)
}
@@ -0,0 +1,154 @@
package com.wisp.app.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.wisp.app.nostr.NostrSigner
import com.wisp.app.relay.OutboxRouter
import com.wisp.app.relay.RelayPool
import com.wisp.app.viewmodel.ComposeViewModel
private const val INTRO_PREFIX = "#introductions\n\n"
@Composable
fun OnboardingFirstPostScreen(
viewModel: ComposeViewModel,
relayPool: RelayPool,
outboxRouter: OutboxRouter?,
signer: NostrSigner?,
onPosted: () -> Unit,
onSkip: () -> Unit
) {
val content by viewModel.content.collectAsState()
val publishing by viewModel.publishing.collectAsState()
val countdown by viewModel.countdownSeconds.collectAsState()
val error by viewModel.error.collectAsState()
LaunchedEffect(Unit) {
if (!content.text.contains("#introductions")) {
val text = INTRO_PREFIX
viewModel.updateContent(TextFieldValue(text, TextRange(text.length)))
}
}
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
) {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
) {
Spacer(Modifier.height(48.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Say hello to nostr",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface
)
TextButton(onClick = onSkip, enabled = !publishing) {
Text("Skip", style = MaterialTheme.typography.labelLarge)
}
}
Spacer(Modifier.height(8.dp))
Text(
text = "Post a short introduction with the #introductions hashtag — a few words about you and how you found wisp is plenty.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = content,
onValueChange = { viewModel.updateContent(it) },
placeholder = { Text("Write your introduction...") },
modifier = Modifier
.fillMaxWidth()
.height(220.dp),
shape = RoundedCornerShape(12.dp),
enabled = !publishing
)
if (error != null) {
Spacer(Modifier.height(8.dp))
Text(
text = error ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(Modifier.height(24.dp))
}
Box(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Button(
onClick = {
if (countdown != null) {
viewModel.cancelPublish()
} else {
viewModel.publish(
relayPool = relayPool,
outboxRouter = outboxRouter,
signer = signer,
onSuccess = onPosted
)
}
},
enabled = (countdown != null) ||
(!publishing && content.text.trim().isNotEmpty() && signer != null),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
val label = when {
countdown != null -> "Cancel — sending in ${countdown}s"
publishing -> "Publishing..."
else -> "Post introduction"
}
Text(label, style = MaterialTheme.typography.labelLarge)
}
}
}
}
@@ -0,0 +1,249 @@
package com.wisp.app.ui.screen
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.wisp.app.viewmodel.TopicOnboardingViewModel
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun OnboardingTopicsScreen(
viewModel: TopicOnboardingViewModel,
onContinue: () -> Unit,
onSkip: () -> Unit
) {
val popularTopics by viewModel.popularTopics.collectAsState()
val loadingPopular by viewModel.loadingPopular.collectAsState()
val query by viewModel.query.collectAsState()
val suggestions by viewModel.suggestions.collectAsState()
val selectedTopics by viewModel.selectedTopics.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
) {
Spacer(Modifier.height(48.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Follow topics",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface
)
TextButton(onClick = onSkip) {
Text("Skip", style = MaterialTheme.typography.labelLarge)
}
}
Spacer(Modifier.height(8.dp))
Text(
text = "Pick a few hashtags so your feed has more to show",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = query,
onValueChange = { viewModel.updateQuery(it) },
placeholder = { Text("Search topics") },
singleLine = true,
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = {
if (suggestions.isEmpty()) viewModel.updateQuery("") else viewModel.addCustomTopic()
}) {
Icon(
imageVector = if (suggestions.isEmpty()) Icons.Default.Close else Icons.Default.Add,
contentDescription = null
)
}
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
if (suggestions.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
) {
LazyColumn(
modifier = Modifier.heightIn(max = 240.dp)
) {
items(suggestions) { topic ->
SuggestionRow(
topic = topic,
onClick = { viewModel.toggleTopic(topic) }
)
}
}
}
}
if (selectedTopics.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
Text(
text = "Your topics (${selectedTopics.size})",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
selectedTopics.forEach { topic ->
FilterChip(
selected = true,
onClick = { viewModel.toggleTopic(topic) },
label = { Text("#$topic") },
leadingIcon = {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
}
}
}
Spacer(Modifier.height(24.dp))
Text(
text = "Popular topics",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(8.dp))
if (loadingPopular) {
Box(
modifier = Modifier.fillMaxWidth().height(120.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
}
} else if (popularTopics.isEmpty()) {
Text(
text = "Couldn't load trending topics — you can still search above.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
popularTopics.take(40).forEach { topic ->
val isSelected = topic in selectedTopics
FilterChip(
selected = isSelected,
onClick = { viewModel.toggleTopic(topic) },
label = { Text("#$topic") }
)
}
}
}
Spacer(Modifier.height(24.dp))
}
Box(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
val label = if (selectedTopics.isEmpty()) {
"Continue without topics"
} else {
"Follow ${selectedTopics.size} topic${if (selectedTopics.size == 1) "" else "s"}"
}
Text(label, style = MaterialTheme.typography.labelLarge)
}
}
}
}
@Composable
private fun SuggestionRow(topic: String, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
Text(
text = "#$topic",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
}
@@ -0,0 +1,170 @@
package com.wisp.app.viewmodel
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.wisp.app.nostr.ClientMessage
import com.wisp.app.nostr.Filter
import com.wisp.app.nostr.Nip51
import com.wisp.app.nostr.NostrEvent
import com.wisp.app.relay.RelayPool
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
/**
* UI state for the onboarding topics step. Loads curated "trending" and
* "all" hashtag sets (kind 30015) from feeds.nostrarchives.com and tracks
* the user's selections. Publishing is delegated to the existing
* FeedViewModel.followHashtag / createInterestSet APIs once the user
* continues this class only owns the pick-list UI state.
*/
class TopicOnboardingViewModel(app: Application) : AndroidViewModel(app) {
private val _popularTopics = MutableStateFlow<List<String>>(emptyList())
val popularTopics: StateFlow<List<String>> = _popularTopics
private val _loadingPopular = MutableStateFlow(true)
val loadingPopular: StateFlow<Boolean> = _loadingPopular
private var allTopics: List<String> = emptyList()
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query
private val _suggestions = MutableStateFlow<List<String>>(emptyList())
val suggestions: StateFlow<List<String>> = _suggestions
private val _selectedTopics = MutableStateFlow<Set<String>>(emptySet())
val selectedTopics: StateFlow<Set<String>> = _selectedTopics
private var loadJob: Job? = null
fun load(relayPool: RelayPool) {
if (loadJob != null) return
loadJob = viewModelScope.launch {
launch { loadTopicsFor(relayPool, TRENDING_RELAY, "trending", "topics-trending") }
launch { loadTopicsFor(relayPool, ALL_RELAY, "all", "topics-all") }
}
}
private suspend fun loadTopicsFor(
relayPool: RelayPool,
relayUrl: String,
dTag: String,
subId: String
) {
try {
val filter = Filter(kinds = listOf(Nip51.KIND_INTEREST_SET), dTags = listOf(dTag))
relayPool.sendToRelayOrEphemeral(relayUrl, ClientMessage.req(subId, filter))
var newest: NostrEvent? = null
collectUntilEose(relayPool, subId, 1, 6_000) { event ->
if (event.kind != Nip51.KIND_INTEREST_SET) return@collectUntilEose
val curr = newest
if (curr == null || event.created_at > curr.created_at) newest = event
}
val tags = newest?.let { Nip51.parseInterestSet(it)?.hashtags?.toList() } ?: emptyList()
when (dTag) {
"trending" -> {
_popularTopics.value = tags
_loadingPopular.value = false
}
"all" -> {
allTopics = tags
filterSuggestions(_query.value)
}
}
} catch (e: Exception) {
Log.e(TAG, "loadTopicsFor($dTag) failed: ${e.message}")
if (dTag == "trending") _loadingPopular.value = false
}
}
private suspend fun collectUntilEose(
relayPool: RelayPool,
subId: String,
expectedEose: Int,
timeoutMs: Long,
onEvent: (NostrEvent) -> Unit
) {
val done = CompletableDeferred<Unit>()
var eoseCount = 0
val collectJob = viewModelScope.launch {
relayPool.relayEvents.collect { relayEvent ->
if (relayEvent.subscriptionId == subId) onEvent(relayEvent.event)
}
}
val eoseJob = viewModelScope.launch {
relayPool.eoseSignals.collect { id ->
if (id == subId) {
eoseCount++
if (eoseCount >= expectedEose) done.complete(Unit)
}
}
}
withTimeoutOrNull(timeoutMs) { done.await() }
collectJob.cancel()
eoseJob.cancel()
relayPool.closeOnAllRelays(subId)
}
fun updateQuery(q: String) {
_query.value = q
filterSuggestions(q)
}
private fun filterSuggestions(q: String) {
val trimmed = q.trim().lowercase().removePrefix("#")
if (trimmed.isEmpty()) {
_suggestions.value = emptyList()
return
}
val selected = _selectedTopics.value
_suggestions.value = allTopics.asSequence()
.filter { it.contains(trimmed) && it !in selected }
.sortedWith(compareBy({ !it.startsWith(trimmed) }, { it.length }, { it }))
.take(20)
.toList()
}
fun toggleTopic(topic: String) {
val clean = topic.trim().lowercase().removePrefix("#")
if (clean.isEmpty()) return
val current = _selectedTopics.value
_selectedTopics.value = if (clean in current) current - clean else current + clean
filterSuggestions(_query.value)
}
fun addCustomTopic() {
val clean = _query.value.trim().lowercase().removePrefix("#")
if (clean.isEmpty()) return
_selectedTopics.value = _selectedTopics.value + clean
_query.value = ""
_suggestions.value = emptyList()
}
fun reset() {
loadJob?.cancel()
loadJob = null
_popularTopics.value = emptyList()
_loadingPopular.value = true
allTopics = emptyList()
_query.value = ""
_suggestions.value = emptyList()
_selectedTopics.value = emptySet()
}
companion object {
private const val TAG = "TopicOnboarding"
private const val TRENDING_RELAY = "wss://feeds.nostrarchives.com/hashtags/trending"
private const val ALL_RELAY = "wss://feeds.nostrarchives.com/hashtags/all"
}
}