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:
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user