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.

*: Fix multi-account crashes and avatar accessibility

- Replace .first {} with .firstOrNull {} in feeds() and
subscribedLabelers() to handle accounts without SavedFeedsPrefV2
or LabelersPref preferences
- Replace \!\! with safe returns in notification post lookups for
deleted/unavailable posts
- Avatar always renders and is long-pressable even when user profile
hasn't loaded yet

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

geesawra 586366fc 16a3ddff

+30 -25
+4 -9
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 407 407 actions = { 408 408 when (currentDestination) { 409 409 TabBarDestinations.TIMELINE -> { 410 - if (timelineViewModel.uiState.user == null) { 411 - return@TopAppBar 412 - } 413 - 414 - val user = timelineViewModel.uiState.user!! 410 + val user = timelineViewModel.uiState.user 415 411 var showAccountSwitcher by remember { mutableStateOf(false) } 416 - 417 412 val avatarClipShape = if (settingsState.avatarShape == AvatarShape.RoundedSquare) RoundedCornerShape(8.dp) else CircleShape 418 413 419 414 AsyncImage( 420 415 model = ImageRequest.Builder(LocalContext.current) 421 - .data(user.avatar?.uri) 416 + .data(user?.avatar?.uri) 422 417 .crossfade(true) 423 418 .build(), 424 419 placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 425 - contentDescription = "${user.displayName ?: user.handle.handle}'s avatar", 420 + contentDescription = "Profile avatar", 426 421 contentScale = ContentScale.Crop, 427 422 modifier = Modifier 428 423 .size(40.dp) 429 424 .clip(avatarClipShape) 430 425 .combinedClickable( 431 - onClick = { onProfileTap(user.did) }, 426 + onClick = { user?.let { onProfileTap(it.did) } }, 432 427 onLongClick = { showAccountSwitcher = true } 433 428 ) 434 429 )
+22 -12
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 1045 1045 return Result.failure(LoginException(it.message)) 1046 1046 } 1047 1047 val prefs = pdsClient!!.getPreferences().requireResponse() 1048 - val feedUris = (prefs.preferences.first { 1049 - when (it) { 1050 - is PreferencesUnion.SavedFeedsPrefV2 -> true 1051 - else -> false 1052 - } 1053 - } as PreferencesUnion.SavedFeedsPrefV2).value.items.filter { 1048 + val savedFeeds = prefs.preferences.firstOrNull { 1049 + it is PreferencesUnion.SavedFeedsPrefV2 1050 + } as? PreferencesUnion.SavedFeedsPrefV2 1051 + 1052 + if (savedFeeds == null) { 1053 + return Result.success(emptyList()) 1054 + } 1055 + 1056 + val feedUris = savedFeeds.value.items.filter { 1054 1057 it.type.value != "timeline" 1055 1058 }.map { AtUri(it.value) } 1056 1059 1060 + if (feedUris.isEmpty()) { 1061 + return Result.success(emptyList()) 1062 + } 1063 + 1057 1064 val resp = client!!.getFeedGenerators( 1058 1065 GetFeedGeneratorsQueryParams( 1059 1066 feedUris ··· 1067 1074 suspend fun subscribedLabelers(): Result<Map<Did?, GetServicesResponseViewUnion.LabelerViewDetailed?>> { 1068 1075 return runCatching { 1069 1076 val prefs = pdsClient!!.getPreferences().requireResponse() 1070 - val labelers = (prefs.preferences.first { 1071 - when (it) { 1072 - is PreferencesUnion.LabelersPref -> true 1073 - else -> false 1074 - } 1075 - } as PreferencesUnion.LabelersPref).value.labelers.map { it.did }.toMutableList() 1077 + val labelersPref = prefs.preferences.firstOrNull { 1078 + it is PreferencesUnion.LabelersPref 1079 + } as? PreferencesUnion.LabelersPref 1080 + 1081 + if (labelersPref == null) { 1082 + return Result.success(emptyMap()) 1083 + } 1084 + 1085 + val labelers = labelersPref.value.labelers.map { it.did }.toMutableList() 1076 1086 1077 1087 val res = client!!.getServices( 1078 1088 GetServicesQueryParams(
+4 -4
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 417 417 418 418 ListNotificationsReason.Like -> { 419 419 val l: Like = it.record.decodeAs() 420 - val lp = posts[l.subject.uri]!! 420 + val lp = posts[l.subject.uri] ?: return@mapNotNull null 421 421 422 422 repeatable += Notification.RawLike( 423 423 l.subject, ··· 459 459 } 460 460 461 461 if (quotedUrl == null) { 462 - throw Exception("quote notification without a record or record media!") 462 + return@mapNotNull null 463 463 } 464 - val lp = posts[quotedUrl]!! 464 + val lp = posts[quotedUrl] ?: return@mapNotNull null 465 465 val skeetData = lp.first 466 466 val post = lp.second 467 467 Notification.Quote( ··· 516 516 517 517 ListNotificationsReason.Repost -> { 518 518 val p: Repost = it.record.decodeAs() 519 - val rpp = posts[p.subject.uri]!! 519 + val rpp = posts[p.subject.uri] ?: return@mapNotNull null 520 520 repeatable += Notification.RawRepost( 521 521 p.subject, 522 522 rpp.first,