A cheap attempt at a native Bluesky client for Android
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

*: Add search tab with posts and people results

Implement a search tab in the bottom navigation with M3 SearchBar,
PrimaryTabRow for Posts/People tabs, filter chips for Latest/Top sort
and from: author filter, pagination, and actor cards with verification
badges. Reuses SkeetView and Card for post results.

API: searchPosts (with sort, author filter) and searchActors on
BlueskyConn. ViewModel: search state, debounced search, pagination
methods, sort/author filter setters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

geesawra cc6b5959 60c3c147

+649
+23
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 27 27 import androidx.compose.material.icons.filled.Create 28 28 import androidx.compose.material.icons.filled.Home 29 29 import androidx.compose.material.icons.filled.Notifications 30 + import androidx.compose.material.icons.filled.Search 30 31 import androidx.compose.material.icons.filled.Tag 31 32 import androidx.compose.material3.Badge 32 33 import androidx.compose.material3.BadgedBox ··· 99 100 val badgeDescFmt: (Int) -> String = { "" }, 100 101 ) { 101 102 TIMELINE(R.string.timeline, Icons.Filled.Home, R.string.timeline), 103 + SEARCH(R.string.search, Icons.Filled.Search, R.string.search), 102 104 NOTIFICATIONS( 103 105 R.string.notifications, 104 106 Icons.Filled.Notifications, ··· 216 218 ) 217 219 val timelineState = rememberLazyListState() 218 220 val notificationsState = rememberLazyListState() 221 + val searchPostsState = rememberLazyListState() 222 + val searchPeopleState = rememberLazyListState() 219 223 val drawerState = rememberWideNavigationRailState( 220 224 initialValue = WideNavigationRailValue.Collapsed 221 225 ) ··· 340 344 341 345 Text(text = timelineViewModel.uiState.feedName) 342 346 } 347 + 348 + TabBarDestinations.SEARCH -> Text(text = "Search") 343 349 344 350 TabBarDestinations.NOTIFICATIONS -> Row( 345 351 horizontalArrangement = Arrangement.spacedBy(8.dp), ··· 360 366 Icon(Icons.Default.Tag, "Feeds") 361 367 } 362 368 369 + TabBarDestinations.SEARCH -> {} 363 370 TabBarDestinations.NOTIFICATIONS -> {} 364 371 } 365 372 }, ··· 389 396 } 390 397 } 391 398 399 + TabBarDestinations.SEARCH -> {} 392 400 TabBarDestinations.NOTIFICATIONS -> {} 393 401 } 394 402 } ··· 443 451 } 444 452 } 445 453 } 454 + 455 + TabBarDestinations.SEARCH -> {} 446 456 447 457 TabBarDestinations.NOTIFICATIONS -> { 448 458 AnimatedVisibility( ··· 540 550 data = timelineViewModel.uiState.skeets, 541 551 isScrollEnabled = isScrollEnabled, 542 552 onSeeMoreTap = onSeeMoreTap, 553 + onProfileTap = onProfileTap, 554 + ) 555 + 556 + TabBarDestinations.SEARCH -> SearchView( 557 + viewModel = timelineViewModel, 558 + postsListState = searchPostsState, 559 + peopleListState = searchPeopleState, 560 + modifier = Modifier, 561 + isScrollEnabled = isScrollEnabled, 562 + scaffoldPadding = values, 563 + onThreadTap = { skeet -> 564 + onSeeMoreTap(skeet) 565 + }, 543 566 onProfileTap = onProfileTap, 544 567 ) 545 568
+454
app/src/main/java/industries/geesawra/monarch/SearchView.kt
··· 1 + package industries.geesawra.monarch 2 + 3 + import androidx.compose.foundation.clickable 4 + import androidx.compose.foundation.layout.Arrangement 5 + import androidx.compose.foundation.layout.Box 6 + import androidx.compose.foundation.layout.Column 7 + import androidx.compose.foundation.layout.PaddingValues 8 + import androidx.compose.foundation.layout.Row 9 + import androidx.compose.foundation.layout.Spacer 10 + import androidx.compose.foundation.layout.fillMaxSize 11 + import androidx.compose.foundation.layout.fillMaxWidth 12 + import androidx.compose.foundation.layout.height 13 + import androidx.compose.foundation.layout.padding 14 + import androidx.compose.foundation.layout.size 15 + import androidx.compose.foundation.layout.width 16 + import androidx.compose.foundation.lazy.LazyColumn 17 + import androidx.compose.foundation.lazy.LazyListState 18 + import androidx.compose.foundation.lazy.items 19 + import androidx.compose.foundation.lazy.itemsIndexed 20 + import androidx.compose.foundation.shape.CircleShape 21 + import androidx.compose.material.icons.Icons 22 + import androidx.compose.material.icons.filled.Clear 23 + import androidx.compose.material.icons.filled.Search 24 + import androidx.compose.material.icons.filled.Verified 25 + import androidx.compose.material3.Card 26 + import androidx.compose.material3.CircularProgressIndicator 27 + import androidx.compose.material3.ExperimentalMaterial3Api 28 + import androidx.compose.material3.FilterChip 29 + import androidx.compose.material3.Icon 30 + import androidx.compose.material3.IconButton 31 + import androidx.compose.material3.MaterialTheme 32 + import androidx.compose.material3.SearchBar 33 + import androidx.compose.material3.SearchBarDefaults 34 + import androidx.compose.material3.Tab 35 + import androidx.compose.material3.PrimaryTabRow 36 + import androidx.compose.material3.Text 37 + import androidx.compose.runtime.Composable 38 + import androidx.compose.runtime.LaunchedEffect 39 + import androidx.compose.runtime.derivedStateOf 40 + import androidx.compose.runtime.getValue 41 + import androidx.compose.runtime.mutableIntStateOf 42 + import androidx.compose.runtime.mutableStateOf 43 + import androidx.compose.runtime.remember 44 + import androidx.compose.runtime.saveable.rememberSaveable 45 + import androidx.compose.runtime.setValue 46 + import androidx.compose.ui.Alignment 47 + import androidx.compose.ui.Modifier 48 + import androidx.compose.ui.draw.clip 49 + import androidx.compose.ui.graphics.painter.ColorPainter 50 + import androidx.compose.ui.layout.ContentScale 51 + import androidx.compose.ui.platform.LocalContext 52 + import androidx.compose.ui.text.font.FontWeight 53 + import androidx.compose.ui.text.style.TextOverflow 54 + import androidx.compose.ui.unit.dp 55 + import app.bsky.actor.ProfileView 56 + import app.bsky.actor.VerifiedStatus 57 + import app.bsky.feed.SearchPostsSort 58 + import coil3.compose.AsyncImage 59 + import coil3.request.ImageRequest 60 + import coil3.request.crossfade 61 + import industries.geesawra.monarch.datalayer.SkeetData 62 + import industries.geesawra.monarch.datalayer.TimelineViewModel 63 + import sh.christian.ozone.api.Did 64 + 65 + 66 + private enum class SearchTab(val label: String) { 67 + POSTS("Posts"), 68 + PEOPLE("People"), 69 + } 70 + 71 + @OptIn(ExperimentalMaterial3Api::class) 72 + @Composable 73 + fun SearchView( 74 + viewModel: TimelineViewModel, 75 + postsListState: LazyListState, 76 + peopleListState: LazyListState, 77 + modifier: Modifier = Modifier, 78 + isScrollEnabled: Boolean, 79 + scaffoldPadding: PaddingValues, 80 + onThreadTap: (SkeetData) -> Unit, 81 + onProfileTap: (Did) -> Unit, 82 + ) { 83 + var query by rememberSaveable { mutableStateOf("") } 84 + var expanded by rememberSaveable { mutableStateOf(false) } 85 + var selectedTab by rememberSaveable { mutableIntStateOf(0) } 86 + 87 + Column( 88 + modifier = modifier 89 + .fillMaxSize() 90 + .padding(scaffoldPadding), 91 + ) { 92 + // M3 SearchBar 93 + SearchBar( 94 + inputField = { 95 + SearchBarDefaults.InputField( 96 + query = query, 97 + onQueryChange = { query = it }, 98 + onSearch = { 99 + expanded = false 100 + viewModel.search(query) 101 + }, 102 + expanded = false, 103 + onExpandedChange = {}, 104 + placeholder = { Text("Search Bluesky") }, 105 + leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") }, 106 + trailingIcon = { 107 + if (query.isNotEmpty()) { 108 + IconButton(onClick = { 109 + query = "" 110 + viewModel.search("") 111 + }) { 112 + Icon(Icons.Default.Clear, contentDescription = "Clear") 113 + } 114 + } 115 + }, 116 + ) 117 + }, 118 + expanded = false, 119 + onExpandedChange = {}, 120 + modifier = Modifier 121 + .fillMaxWidth() 122 + .padding(horizontal = 16.dp), 123 + ) {} 124 + 125 + // Filter chips row 126 + SearchFilters(viewModel) 127 + 128 + // Tabs: Posts / People 129 + PrimaryTabRow( 130 + selectedTabIndex = selectedTab, 131 + ) { 132 + SearchTab.entries.forEachIndexed { index, tab -> 133 + Tab( 134 + selected = selectedTab == index, 135 + onClick = { selectedTab = index }, 136 + text = { Text(tab.label) }, 137 + ) 138 + } 139 + } 140 + 141 + // Tab content 142 + when (selectedTab) { 143 + 0 -> SearchPostsResults( 144 + viewModel = viewModel, 145 + listState = postsListState, 146 + isScrollEnabled = isScrollEnabled, 147 + onThreadTap = onThreadTap, 148 + onProfileTap = onProfileTap, 149 + ) 150 + 151 + 1 -> SearchPeopleResults( 152 + viewModel = viewModel, 153 + listState = peopleListState, 154 + isScrollEnabled = isScrollEnabled, 155 + onProfileTap = onProfileTap, 156 + ) 157 + } 158 + } 159 + } 160 + 161 + @Composable 162 + private fun SearchFilters(viewModel: TimelineViewModel) { 163 + val sort = viewModel.uiState.searchPostsSort 164 + val authorFilter = viewModel.uiState.searchAuthorFilter 165 + 166 + Row( 167 + modifier = Modifier 168 + .fillMaxWidth() 169 + .padding(horizontal = 16.dp, vertical = 8.dp), 170 + horizontalArrangement = Arrangement.spacedBy(8.dp), 171 + ) { 172 + FilterChip( 173 + selected = sort == SearchPostsSort.Latest, 174 + onClick = { viewModel.setSearchSort(SearchPostsSort.Latest) }, 175 + label = { Text("Latest") }, 176 + ) 177 + FilterChip( 178 + selected = sort == SearchPostsSort.Top, 179 + onClick = { viewModel.setSearchSort(SearchPostsSort.Top) }, 180 + label = { Text("Top") }, 181 + ) 182 + if (authorFilter != null) { 183 + FilterChip( 184 + selected = true, 185 + onClick = { viewModel.setSearchAuthorFilter(null) }, 186 + label = { Text("from:$authorFilter") }, 187 + trailingIcon = { 188 + Icon( 189 + Icons.Default.Clear, 190 + contentDescription = "Remove author filter", 191 + modifier = Modifier.size(16.dp), 192 + ) 193 + }, 194 + ) 195 + } 196 + } 197 + } 198 + 199 + @Composable 200 + private fun SearchPostsResults( 201 + viewModel: TimelineViewModel, 202 + listState: LazyListState, 203 + isScrollEnabled: Boolean, 204 + onThreadTap: (SkeetData) -> Unit, 205 + onProfileTap: (Did) -> Unit, 206 + ) { 207 + val posts = viewModel.uiState.searchPostResults 208 + val isSearching = viewModel.uiState.isSearching 209 + 210 + if (posts.isEmpty() && !isSearching && viewModel.uiState.searchQuery.isNotBlank()) { 211 + Box( 212 + modifier = Modifier.fillMaxSize(), 213 + contentAlignment = Alignment.Center, 214 + ) { 215 + Text( 216 + text = "No posts found", 217 + style = MaterialTheme.typography.bodyLarge, 218 + color = MaterialTheme.colorScheme.onSurfaceVariant, 219 + ) 220 + } 221 + return 222 + } 223 + 224 + if (posts.isEmpty() && !isSearching) { 225 + Box( 226 + modifier = Modifier.fillMaxSize(), 227 + contentAlignment = Alignment.Center, 228 + ) { 229 + Text( 230 + text = "Search for posts on Bluesky", 231 + style = MaterialTheme.typography.bodyLarge, 232 + color = MaterialTheme.colorScheme.onSurfaceVariant, 233 + ) 234 + } 235 + return 236 + } 237 + 238 + LazyColumn( 239 + state = listState, 240 + userScrollEnabled = isScrollEnabled, 241 + modifier = Modifier 242 + .fillMaxSize() 243 + .padding(horizontal = 16.dp), 244 + verticalArrangement = Arrangement.spacedBy(12.dp), 245 + contentPadding = PaddingValues(vertical = 8.dp), 246 + ) { 247 + itemsIndexed( 248 + items = posts, 249 + key = { _, skeet -> "search_post_${skeet.key()}" } 250 + ) { _, skeet -> 251 + Card { 252 + SkeetView( 253 + viewModel = viewModel, 254 + skeet = skeet, 255 + onAvatarTap = onProfileTap, 256 + onShowThread = { s -> 257 + viewModel.setThread(s) 258 + onThreadTap(s) 259 + }, 260 + ) 261 + } 262 + } 263 + 264 + if (isSearching) { 265 + item(key = "loading") { 266 + Box( 267 + modifier = Modifier 268 + .fillMaxWidth() 269 + .padding(16.dp), 270 + contentAlignment = Alignment.Center, 271 + ) { 272 + CircularProgressIndicator() 273 + } 274 + } 275 + } 276 + } 277 + 278 + // Pagination 279 + val endReached by remember { 280 + derivedStateOf { 281 + val layoutInfo = listState.layoutInfo 282 + val last = layoutInfo.visibleItemsInfo.lastOrNull() 283 + last != null && last.index == layoutInfo.totalItemsCount - 1 284 + } 285 + } 286 + 287 + LaunchedEffect(endReached) { 288 + if (endReached && posts.isNotEmpty()) { 289 + viewModel.fetchMoreSearchPosts() 290 + } 291 + } 292 + } 293 + 294 + @Composable 295 + private fun SearchPeopleResults( 296 + viewModel: TimelineViewModel, 297 + listState: LazyListState, 298 + isScrollEnabled: Boolean, 299 + onProfileTap: (Did) -> Unit, 300 + ) { 301 + val actors = viewModel.uiState.searchActorResults 302 + val isSearching = viewModel.uiState.isSearching 303 + 304 + if (actors.isEmpty() && !isSearching && viewModel.uiState.searchQuery.isNotBlank()) { 305 + Box( 306 + modifier = Modifier.fillMaxSize(), 307 + contentAlignment = Alignment.Center, 308 + ) { 309 + Text( 310 + text = "No people found", 311 + style = MaterialTheme.typography.bodyLarge, 312 + color = MaterialTheme.colorScheme.onSurfaceVariant, 313 + ) 314 + } 315 + return 316 + } 317 + 318 + if (actors.isEmpty() && !isSearching) { 319 + Box( 320 + modifier = Modifier.fillMaxSize(), 321 + contentAlignment = Alignment.Center, 322 + ) { 323 + Text( 324 + text = "Search for people on Bluesky", 325 + style = MaterialTheme.typography.bodyLarge, 326 + color = MaterialTheme.colorScheme.onSurfaceVariant, 327 + ) 328 + } 329 + return 330 + } 331 + 332 + LazyColumn( 333 + state = listState, 334 + userScrollEnabled = isScrollEnabled, 335 + modifier = Modifier 336 + .fillMaxSize() 337 + .padding(horizontal = 16.dp), 338 + verticalArrangement = Arrangement.spacedBy(8.dp), 339 + contentPadding = PaddingValues(vertical = 8.dp), 340 + ) { 341 + items( 342 + items = actors, 343 + key = { "search_actor_${it.did.did}" } 344 + ) { actor -> 345 + ActorCard(actor = actor, onProfileTap = onProfileTap) 346 + } 347 + 348 + if (isSearching) { 349 + item(key = "loading") { 350 + Box( 351 + modifier = Modifier 352 + .fillMaxWidth() 353 + .padding(16.dp), 354 + contentAlignment = Alignment.Center, 355 + ) { 356 + CircularProgressIndicator() 357 + } 358 + } 359 + } 360 + } 361 + 362 + val endReached by remember { 363 + derivedStateOf { 364 + val layoutInfo = listState.layoutInfo 365 + val last = layoutInfo.visibleItemsInfo.lastOrNull() 366 + last != null && last.index == layoutInfo.totalItemsCount - 1 367 + } 368 + } 369 + 370 + LaunchedEffect(endReached) { 371 + if (endReached && actors.isNotEmpty()) { 372 + viewModel.fetchMoreSearchActors() 373 + } 374 + } 375 + } 376 + 377 + @Composable 378 + private fun ActorCard( 379 + actor: ProfileView, 380 + onProfileTap: (Did) -> Unit, 381 + ) { 382 + Card( 383 + modifier = Modifier 384 + .fillMaxWidth() 385 + .clickable { onProfileTap(actor.did) }, 386 + ) { 387 + Row( 388 + modifier = Modifier 389 + .fillMaxWidth() 390 + .padding(12.dp), 391 + verticalAlignment = Alignment.CenterVertically, 392 + ) { 393 + AsyncImage( 394 + model = ImageRequest.Builder(LocalContext.current) 395 + .data(actor.avatar?.uri) 396 + .crossfade(true) 397 + .build(), 398 + placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 399 + contentDescription = "${actor.displayName ?: actor.handle.handle}'s avatar", 400 + contentScale = ContentScale.Crop, 401 + modifier = Modifier 402 + .size(48.dp) 403 + .clip(CircleShape), 404 + ) 405 + 406 + Spacer(Modifier.width(12.dp)) 407 + 408 + Column(modifier = Modifier.weight(1f)) { 409 + Row( 410 + verticalAlignment = Alignment.CenterVertically, 411 + horizontalArrangement = Arrangement.spacedBy(4.dp), 412 + ) { 413 + Text( 414 + text = actor.displayName ?: actor.handle.handle, 415 + style = MaterialTheme.typography.titleSmall, 416 + fontWeight = FontWeight.Bold, 417 + maxLines = 1, 418 + overflow = TextOverflow.Ellipsis, 419 + modifier = Modifier.weight(1f, fill = false), 420 + ) 421 + 422 + val isVerified = actor.verification?.verifiedStatus is VerifiedStatus.Valid 423 + if (isVerified) { 424 + Icon( 425 + imageVector = Icons.Default.Verified, 426 + contentDescription = "Verified", 427 + tint = MaterialTheme.colorScheme.primary, 428 + modifier = Modifier.size(16.dp), 429 + ) 430 + } 431 + } 432 + 433 + Text( 434 + text = "@${actor.handle.handle}", 435 + style = MaterialTheme.typography.bodySmall, 436 + color = MaterialTheme.colorScheme.onSurfaceVariant, 437 + maxLines = 1, 438 + overflow = TextOverflow.Ellipsis, 439 + ) 440 + 441 + if (!actor.description.isNullOrBlank()) { 442 + Spacer(Modifier.height(4.dp)) 443 + Text( 444 + text = actor.description!!, 445 + style = MaterialTheme.typography.bodySmall, 446 + color = MaterialTheme.colorScheme.onSurface, 447 + maxLines = 2, 448 + overflow = TextOverflow.Ellipsis, 449 + ) 450 + } 451 + } 452 + } 453 + } 454 + }
+58
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 18 18 import app.bsky.actor.Profile 19 19 import app.bsky.actor.ProfileViewBasic 20 20 import app.bsky.actor.ProfileViewDetailed 21 + import app.bsky.actor.SearchActorsQueryParams 22 + import app.bsky.actor.SearchActorsResponse 21 23 import app.bsky.actor.SearchActorsTypeaheadQueryParams 22 24 import app.bsky.actor.SearchActorsTypeaheadResponse 23 25 import app.bsky.embed.AspectRatio ··· 1360 1362 return when (ret) { 1361 1363 is AtpResponse.Failure<*> -> Result.failure(Exception("Failed updating profile: ${ret.error?.message}")) 1362 1364 is AtpResponse.Success<*> -> Result.success(Unit) 1365 + } 1366 + } 1367 + } 1368 + 1369 + suspend fun searchPosts( 1370 + query: String, 1371 + sort: app.bsky.feed.SearchPostsSort? = app.bsky.feed.SearchPostsSort.Latest, 1372 + cursor: String? = null, 1373 + author: Did? = null, 1374 + ): Result<Pair<List<PostView>, String?>> { 1375 + return runCatching { 1376 + create().onFailure { 1377 + return Result.failure(LoginException(it.message)) 1378 + } 1379 + 1380 + val ret = client!!.searchPosts( 1381 + app.bsky.feed.SearchPostsQueryParams( 1382 + q = query, 1383 + sort = sort, 1384 + limit = 25, 1385 + cursor = cursor, 1386 + author = author, 1387 + ) 1388 + ) 1389 + 1390 + return when (ret) { 1391 + is AtpResponse.Failure<*> -> Result.failure(Exception("Search failed: ${ret.error}")) 1392 + is AtpResponse.Success<app.bsky.feed.SearchPostsResponse> -> Result.success( 1393 + ret.response.posts to ret.response.cursor 1394 + ) 1395 + } 1396 + } 1397 + } 1398 + 1399 + suspend fun searchActors( 1400 + query: String, 1401 + cursor: String? = null, 1402 + ): Result<Pair<List<app.bsky.actor.ProfileView>, String?>> { 1403 + return runCatching { 1404 + create().onFailure { 1405 + return Result.failure(LoginException(it.message)) 1406 + } 1407 + 1408 + val ret = client!!.searchActors( 1409 + SearchActorsQueryParams( 1410 + q = query, 1411 + limit = 25, 1412 + cursor = cursor, 1413 + ) 1414 + ) 1415 + 1416 + return when (ret) { 1417 + is AtpResponse.Failure<*> -> Result.failure(Exception("Search failed: ${ret.error}")) 1418 + is AtpResponse.Success<SearchActorsResponse> -> Result.success( 1419 + ret.response.actors to ret.response.cursor 1420 + ) 1363 1421 } 1364 1422 } 1365 1423 }
+113
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 13 13 import app.bsky.actor.ProfileViewBasic 14 14 import app.bsky.actor.ProfileViewDetailed 15 15 import app.bsky.feed.GetAuthorFeedFilter 16 + import app.bsky.feed.SearchPostsSort 16 17 import app.bsky.embed.RecordView 17 18 import app.bsky.embed.RecordViewRecord 18 19 import app.bsky.embed.RecordViewRecordUnion ··· 82 83 val isFetchingProfile: Boolean = false, 83 84 val isFetchingProfileFeed: Boolean = false, 84 85 val profileNotFound: Boolean = false, 86 + 87 + // Search state 88 + val searchQuery: String = "", 89 + val searchPostResults: List<SkeetData> = listOf(), 90 + val searchActorResults: List<ProfileView> = listOf(), 91 + val searchPostsCursor: String? = null, 92 + val searchActorsCursor: String? = null, 93 + val searchPostsSort: SearchPostsSort = SearchPostsSort.Latest, 94 + val searchAuthorFilter: String? = null, 95 + val isSearching: Boolean = false, 85 96 ) 86 97 87 98 @HiltViewModel(assistedFactory = TimelineViewModel.Factory::class) ··· 862 873 uiState = uiState.copy(profileUser = it) 863 874 } 864 875 } 876 + } 877 + } 878 + 879 + fun search(query: String, fresh: Boolean = true) { 880 + if (query.isBlank()) { 881 + uiState = uiState.copy( 882 + searchQuery = "", 883 + searchPostResults = listOf(), 884 + searchActorResults = listOf(), 885 + searchPostsCursor = null, 886 + searchActorsCursor = null, 887 + isSearching = false, 888 + ) 889 + return 890 + } 891 + 892 + if (fresh) { 893 + uiState = uiState.copy( 894 + searchQuery = query, 895 + searchPostResults = listOf(), 896 + searchActorResults = listOf(), 897 + searchPostsCursor = null, 898 + searchActorsCursor = null, 899 + isSearching = true, 900 + ) 901 + } else { 902 + uiState = uiState.copy(isSearching = true) 903 + } 904 + 905 + searchPosts(query, fresh) 906 + searchActors(query, fresh) 907 + } 908 + 909 + private fun searchPosts(query: String, fresh: Boolean) { 910 + val effectiveQuery = if (uiState.searchAuthorFilter != null) { 911 + "from:${uiState.searchAuthorFilter} $query" 912 + } else { 913 + query 914 + } 915 + 916 + viewModelScope.launch { 917 + bskyConn.searchPosts( 918 + query = effectiveQuery, 919 + sort = uiState.searchPostsSort, 920 + cursor = if (fresh) null else uiState.searchPostsCursor, 921 + ).onFailure { 922 + if (it is CancellationException) return@onFailure 923 + uiState = uiState.copy(isSearching = false, error = it.message) 924 + }.onSuccess { (posts, cursor) -> 925 + val newSkeets = posts.map { 926 + SkeetData.fromPostView(it, it.author) 927 + } 928 + uiState = uiState.copy( 929 + searchPostResults = if (fresh) newSkeets 930 + else (uiState.searchPostResults + newSkeets).distinctBy { it.cid }, 931 + searchPostsCursor = cursor, 932 + isSearching = false, 933 + ) 934 + } 935 + } 936 + } 937 + 938 + private fun searchActors(query: String, fresh: Boolean) { 939 + viewModelScope.launch { 940 + bskyConn.searchActors( 941 + query = query, 942 + cursor = if (fresh) null else uiState.searchActorsCursor, 943 + ).onFailure { 944 + if (it is CancellationException) return@onFailure 945 + uiState = uiState.copy(isSearching = false) 946 + }.onSuccess { (actors, cursor) -> 947 + uiState = uiState.copy( 948 + searchActorResults = if (fresh) actors 949 + else (uiState.searchActorResults + actors).distinctBy { it.did }, 950 + searchActorsCursor = cursor, 951 + isSearching = false, 952 + ) 953 + } 954 + } 955 + } 956 + 957 + fun fetchMoreSearchPosts() { 958 + if (uiState.searchPostsCursor == null || uiState.searchQuery.isBlank()) return 959 + searchPosts(uiState.searchQuery, fresh = false) 960 + } 961 + 962 + fun fetchMoreSearchActors() { 963 + if (uiState.searchActorsCursor == null || uiState.searchQuery.isBlank()) return 964 + searchActors(uiState.searchQuery, fresh = false) 965 + } 966 + 967 + fun setSearchSort(sort: SearchPostsSort) { 968 + uiState = uiState.copy(searchPostsSort = sort) 969 + if (uiState.searchQuery.isNotBlank()) { 970 + search(uiState.searchQuery, fresh = true) 971 + } 972 + } 973 + 974 + fun setSearchAuthorFilter(handle: String?) { 975 + uiState = uiState.copy(searchAuthorFilter = handle) 976 + if (uiState.searchQuery.isNotBlank()) { 977 + search(uiState.searchQuery, fresh = true) 865 978 } 866 979 } 867 980
+1
app/src/main/res/values/strings.xml
··· 2 2 <string name="app_name">Monarch</string> 3 3 <string name="timeline">Timeline</string> 4 4 <string name="notifications">Notifications</string> 5 + <string name="search">Search</string> 5 6 </resources>