Direct messaging, search, and content discovery are three distinct systems that share a theme: low-latency, high-relevance results. Each requires different architecture choices.
Direct Messages (DMs)
Conversation List
data class ConversationPreview(
val conversationId: String,
val partnerName: String,
val partnerAvatarUrl: String,
val lastMessage: String,
val lastMessageAt: Long,
val unreadCount: Int,
val isOnline: Boolean
)
// Conversation list from Room, synced via WebSocket
class ConversationListViewModel @Inject constructor(
private val repository: ConversationRepository,
private val presenceService: PresenceService
) : ViewModel() {
val conversations: StateFlow<List<ConversationPreview>> =
repository.observeConversations()
.combine(presenceService.onlineUsers) { convs, onlineIds ->
convs.map { it.copy(isOnline = it.partnerId in onlineIds) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
Message Threading
@Entity(tableName = "messages")
data class MessageEntity(
@PrimaryKey val id: String,
val conversationId: String,
val senderId: String,
val text: String?,
val mediaUrl: String?,
val mediaType: MediaType?,
val sentAt: Long,
val deliveredAt: Long?,
val readAt: Long?,
val status: MessageStatus // SENDING, SENT, DELIVERED, READ, FAILED
)
// Date separator in UI
sealed class MessageListItem {
data class DateHeader(val date: LocalDate) : MessageListItem()
data class Message(val entity: MessageEntity) : MessageListItem()
}
fun List<MessageEntity>.toListItems(): List<MessageListItem> {
val items = mutableListOf<MessageListItem>()
var lastDate: LocalDate? = null
sortedBy { it.sentAt }.forEach { msg ->
val date = Instant.ofEpochMilli(msg.sentAt).atZone(ZoneId.systemDefault()).toLocalDate()
if (date != lastDate) {
items.add(MessageListItem.DateHeader(date))
lastDate = date
}
items.add(MessageListItem.Message(msg))
}
return items
}
Search Architecture
Debounced Search with Room FTS
@Dao
interface SearchDao {
// Full-text search on post captions and user names
@Query("""
SELECT * FROM posts_fts
WHERE posts_fts MATCH :query
ORDER BY rank
LIMIT 20
""")
fun searchPosts(query: String): Flow<List<PostEntity>>
@Query("""
SELECT * FROM users_fts
WHERE users_fts MATCH :query
ORDER BY follower_count DESC
LIMIT 10
""")
fun searchUsers(query: String): Flow<List<UserEntity>>
}
@HiltViewModel
class SearchViewModel @Inject constructor(
private val dao: SearchDao,
private val api: SearchApi
) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query
val searchResults: StateFlow<SearchResults> = _query
.debounce(300)
.filter { it.length >= 2 }
.flatMapLatest { q ->
combine(
dao.searchUsers(q),
dao.searchPosts(q),
api.searchHashtags(q).catch { emit(emptyList()) }
) { users, posts, hashtags ->
SearchResults(users = users, posts = posts, hashtags = hashtags)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SearchResults())
fun onQueryChanged(q: String) { _query.value = q }
}
Remote Search for Fresh Results
// Hybrid: local FTS for instant results, remote API for fresh/ranked results
val searchResults: Flow<SearchResults> = _query
.debounce(300)
.filter { it.length >= 2 }
.flatMapLatest { q ->
flow {
// Emit local results immediately
val local = dao.searchUsers(q).first()
emit(SearchResults(users = local, isLocal = true))
// Then fetch from server
try {
val remote = api.search(q)
emit(SearchResults(users = remote.users, posts = remote.posts, isLocal = false))
} catch (e: IOException) {
// Keep local results; don't error
}
}
}
Discovery Feed
Explore / Discover Algorithm (Client Perspective)
data class DiscoverPost(
val post: Post,
val reason: DiscoverReason // why this was shown
)
enum class DiscoverReason {
TRENDING, // high engagement in last hour
SIMILAR_TO_LIKED, // similar content to posts you liked
FOLLOWED_HASHTAG, // matches a hashtag you follow
POPULAR_IN_AREA // geographically trending
}
// Display reason hint in UI
@Composable
fun DiscoverPostHeader(reason: DiscoverReason) {
val text = when (reason) {
DiscoverReason.TRENDING -> "Trending now"
DiscoverReason.SIMILAR_TO_LIKED -> "Because you liked similar posts"
DiscoverReason.FOLLOWED_HASHTAG -> "From a hashtag you follow"
DiscoverReason.POPULAR_IN_AREA -> "Popular near you"
}
Text(text, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Deduplicate Seen Posts
class SeenPostsTracker(private val prefs: DataStore<Preferences>) {
private val seenKey = stringSetPreferencesKey("seen_post_ids")
suspend fun markSeen(postId: String) {
prefs.edit { it[seenKey] = (it[seenKey] ?: emptySet()) + postId }
}
suspend fun filterUnseen(posts: List<Post>): List<Post> {
val seen = prefs.data.first()[seenKey] ?: emptySet()
return posts.filter { it.id !in seen }
}
}
Key Takeaways
| System | Key Pattern |
|---|---|
| DM conversation list | Room + WebSocket; combine with presence service for online status |
| Message date headers | Transform List<Message> → List<MessageListItem> with DateHeader inserted |
| Search debounce | 300ms debounce + flatMapLatest → cancels in-flight search on new keystroke |
| Hybrid search | Emit local FTS results instantly; overlay remote results when they arrive |
| Discovery reasons | Show "because you liked..." — increases trust and engagement |
| Deduplication | Track seen post IDs; filter from discovery feed |