feat: add For You feed blending extended/follows with trending and hashtags
New default feed type that combines three streams into one sorted list: - Main author subscription (extended network if cached, follow feed otherwise) - Top-reactions and top-replies trending notes (today timeframe) - Notes for all followed hashtags, batched 10 at a time against the search relay over a 24h window Supplementary fetches are one-shot per feed-generation and self-close on EOSE + drain; events flow through EventRepository.addEvent() for dedup and in-order insertion alongside the main feed. Pagination paginates only the author subscription; trending and hashtag batches are initial-load only.
This commit is contained in:
@@ -759,6 +759,7 @@ fun FeedScreen(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val feedLabel = when (feedType) {
|
||||
FeedType.FOR_YOU -> stringResource(R.string.tab_for_you)
|
||||
FeedType.FOLLOWS -> stringResource(R.string.tab_follows)
|
||||
FeedType.EXTENDED_FOLLOWS -> stringResource(R.string.tab_extended)
|
||||
FeedType.RELAY -> if (selectedRelay != null) {
|
||||
@@ -789,6 +790,16 @@ fun FeedScreen(
|
||||
onDismissRequest = { showFeedTypeDropdown = false },
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.tab_for_you)) },
|
||||
onClick = {
|
||||
showFeedTypeDropdown = false
|
||||
viewModel.setFeedType(FeedType.FOR_YOU)
|
||||
},
|
||||
trailingIcon = if (feedType == FeedType.FOR_YOU) {{
|
||||
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
}} else null
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.tab_follows)) },
|
||||
onClick = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.wisp.app.relay.SubscriptionManager
|
||||
import com.wisp.app.repo.ContactRepository
|
||||
import com.wisp.app.repo.EventRepository
|
||||
import com.wisp.app.repo.ExtendedNetworkRepository
|
||||
import com.wisp.app.repo.InterestRepository
|
||||
import com.wisp.app.repo.KeyRepository
|
||||
import com.wisp.app.repo.ListRepository
|
||||
import com.wisp.app.repo.MetadataFetcher
|
||||
@@ -51,6 +52,7 @@ class FeedSubscriptionManager(
|
||||
private val listRepo: ListRepository,
|
||||
private val notifRepo: NotificationRepository,
|
||||
private val extendedNetworkRepo: ExtendedNetworkRepository,
|
||||
private val interestRepo: InterestRepository,
|
||||
private val keyRepo: KeyRepository,
|
||||
private val healthTracker: RelayHealthTracker,
|
||||
private val relayScoreBoard: RelayScoreBoard,
|
||||
@@ -69,6 +71,7 @@ class FeedSubscriptionManager(
|
||||
private const val KEY_LAST_RELAY_SET_RELAYS = "last_relay_set_relays"
|
||||
private const val KEY_LAST_LIST_PUBKEY = "last_list_pubkey"
|
||||
private const val KEY_LAST_LIST_DTAG = "last_list_dtag"
|
||||
private const val HASHTAG_BATCH_SIZE = 10
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -80,7 +83,7 @@ class FeedSubscriptionManager(
|
||||
relayPool.registerDedupBypass("trending-users-")
|
||||
}
|
||||
|
||||
private val _feedType = MutableStateFlow(FeedType.FOLLOWS)
|
||||
private val _feedType = MutableStateFlow(FeedType.FOR_YOU)
|
||||
val feedType: StateFlow<FeedType> = _feedType
|
||||
|
||||
private val _selectedRelay = MutableStateFlow<String?>(null)
|
||||
@@ -153,6 +156,10 @@ class FeedSubscriptionManager(
|
||||
private var viewportEngagementJob: Job? = null
|
||||
private var hasRestoredFeedType = false
|
||||
|
||||
// For You supplementary fetches (trending + hashtags)
|
||||
private var forYouSupplementaryJob: Job? = null
|
||||
private var forYouGeneration = 0L
|
||||
|
||||
fun markLoadingComplete() { _loadingScreenComplete.value = true }
|
||||
|
||||
/** Resolve indexer relays: user's search relays (kind 10007) with default fallback. */
|
||||
@@ -194,16 +201,19 @@ class FeedSubscriptionManager(
|
||||
}
|
||||
|
||||
when (type) {
|
||||
FeedType.FOLLOWS, FeedType.EXTENDED_FOLLOWS -> {
|
||||
FeedType.FOR_YOU, FeedType.FOLLOWS, FeedType.EXTENDED_FOLLOWS -> {
|
||||
if (prev == FeedType.LIST) {
|
||||
Log.d("RLC", "[FeedSub] switching from $prev to $type — rebuilding feed from cache and resubscribing")
|
||||
eventRepo.resetFeedDisplay()
|
||||
eventRepo.rebuildFeedFromCache()
|
||||
resubscribeFeed()
|
||||
} else {
|
||||
// Switching from RELAY or between FOLLOWS/EXTENDED — main feed still running
|
||||
// Switching from RELAY or between FOLLOWS/EXTENDED/FOR_YOU — main feed still running
|
||||
Log.d("RLC", "[FeedSub] setFeedType $prev → $type — filter-only switch, no resubscribe needed, feedSize=${eventRepo.feed.value.size}")
|
||||
}
|
||||
if (type == FeedType.FOR_YOU && prev != FeedType.FOR_YOU) {
|
||||
startForYouSupplementaryFetches()
|
||||
}
|
||||
}
|
||||
FeedType.RELAY -> {
|
||||
// Skip if already in RELAY mode — setSelectedRelay() already triggered
|
||||
@@ -333,7 +343,7 @@ class FeedSubscriptionManager(
|
||||
val indexerRelays = getIndexerRelays()
|
||||
val excludedUrls = getExcludedRelayUrls()
|
||||
val targetedRelays: Set<String> = when (_feedType.value) {
|
||||
FeedType.FOLLOWS, FeedType.EXTENDED_FOLLOWS -> {
|
||||
FeedType.FOR_YOU, FeedType.FOLLOWS, FeedType.EXTENDED_FOLLOWS -> {
|
||||
val cache = extendedNetworkRepo.cachedNetwork.value
|
||||
val firstDegree = contactRepo.getFollowList().map { it.pubkey }
|
||||
val allAuthors = if (cache != null) {
|
||||
@@ -444,6 +454,10 @@ class FeedSubscriptionManager(
|
||||
}
|
||||
|
||||
restoreSavedFeedType()
|
||||
|
||||
if (_feedType.value == FeedType.FOR_YOU) {
|
||||
startForYouSupplementaryFetches()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +469,7 @@ class FeedSubscriptionManager(
|
||||
val indexerRelays = getIndexerRelays()
|
||||
val excludedUrls = getExcludedRelayUrls()
|
||||
when (_feedType.value) {
|
||||
FeedType.FOLLOWS, FeedType.EXTENDED_FOLLOWS -> {
|
||||
FeedType.FOR_YOU, FeedType.FOLLOWS, FeedType.EXTENDED_FOLLOWS -> {
|
||||
val oldest = eventRepo.getOldestTimestamp() ?: run { isLoadingMore = false; return }
|
||||
val cache = extendedNetworkRepo.cachedNetwork.value
|
||||
val firstDegree = contactRepo.getFollowList().map { it.pubkey }
|
||||
@@ -1234,11 +1248,11 @@ class FeedSubscriptionManager(
|
||||
|
||||
val savedName = prefs.getString(KEY_LAST_FEED_TYPE, null) ?: return
|
||||
val savedType = try { FeedType.valueOf(savedName) } catch (_: Exception) { return }
|
||||
if (savedType == FeedType.FOLLOWS) return
|
||||
if (savedType == _feedType.value) return
|
||||
|
||||
Log.d("RLC", "[FeedSub] restoring saved feed type: $savedType")
|
||||
when (savedType) {
|
||||
FeedType.TRENDING, FeedType.EXTENDED_FOLLOWS -> setFeedType(savedType)
|
||||
FeedType.FOLLOWS, FeedType.TRENDING, FeedType.EXTENDED_FOLLOWS -> setFeedType(savedType)
|
||||
FeedType.RELAY -> {
|
||||
val relaySetName = prefs.getString(KEY_LAST_RELAY_SET_NAME, null)
|
||||
val relaySetRelays = prefs.getString(KEY_LAST_RELAY_SET_RELAYS, null)
|
||||
@@ -1269,4 +1283,76 @@ class FeedSubscriptionManager(
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires the two supplementary event sources that make FOR_YOU feel more diverse than the
|
||||
* plain follow/extended feed: (1) top-reactions and top-replies trending notes (fast), and
|
||||
* (2) notes for all followed hashtags via the search relay (slow, batched). All events
|
||||
* funnel through eventRepo.addEvent() so dedup + binary insert by created_at are free.
|
||||
*/
|
||||
private fun startForYouSupplementaryFetches() {
|
||||
forYouSupplementaryJob?.cancel()
|
||||
val gen = ++forYouGeneration
|
||||
forYouSupplementaryJob = scope.launch {
|
||||
launch { fetchTrendingForForYou(TrendingMetric.REACTIONS, gen) }
|
||||
launch { fetchTrendingForForYou(TrendingMetric.REPLIES, gen) }
|
||||
|
||||
val hashtags = interestRepo.getAllHashtags().toList()
|
||||
if (hashtags.isEmpty()) {
|
||||
Log.d("RLC", "[FeedSub] FOR_YOU supplementary: no followed hashtags, skipping hashtag fetch")
|
||||
return@launch
|
||||
}
|
||||
val since = System.currentTimeMillis() / 1000 - 24 * 3600
|
||||
Log.d("RLC", "[FeedSub] FOR_YOU hashtag fetch: ${hashtags.size} tags in ${(hashtags.size + HASHTAG_BATCH_SIZE - 1) / HASHTAG_BATCH_SIZE} batches")
|
||||
hashtags.chunked(HASHTAG_BATCH_SIZE).forEachIndexed { idx, chunk ->
|
||||
launch { fetchHashtagChunkForForYou(chunk, since, gen, idx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchTrendingForForYou(metric: TrendingMetric, gen: Long) {
|
||||
val url = buildTrendingRelayUrl(metric, TrendingTimeframe.TODAY)
|
||||
val subId = "foryou-trending-${metric.slug}-$gen"
|
||||
val filter = Filter(kinds = FEED_KINDS, limit = 100)
|
||||
val collector = scope.launch {
|
||||
relayPool.relayEvents.collect { re ->
|
||||
if (re.subscriptionId == subId && gen == forYouGeneration) {
|
||||
eventRepo.addEvent(re.event)
|
||||
eventRepo.requestProfileIfMissing(re.event.pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
relayPool.sendToRelayOrEphemeral(url, ClientMessage.req(subId, filter), skipBadCheck = true)
|
||||
subManager.awaitEoseWithTimeout(subId, timeoutMs = 10_000)
|
||||
delay(1_500)
|
||||
} finally {
|
||||
collector.cancel()
|
||||
subManager.closeSubscription(subId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchHashtagChunkForForYou(chunk: List<String>, since: Long, gen: Long, idx: Int) {
|
||||
val subId = "foryou-hashtags-$gen-$idx"
|
||||
val filter = Filter(kinds = listOf(1), tTags = chunk, since = since, limit = 100)
|
||||
val collector = scope.launch {
|
||||
relayPool.relayEvents.collect { re ->
|
||||
if (re.subscriptionId == subId && gen == forYouGeneration && re.event.kind == 1) {
|
||||
eventRepo.addEvent(re.event)
|
||||
eventRepo.requestProfileIfMissing(re.event.pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
relayPool.sendToRelayOrEphemeral(
|
||||
SearchViewModel.DEFAULT_SEARCH_RELAY,
|
||||
ClientMessage.req(subId, filter)
|
||||
)
|
||||
subManager.awaitEoseWithTimeout(subId, timeoutMs = 15_000)
|
||||
delay(2_000)
|
||||
} finally {
|
||||
collector.cancel()
|
||||
subManager.closeSubscription(subId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
enum class FeedType { FOLLOWS, EXTENDED_FOLLOWS, RELAY, LIST, TRENDING }
|
||||
enum class FeedType { FOR_YOU, FOLLOWS, EXTENDED_FOLLOWS, RELAY, LIST, TRENDING }
|
||||
|
||||
enum class TrendingMode { NOTES, USERS }
|
||||
|
||||
@@ -284,7 +284,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
|
||||
// -- Manager classes --
|
||||
val feedSub: FeedSubscriptionManager = FeedSubscriptionManager(
|
||||
relayPool, outboxRouter, subManager, eventRepo, contactRepo, listRepo, notifRepo,
|
||||
extendedNetworkRepo, keyRepo, healthTracker, relayScoreBoard, profileRepo,
|
||||
extendedNetworkRepo, interestRepo, keyRepo, healthTracker, relayScoreBoard, profileRepo,
|
||||
metadataFetcher, viewModelScope, processingDispatcher, pubkeyHex,
|
||||
getApplication<Application>().getSharedPreferences("wisp_feed", android.content.Context.MODE_PRIVATE)
|
||||
)
|
||||
|
||||
@@ -343,7 +343,9 @@ class StartupCoordinator(
|
||||
}
|
||||
|
||||
if ((added.isNotEmpty() || removed.isNotEmpty()) &&
|
||||
(feedSub.feedType.value == FeedType.FOLLOWS || feedSub.feedType.value == FeedType.EXTENDED_FOLLOWS)) {
|
||||
(feedSub.feedType.value == FeedType.FOR_YOU ||
|
||||
feedSub.feedType.value == FeedType.FOLLOWS ||
|
||||
feedSub.feedType.value == FeedType.EXTENDED_FOLLOWS)) {
|
||||
rebuildRelayPool()
|
||||
feedSub.resubscribeFeed()
|
||||
feedSub.applyAuthorFilterForFeedType(feedSub.feedType.value)
|
||||
|
||||
@@ -206,6 +206,7 @@
|
||||
<string name="tab_bad">Bad</string>
|
||||
<string name="tab_people">Profiles</string>
|
||||
<string name="tab_notes">Notes</string>
|
||||
<string name="tab_for_you">For You</string>
|
||||
<string name="tab_follows">Follows</string>
|
||||
<string name="tab_extended">Extended</string>
|
||||
<string name="tab_trending">Trending</string>
|
||||
|
||||
Reference in New Issue
Block a user