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.

*: Multi-account support, appview settings, content warnings, and fixes

Multi-account:
- Add AccountManager with DataStore persistence for multiple accounts
- Long-press profile avatar to open account switcher sheet
- Switch between accounts without re-logging in
- Add account button navigates to login
- Log out button in settings with account removal
- Auto-save account profile info after fetching self

Settings:
- Add appview selection (Bluesky/Blacksky/Custom) in settings
- Remove "via appview" label from feeds drawer

Content:
- Add post-level label content warnings with Show/Hide toggle
- Hydrate notification posts via getPosts for like/repost/reply counts
- Tapping "Reposted by" navigates to reposter's profile

UI fixes:
- Replace "See more" card with compact FilledTonalButton
- Profile view uses ElevatedCard with surfaceContainerLow
- Profile/Thread views use surface instead of background colors
- Consistent CircularWavyProgressIndicator across all views
- Compose view respects font size and avatar shape settings
- Back gesture dismisses compose bottom sheet

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

geesawra 5a6959ae 0634bf21

+614 -67
+150
app/src/main/java/industries/geesawra/monarch/AccountSwitcherSheet.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.Column 6 + import androidx.compose.foundation.layout.Row 7 + import androidx.compose.foundation.layout.Spacer 8 + import androidx.compose.foundation.layout.fillMaxWidth 9 + import androidx.compose.foundation.layout.height 10 + import androidx.compose.foundation.layout.padding 11 + import androidx.compose.foundation.layout.size 12 + import androidx.compose.foundation.layout.width 13 + import androidx.compose.foundation.shape.CircleShape 14 + import androidx.compose.material.icons.Icons 15 + import androidx.compose.material.icons.filled.Add 16 + import androidx.compose.material.icons.filled.Check 17 + import androidx.compose.material.icons.filled.Close 18 + import androidx.compose.material3.ExperimentalMaterial3Api 19 + import androidx.compose.material3.HorizontalDivider 20 + import androidx.compose.material3.Icon 21 + import androidx.compose.material3.IconButton 22 + import androidx.compose.material3.MaterialTheme 23 + import androidx.compose.material3.ModalBottomSheet 24 + import androidx.compose.material3.Text 25 + import androidx.compose.material3.rememberModalBottomSheetState 26 + import androidx.compose.runtime.Composable 27 + import androidx.compose.ui.Alignment 28 + import androidx.compose.ui.Modifier 29 + import androidx.compose.ui.draw.clip 30 + import androidx.compose.ui.text.font.FontWeight 31 + import androidx.compose.ui.unit.dp 32 + import androidx.compose.ui.graphics.painter.ColorPainter 33 + import coil3.compose.AsyncImage 34 + import coil3.request.ImageRequest 35 + import coil3.request.crossfade 36 + import androidx.compose.ui.platform.LocalContext 37 + import industries.geesawra.monarch.datalayer.StoredAccount 38 + 39 + @OptIn(ExperimentalMaterial3Api::class) 40 + @Composable 41 + fun AccountSwitcherSheet( 42 + accounts: List<StoredAccount>, 43 + activeDid: String?, 44 + onSwitchAccount: (String) -> Unit, 45 + onAddAccount: () -> Unit, 46 + onRemoveAccount: (String) -> Unit, 47 + onDismiss: () -> Unit, 48 + ) { 49 + ModalBottomSheet( 50 + onDismissRequest = onDismiss, 51 + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), 52 + ) { 53 + Column( 54 + modifier = Modifier.padding(bottom = 32.dp) 55 + ) { 56 + Text( 57 + text = "Accounts", 58 + style = MaterialTheme.typography.titleLarge, 59 + fontWeight = FontWeight.Bold, 60 + modifier = Modifier.padding(start = 24.dp, bottom = 16.dp) 61 + ) 62 + 63 + accounts.forEach { account -> 64 + val isActive = account.did == activeDid 65 + Row( 66 + modifier = Modifier 67 + .fillMaxWidth() 68 + .clickable { 69 + if (!isActive) { 70 + onSwitchAccount(account.did) 71 + } 72 + onDismiss() 73 + } 74 + .padding(horizontal = 24.dp, vertical = 12.dp), 75 + verticalAlignment = Alignment.CenterVertically, 76 + ) { 77 + AsyncImage( 78 + model = ImageRequest.Builder(LocalContext.current) 79 + .data(account.avatarUrl) 80 + .crossfade(true) 81 + .build(), 82 + placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 83 + contentDescription = "Avatar", 84 + modifier = Modifier 85 + .size(44.dp) 86 + .clip(CircleShape) 87 + ) 88 + Spacer(modifier = Modifier.width(12.dp)) 89 + Column(modifier = Modifier.weight(1f)) { 90 + Text( 91 + text = account.displayName ?: account.handle, 92 + style = MaterialTheme.typography.titleSmall, 93 + fontWeight = FontWeight.Bold, 94 + ) 95 + Text( 96 + text = "@${account.handle}", 97 + style = MaterialTheme.typography.labelMedium, 98 + color = MaterialTheme.colorScheme.onSurfaceVariant, 99 + ) 100 + } 101 + if (isActive) { 102 + Icon( 103 + imageVector = Icons.Default.Check, 104 + contentDescription = "Active account", 105 + tint = MaterialTheme.colorScheme.primary, 106 + modifier = Modifier.size(24.dp), 107 + ) 108 + } else { 109 + IconButton( 110 + onClick = { onRemoveAccount(account.did) } 111 + ) { 112 + Icon( 113 + imageVector = Icons.Default.Close, 114 + contentDescription = "Remove account", 115 + modifier = Modifier.size(20.dp), 116 + ) 117 + } 118 + } 119 + } 120 + } 121 + 122 + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) 123 + 124 + Row( 125 + modifier = Modifier 126 + .fillMaxWidth() 127 + .clickable { 128 + onAddAccount() 129 + onDismiss() 130 + } 131 + .padding(horizontal = 24.dp, vertical = 12.dp), 132 + verticalAlignment = Alignment.CenterVertically, 133 + horizontalArrangement = Arrangement.Start, 134 + ) { 135 + Icon( 136 + imageVector = Icons.Default.Add, 137 + contentDescription = "Add account", 138 + modifier = Modifier.size(44.dp), 139 + tint = MaterialTheme.colorScheme.onSurfaceVariant, 140 + ) 141 + Spacer(modifier = Modifier.width(12.dp)) 142 + Text( 143 + text = "Add account", 144 + style = MaterialTheme.typography.titleSmall, 145 + fontWeight = FontWeight.Bold, 146 + ) 147 + } 148 + } 149 + } 150 + }
+17
app/src/main/java/industries/geesawra/monarch/MainActivity.kt
··· 161 161 onSettingsTap = { 162 162 navController.navigate(ViewList.Settings.name) 163 163 }, 164 + onAddAccount = { 165 + navController.navigate(ViewList.Login.name) 166 + }, 164 167 onFirstLoad = { 165 168 if (firstLoadDone.value) { 166 169 return@MainView ··· 209 212 composable(route = ViewList.Settings.name) { 210 213 SettingsView( 211 214 settingsViewModel = settingsViewModel, 215 + timelineViewModel = timelineViewModel, 212 216 backButton = { 213 217 navController.popBackStack() 218 + }, 219 + onLogout = { 220 + timelineViewModel.logout { 221 + if (timelineViewModel.accounts.isEmpty()) { 222 + navController.navigate(ViewList.Login.name) { 223 + popUpTo(0) { inclusive = true } 224 + } 225 + } else { 226 + navController.navigate(ViewList.Main.name) { 227 + popUpTo(0) { inclusive = true } 228 + } 229 + } 230 + } 214 231 }, 215 232 ) 216 233 }
+40 -19
app/src/main/java/industries/geesawra/monarch/MainView.kt
··· 4 4 5 5 import android.widget.Toast 6 6 import androidx.activity.compose.BackHandler 7 + import androidx.compose.foundation.ExperimentalFoundationApi 8 + import androidx.compose.foundation.combinedClickable 7 9 import androidx.annotation.StringRes 8 10 import androidx.compose.animation.AnimatedVisibility 9 11 import androidx.compose.animation.core.animate ··· 133 135 onThreadTap: (SkeetData) -> Unit, 134 136 onProfileTap: (Did) -> Unit, 135 137 onSettingsTap: () -> Unit, 138 + onAddAccount: () -> Unit = {}, 136 139 onFirstLoad: () -> Unit, 137 140 ) { 138 141 val scrollState = rememberScrollState() ··· 187 190 settingsState = settingsState, 188 191 onProfileTap = onProfileTap, 189 192 onSettingsTap = onSettingsTap, 193 + onAddAccount = onAddAccount, 190 194 fobOnClick = { 191 195 coroutineScope.launch { 192 196 scaffoldState.bottomSheetState.expand() ··· 216 220 ) 217 221 } 218 222 219 - @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) 223 + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) 220 224 @Composable 221 225 private fun InnerTimelineView( 222 226 modifier: Modifier = Modifier, ··· 226 230 onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> }, 227 231 onProfileTap: (Did) -> Unit = {}, 228 232 onSettingsTap: () -> Unit = {}, 233 + onAddAccount: () -> Unit = {}, 229 234 fobOnClick: () -> Unit, 230 235 loginError: () -> Unit, 231 236 onError: (String) -> Unit, ··· 295 300 fontWeight = FontWeight.Bold, 296 301 style = MaterialTheme.typography.titleLarge 297 302 ) 298 - Text( 299 - text = "via ${timelineViewModel.appviewName()}", 300 - style = MaterialTheme.typography.labelSmall, 301 - color = MaterialTheme.colorScheme.onSurfaceVariant, 302 - ) 303 303 } 304 304 }, 305 305 hideOnCollapse = true, ··· 410 410 } 411 411 412 412 val user = timelineViewModel.uiState.user!! 413 + var showAccountSwitcher by remember { mutableStateOf(false) } 414 + 415 + AsyncImage( 416 + model = ImageRequest.Builder(LocalContext.current) 417 + .data(user.avatar?.uri) 418 + .crossfade(true) 419 + .build(), 420 + placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 421 + contentDescription = "${user.displayName ?: user.handle.handle}'s avatar", 422 + contentScale = ContentScale.Crop, 423 + modifier = Modifier 424 + .size(55.dp) 425 + .clip(CircleShape) 426 + .combinedClickable( 427 + onClick = { onProfileTap(user.did) }, 428 + onLongClick = { showAccountSwitcher = true } 429 + ) 430 + ) 413 431 414 - IconButton(onClick = { onProfileTap(user.did) }) { 415 - AsyncImage( 416 - model = ImageRequest.Builder(LocalContext.current) 417 - .data(user.avatar?.uri) 418 - .crossfade(true) 419 - .build(), 420 - placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), 421 - contentDescription = "${user.displayName ?: user.handle.handle}'s avatar", 422 - contentScale = ContentScale.Crop, 423 - modifier = 424 - Modifier 425 - .size(55.dp) 426 - .clip(CircleShape) 432 + if (showAccountSwitcher) { 433 + AccountSwitcherSheet( 434 + accounts = timelineViewModel.accounts, 435 + activeDid = timelineViewModel.activeDid, 436 + onSwitchAccount = { did -> 437 + timelineViewModel.switchAccount(did) 438 + }, 439 + onAddAccount = onAddAccount, 440 + onRemoveAccount = { did -> 441 + timelineViewModel.logout { 442 + if (timelineViewModel.accounts.isEmpty()) { 443 + loginError() 444 + } 445 + } 446 + }, 447 + onDismiss = { showAccountSwitcher = false } 427 448 ) 428 449 } 429 450 }
+3 -3
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
··· 195 195 196 196 is Notification.Mention -> SkeetView( 197 197 viewModel = viewModel, 198 - skeet = SkeetData.fromPost( 198 + skeet = notification.hydratedPost ?: SkeetData.fromPost( 199 199 notification.parent, 200 200 notification.mention, 201 201 notification.author, ··· 209 209 210 210 is Notification.Quote -> SkeetView( 211 211 viewModel = viewModel, 212 - skeet = SkeetData.fromPost( 212 + skeet = notification.hydratedPost ?: SkeetData.fromPost( 213 213 notification.parent, 214 214 notification.quote, 215 215 notification.author, ··· 223 223 224 224 is Notification.Reply -> SkeetView( 225 225 viewModel = viewModel, 226 - skeet = SkeetData.fromPost( 226 + skeet = notification.hydratedPost ?: SkeetData.fromPost( 227 227 notification.parent, 228 228 notification.reply, 229 229 notification.author
+14 -7
app/src/main/java/industries/geesawra/monarch/ProfileView.kt
··· 45 45 import androidx.compose.material.icons.filled.SmartToy 46 46 import androidx.compose.material.icons.filled.Verified 47 47 import androidx.compose.material3.Card 48 + import androidx.compose.material3.CardDefaults 49 + import androidx.compose.material3.ElevatedCard 48 50 import androidx.compose.material3.CircularWavyProgressIndicator 49 51 import androidx.compose.material3.DropdownMenu 50 52 import androidx.compose.material3.DropdownMenuItem ··· 136 138 }, 137 139 ) { 138 140 Scaffold( 139 - containerColor = MaterialTheme.colorScheme.background, 141 + containerColor = MaterialTheme.colorScheme.surface, 140 142 modifier = Modifier 141 143 .fillMaxSize() 142 144 .nestedScroll(scrollBehavior.nestedScrollConnection), 143 145 topBar = { 144 146 TopAppBar( 145 147 colors = TopAppBarDefaults.topAppBarColors( 146 - containerColor = MaterialTheme.colorScheme.background, 147 - scrolledContainerColor = MaterialTheme.colorScheme.background, 148 - navigationIconContentColor = MaterialTheme.colorScheme.onBackground, 149 - titleContentColor = MaterialTheme.colorScheme.onBackground, 150 - actionIconContentColor = MaterialTheme.colorScheme.onBackground, 148 + containerColor = MaterialTheme.colorScheme.surface, 149 + scrolledContainerColor = MaterialTheme.colorScheme.surface, 150 + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, 151 + titleContentColor = MaterialTheme.colorScheme.onSurface, 152 + actionIconContentColor = MaterialTheme.colorScheme.onSurface, 151 153 ), 152 154 navigationIcon = { 153 155 IconButton(onClick = backButton) { ··· 293 295 items = posts, 294 296 key = { _, skeet -> "post_${skeet.key()}" } 295 297 ) { _, skeet -> 296 - Card { 298 + ElevatedCard( 299 + colors = CardDefaults.elevatedCardColors( 300 + containerColor = MaterialTheme.colorScheme.surfaceContainerLow 301 + ), 302 + ) { 297 303 SkeetView( 298 304 viewModel = timelineViewModel, 299 305 skeet = skeet, ··· 301 307 postTextSize = settingsState.postTextSize, 302 308 avatarShape = avatarClipShape, 303 309 showLabels = settingsState.showLabels, 310 + onAvatarTap = onProfileTap, 304 311 onShowThread = { s -> 305 312 timelineViewModel.setThread(s) 306 313 onThreadTap(s)
+123
app/src/main/java/industries/geesawra/monarch/SettingsView.kt
··· 1 1 package industries.geesawra.monarch 2 2 3 3 import android.os.Build 4 + import androidx.compose.foundation.clickable 4 5 import androidx.compose.foundation.layout.Column 5 6 import androidx.compose.foundation.layout.WindowInsets 6 7 import androidx.compose.foundation.layout.fillMaxSize ··· 15 16 import androidx.compose.material3.HorizontalDivider 16 17 import androidx.compose.material3.Icon 17 18 import androidx.compose.material3.IconButton 19 + import androidx.compose.foundation.layout.fillMaxWidth 20 + import androidx.compose.material3.ButtonDefaults 18 21 import androidx.compose.material3.ListItem 22 + import androidx.compose.material3.OutlinedButton 19 23 import androidx.compose.material3.MaterialTheme 20 24 import androidx.compose.material3.Scaffold 21 25 import androidx.compose.material3.SegmentedButton ··· 23 27 import androidx.compose.material3.SingleChoiceSegmentedButtonRow 24 28 import androidx.compose.material3.Switch 25 29 import androidx.compose.material3.Text 30 + import androidx.compose.material3.TextButton 26 31 import androidx.compose.material3.TopAppBar 27 32 import androidx.compose.material3.TopAppBarDefaults 28 33 import androidx.compose.runtime.Composable 34 + import androidx.compose.runtime.getValue 35 + import androidx.compose.runtime.mutableStateOf 36 + import androidx.compose.runtime.remember 37 + import androidx.compose.runtime.setValue 29 38 import androidx.compose.ui.Modifier 30 39 import androidx.compose.ui.unit.dp 40 + import androidx.compose.foundation.layout.Spacer 41 + import androidx.compose.foundation.layout.height 42 + import androidx.compose.material3.AlertDialog 43 + import androidx.compose.material3.OutlinedTextField 31 44 import industries.geesawra.monarch.datalayer.AvatarShape 32 45 import industries.geesawra.monarch.datalayer.PostTextSize 33 46 import industries.geesawra.monarch.datalayer.ReplyFilterMode 34 47 import industries.geesawra.monarch.datalayer.SettingsViewModel 35 48 import industries.geesawra.monarch.datalayer.ThemeMode 49 + import industries.geesawra.monarch.datalayer.TimelineViewModel 36 50 37 51 @OptIn(ExperimentalMaterial3Api::class) 38 52 @Composable 39 53 fun SettingsView( 40 54 settingsViewModel: SettingsViewModel, 55 + timelineViewModel: TimelineViewModel? = null, 41 56 backButton: () -> Unit, 57 + onLogout: () -> Unit = {}, 42 58 ) { 43 59 val settings = settingsViewModel.settingsState 44 60 ··· 211 227 ) 212 228 }, 213 229 ) 230 + 231 + if (timelineViewModel != null) { 232 + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) 233 + 234 + Text( 235 + text = "Network", 236 + style = MaterialTheme.typography.titleSmall, 237 + color = MaterialTheme.colorScheme.primary, 238 + modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp) 239 + ) 240 + 241 + val knownAppviews = listOf( 242 + "Bluesky" to "did:web:api.bsky.app#bsky_appview", 243 + "Blacksky" to "did:web:api.blacksky.community#bsky_appview", 244 + ) 245 + val currentProxy = timelineViewModel.appviewProxy() ?: "" 246 + var showCustomDialog by remember { mutableStateOf(false) } 247 + 248 + knownAppviews.forEach { (name, did) -> 249 + ListItem( 250 + headlineContent = { Text(name) }, 251 + supportingContent = { Text(did) }, 252 + leadingContent = { 253 + androidx.compose.material3.RadioButton( 254 + selected = currentProxy == did, 255 + onClick = { 256 + if (currentProxy != did) { 257 + timelineViewModel.changeAppview(did) 258 + } 259 + } 260 + ) 261 + }, 262 + modifier = Modifier.clickable { 263 + if (currentProxy != did) { 264 + timelineViewModel.changeAppview(did) 265 + } 266 + } 267 + ) 268 + } 269 + 270 + val isCustom = currentProxy.isNotEmpty() && knownAppviews.none { it.second == currentProxy } 271 + ListItem( 272 + headlineContent = { Text("Custom") }, 273 + supportingContent = { 274 + if (isCustom) Text(currentProxy) 275 + else Text("Use a custom appview") 276 + }, 277 + leadingContent = { 278 + androidx.compose.material3.RadioButton( 279 + selected = isCustom, 280 + onClick = { showCustomDialog = true } 281 + ) 282 + }, 283 + modifier = Modifier.clickable { showCustomDialog = true } 284 + ) 285 + 286 + if (showCustomDialog) { 287 + var customDid by remember { mutableStateOf(if (isCustom) currentProxy else "") } 288 + AlertDialog( 289 + onDismissRequest = { showCustomDialog = false }, 290 + title = { Text("Custom Appview") }, 291 + text = { 292 + Column { 293 + Text( 294 + text = "Enter the DID of the appview service", 295 + style = MaterialTheme.typography.bodyMedium, 296 + ) 297 + Spacer(modifier = Modifier.height(8.dp)) 298 + OutlinedTextField( 299 + value = customDid, 300 + onValueChange = { customDid = it }, 301 + label = { Text("Appview DID") }, 302 + placeholder = { Text("did:web:example.com#bsky_appview") }, 303 + singleLine = true, 304 + ) 305 + } 306 + }, 307 + confirmButton = { 308 + TextButton( 309 + onClick = { 310 + if (customDid.isNotBlank()) { 311 + timelineViewModel.changeAppview(customDid.trim()) 312 + showCustomDialog = false 313 + } 314 + } 315 + ) { Text("Apply") } 316 + }, 317 + dismissButton = { 318 + TextButton(onClick = { showCustomDialog = false }) { Text("Cancel") } 319 + }, 320 + ) 321 + } 322 + } 323 + 324 + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) 325 + 326 + OutlinedButton( 327 + onClick = onLogout, 328 + modifier = Modifier 329 + .fillMaxWidth() 330 + .padding(horizontal = 16.dp, vertical = 8.dp), 331 + colors = ButtonDefaults.outlinedButtonColors( 332 + contentColor = MaterialTheme.colorScheme.error 333 + ), 334 + ) { 335 + Text("Log out") 336 + } 214 337 } 215 338 } 216 339 }
+8 -7
app/src/main/java/industries/geesawra/monarch/ShowSkeets.kt
··· 20 20 import androidx.compose.foundation.shape.RoundedCornerShape 21 21 import androidx.compose.material3.CardDefaults 22 22 import androidx.compose.material3.ElevatedCard 23 + import androidx.compose.material3.FilledTonalButton 24 + import androidx.compose.material3.Text 23 25 import androidx.compose.material3.HorizontalDivider 24 26 import androidx.compose.material3.VerticalDivider 25 27 import androidx.compose.runtime.Composable ··· 141 143 color = MaterialTheme.colorScheme.outlineVariant 142 144 ) 143 145 } 144 - ConditionalCard( 145 - text = "See more", 146 - modifier = Modifier 147 - .weight(1f) 148 - .padding(start = 12.dp), 149 - onTap = { 146 + FilledTonalButton( 147 + modifier = Modifier.padding(start = 12.dp), 148 + onClick = { 150 149 if (onSeeMoreTap != null) { 151 150 viewModel.setThread(root) 152 151 onSeeMoreTap(root) 153 152 } 154 153 } 155 - ) 154 + ) { 155 + Text("See full thread") 156 + } 156 157 } 157 158 } 158 159
+7 -7
app/src/main/java/industries/geesawra/monarch/ThreadView.kt
··· 49 49 onRefresh = {}, 50 50 ) { 51 51 Scaffold( 52 - containerColor = MaterialTheme.colorScheme.background, 52 + containerColor = MaterialTheme.colorScheme.surface, 53 53 modifier = modifier 54 54 .fillMaxSize() 55 55 .nestedScroll(scrollBehavior.nestedScrollConnection), 56 56 topBar = { 57 57 TopAppBar( 58 58 colors = TopAppBarColors( 59 - containerColor = MaterialTheme.colorScheme.background, 60 - scrolledContainerColor = MaterialTheme.colorScheme.background, 61 - navigationIconContentColor = MaterialTheme.colorScheme.onBackground, // Ensuring correct contrast 62 - titleContentColor = MaterialTheme.colorScheme.onBackground, 63 - actionIconContentColor = MaterialTheme.colorScheme.onBackground, 64 - subtitleContentColor = MaterialTheme.colorScheme.onBackground 59 + containerColor = MaterialTheme.colorScheme.surface, 60 + scrolledContainerColor = MaterialTheme.colorScheme.surface, 61 + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, // Ensuring correct contrast 62 + titleContentColor = MaterialTheme.colorScheme.onSurface, 63 + actionIconContentColor = MaterialTheme.colorScheme.onSurface, 64 + subtitleContentColor = MaterialTheme.colorScheme.onSurface 65 65 ), 66 66 navigationIcon = { 67 67 IconButton(onClick = backButton) {
+108
app/src/main/java/industries/geesawra/monarch/datalayer/AccountManager.kt
··· 1 + package industries.geesawra.monarch.datalayer 2 + 3 + import android.content.Context 4 + import androidx.datastore.preferences.core.edit 5 + import androidx.datastore.preferences.core.stringPreferencesKey 6 + import androidx.datastore.preferences.preferencesDataStore 7 + import dagger.hilt.android.qualifiers.ApplicationContext 8 + import jakarta.inject.Inject 9 + import jakarta.inject.Singleton 10 + import kotlinx.coroutines.flow.first 11 + import kotlinx.coroutines.flow.map 12 + import kotlinx.serialization.Serializable 13 + import kotlinx.serialization.encodeToString 14 + import kotlinx.serialization.json.Json 15 + 16 + private val Context.accountsDataStore by preferencesDataStore("accounts") 17 + 18 + @Serializable 19 + data class StoredAccount( 20 + val did: String, 21 + val handle: String, 22 + val displayName: String? = null, 23 + val avatarUrl: String? = null, 24 + val pdsHost: String, 25 + val appviewProxy: String, 26 + val sessionJson: String, 27 + ) 28 + 29 + @Singleton 30 + class AccountManager @Inject constructor( 31 + @ApplicationContext private val context: Context 32 + ) { 33 + companion object { 34 + private val ACCOUNTS_LIST = stringPreferencesKey("accounts_list") 35 + private val ACTIVE_DID = stringPreferencesKey("active_did") 36 + } 37 + 38 + suspend fun getAccounts(): List<StoredAccount> { 39 + val json = context.accountsDataStore.data.map { it[ACCOUNTS_LIST] ?: "[]" }.first() 40 + return runCatching { Json.decodeFromString<List<StoredAccount>>(json) }.getOrDefault(emptyList()) 41 + } 42 + 43 + suspend fun getActiveDid(): String? { 44 + return context.accountsDataStore.data.map { it[ACTIVE_DID] }.first() 45 + } 46 + 47 + suspend fun addAccount(account: StoredAccount) { 48 + val accounts = getAccounts().toMutableList() 49 + accounts.removeAll { it.did == account.did } 50 + accounts.add(account) 51 + context.accountsDataStore.edit { 52 + it[ACCOUNTS_LIST] = Json.encodeToString(accounts) 53 + it[ACTIVE_DID] = account.did 54 + } 55 + } 56 + 57 + suspend fun removeAccount(did: String) { 58 + val accounts = getAccounts().toMutableList() 59 + accounts.removeAll { it.did == did } 60 + context.accountsDataStore.edit { 61 + it[ACCOUNTS_LIST] = Json.encodeToString(accounts) 62 + if (it[ACTIVE_DID] == did) { 63 + val next = accounts.firstOrNull() 64 + if (next != null) { 65 + it[ACTIVE_DID] = next.did 66 + } else { 67 + it.remove(ACTIVE_DID) 68 + } 69 + } 70 + } 71 + } 72 + 73 + suspend fun updateAccountSession(did: String, sessionJson: String) { 74 + val accounts = getAccounts().toMutableList() 75 + val idx = accounts.indexOfFirst { it.did == did } 76 + if (idx >= 0) { 77 + accounts[idx] = accounts[idx].copy(sessionJson = sessionJson) 78 + context.accountsDataStore.edit { 79 + it[ACCOUNTS_LIST] = Json.encodeToString(accounts) 80 + } 81 + } 82 + } 83 + 84 + suspend fun updateAccountProfile(did: String, displayName: String?, avatarUrl: String?) { 85 + val accounts = getAccounts().toMutableList() 86 + val idx = accounts.indexOfFirst { it.did == did } 87 + if (idx >= 0) { 88 + accounts[idx] = accounts[idx].copy(displayName = displayName, avatarUrl = avatarUrl) 89 + context.accountsDataStore.edit { 90 + it[ACCOUNTS_LIST] = Json.encodeToString(accounts) 91 + } 92 + } 93 + } 94 + 95 + suspend fun setActiveDid(did: String) { 96 + context.accountsDataStore.edit { 97 + it[ACTIVE_DID] = did 98 + } 99 + } 100 + 101 + suspend fun getAccount(did: String): StoredAccount? { 102 + return getAccounts().firstOrNull { it.did == did } 103 + } 104 + 105 + suspend fun hasAccounts(): Boolean { 106 + return getAccounts().isNotEmpty() 107 + } 108 + }
+24
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 316 316 } 317 317 } 318 318 319 + suspend fun logout() { 320 + cleanSessionData() 321 + client = null 322 + pdsClient = null 323 + session = null 324 + pdsURL = null 325 + appviewProxy = null 326 + } 327 + 328 + fun resetClients() { 329 + client = null 330 + pdsClient = null 331 + session = null 332 + pdsURL = null 333 + } 334 + 335 + suspend fun changeAppview(newAppviewProxy: String) { 336 + this.appviewProxy = newAppviewProxy 337 + context.dataStore.edit { settings -> 338 + settings[APPVIEW_PROXY] = newAppviewProxy 339 + } 340 + this.client = null 341 + } 342 + 319 343 suspend fun cleanSessionData() { 320 344 context.dataStore.edit { settings -> 321 345 settings.remove(SESSION)
+6 -3
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 719 719 val reply: Post, 720 720 val author: ProfileView, 721 721 val createdAt: Instant, 722 - val new: Boolean 722 + val new: Boolean, 723 + val hydratedPost: SkeetData? = null, 723 724 ) : 724 725 Notification() 725 726 ··· 731 732 val mention: Post, 732 733 val author: ProfileView, 733 734 val createdAt: Instant, 734 - val new: Boolean 735 + val new: Boolean, 736 + val hydratedPost: SkeetData? = null, 735 737 ) : 736 738 Notification() 737 739 ··· 741 743 val quotedPost: PostViewEmbedUnion, 742 744 val author: ProfileView, 743 745 val createdAt: Instant, 744 - val new: Boolean 746 + val new: Boolean, 747 + val hydratedPost: SkeetData? = null, 745 748 ) : 746 749 Notification() 747 750
+114 -21
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 97 97 98 98 @HiltViewModel(assistedFactory = TimelineViewModel.Factory::class) 99 99 class TimelineViewModel @AssistedInject constructor( 100 - @Assisted private val bskyConn: BlueskyConn 100 + @Assisted private val bskyConn: BlueskyConn, 101 + private val accountManager: AccountManager, 101 102 ) : ViewModel() { 102 103 103 104 @AssistedFactory ··· 106 107 } 107 108 108 109 var uiState by mutableStateOf(TimelineUiState()) 110 + private set 111 + 112 + var accounts by mutableStateOf<List<StoredAccount>>(emptyList()) 113 + private set 114 + 115 + var activeDid by mutableStateOf<String?>(null) 109 116 private set 110 117 111 118 private var timelineFetchJob: Job? = null 112 119 private var notificationsFetchJob: Job? = null 113 120 114 121 fun appviewName(): String = bskyConn.appviewName() 122 + fun appviewProxy(): String? = bskyConn.appviewProxy 123 + 124 + fun changeAppview(newAppviewProxy: String, then: () -> Unit = {}) { 125 + viewModelScope.launch { 126 + bskyConn.changeAppview(newAppviewProxy) 127 + fetchAllNewData(then) 128 + } 129 + } 130 + 115 131 fun labelDisplayName(label: Label): String = bskyConn.labelDisplayName(label) 116 132 fun labelDescription(label: Label): String? = bskyConn.labelDescription(label) 117 133 fun labelerAvatar(label: Label): String? = bskyConn.labelerAvatar(label) 118 134 135 + fun refreshAccounts() { 136 + viewModelScope.launch { 137 + accounts = accountManager.getAccounts() 138 + activeDid = accountManager.getActiveDid() 139 + } 140 + } 141 + 142 + fun saveCurrentAccount() { 143 + viewModelScope.launch { 144 + val session = bskyConn.session ?: return@launch 145 + val pds = bskyConn.pdsURL ?: return@launch 146 + val appview = bskyConn.appviewProxy ?: return@launch 147 + val user = uiState.user 148 + 149 + accountManager.addAccount( 150 + StoredAccount( 151 + did = session.did.did, 152 + handle = session.handle.handle, 153 + displayName = user?.displayName, 154 + avatarUrl = user?.avatar?.uri, 155 + pdsHost = pds, 156 + appviewProxy = appview, 157 + sessionJson = session.encodeToJson(), 158 + ) 159 + ) 160 + refreshAccounts() 161 + } 162 + } 163 + 164 + fun switchAccount(did: String, then: () -> Unit = {}) { 165 + viewModelScope.launch { 166 + val currentSession = bskyConn.session 167 + if (currentSession != null) { 168 + accountManager.updateAccountSession(currentSession.did.did, currentSession.encodeToJson()) 169 + } 170 + 171 + val target = accountManager.getAccount(did) ?: return@launch 172 + accountManager.setActiveDid(did) 173 + 174 + bskyConn.storeSessionData( 175 + target.pdsHost, 176 + target.appviewProxy, 177 + SessionData.decodeFromJson(target.sessionJson) 178 + ) 179 + bskyConn.resetClients() 180 + uiState = TimelineUiState() 181 + uiState = uiState.copy(authenticated = true, sessionChecked = true) 182 + refreshAccounts() 183 + fetchAllNewData(then) 184 + } 185 + } 186 + 187 + fun logout(then: () -> Unit = {}) { 188 + viewModelScope.launch { 189 + val currentDid = bskyConn.session?.did?.did 190 + if (currentDid != null) { 191 + accountManager.removeAccount(currentDid) 192 + } 193 + bskyConn.logout() 194 + 195 + val remaining = accountManager.getAccounts() 196 + if (remaining.isNotEmpty()) { 197 + val next = remaining.first() 198 + switchAccount(next.did, then) 199 + } else { 200 + uiState = TimelineUiState(sessionChecked = true) 201 + refreshAccounts() 202 + then() 203 + } 204 + } 205 + } 206 + 119 207 fun loadSession() { 120 208 viewModelScope.launch { 121 209 if (!bskyConn.hasSession()) { 122 210 uiState = uiState.copy(sessionChecked = true) 211 + refreshAccounts() 123 212 return@launch 124 213 } 125 214 126 215 uiState = uiState.copy(authenticated = true, sessionChecked = true) 216 + refreshAccounts() 127 217 } 128 218 } 129 219 ··· 163 253 164 254 fun fetchSelf(): Job { 165 255 return viewModelScope.launch { 166 - val ret = bskyConn.fetchSelf().onFailure { 256 + bskyConn.fetchSelf().onFailure { 167 257 uiState = when (it) { 168 258 is LoginException -> uiState.copy(loginError = it.message) 169 259 else -> uiState.copy(error = it.message) 170 260 } 171 261 }.onSuccess { 172 262 uiState = uiState.copy(user = it) 263 + saveCurrentAccount() 173 264 } 174 265 } 175 266 } ··· 269 360 ) 270 361 271 362 val repeatable = mutableListOf<Notification>() 272 - val postsToFetch = rawNotifs.notifications.mapNotNull { 363 + val postsToFetch = rawNotifs.notifications.flatMap { 273 364 when (it.reason) { 274 365 ListNotificationsReason.Like -> { 275 366 val l: Like = it.record.decodeAs() 276 - l.subject.uri 367 + listOf(l.subject.uri) 277 368 } 278 369 279 370 ListNotificationsReason.Repost -> { 280 371 val l: Repost = it.record.decodeAs() 281 - l.subject.uri 372 + listOf(l.subject.uri) 282 373 } 283 374 284 375 ListNotificationsReason.Quote -> { 285 376 val l: Post = it.record.decodeAs() 286 377 val e = l.embed 287 - when (e) { 288 - is PostEmbedUnion.Record -> { 289 - e.value.record.uri 290 - } 291 - 292 - is PostEmbedUnion.RecordWithMedia -> { 293 - e.value.record.record.uri 294 - } 295 - 378 + val quotedUri = when (e) { 379 + is PostEmbedUnion.Record -> e.value.record.uri 380 + is PostEmbedUnion.RecordWithMedia -> e.value.record.record.uri 296 381 else -> null 297 382 } 383 + listOfNotNull(it.uri, quotedUri) 298 384 } 299 385 300 - else -> null 386 + ListNotificationsReason.Mention -> listOf(it.uri) 387 + ListNotificationsReason.Reply -> listOf(it.uri) 388 + 389 + else -> emptyList() 301 390 } 302 391 }.distinct() 303 392 ··· 306 395 acc + bskyConn.getPosts(chunk).getOrThrow() 307 396 .associate { 308 397 val record = it.record.decodeAs<Post>() 309 - it.uri to (SkeetData.fromPost( 310 - (it.cid to it.uri), 311 - record, 398 + it.uri to (SkeetData.fromPostView( 399 + it, 312 400 it.author 313 401 ) to record) 314 402 } ··· 339 427 340 428 ListNotificationsReason.Mention -> { 341 429 val p: Post = it.record.decodeAs() 430 + val hydrated = posts[it.uri]?.first 342 431 Notification.Mention( 343 432 Pair(it.cid, it.uri), 344 433 p, 345 434 it.author, 346 435 p.createdAt.toStdlibInstant(), 347 - !it.isRead 436 + !it.isRead, 437 + hydrated, 348 438 ) 349 439 } 350 440 ··· 401 491 ), // TODO: handle recordwithmedia 402 492 it.author, 403 493 p.createdAt.toStdlibInstant(), 404 - !it.isRead 494 + !it.isRead, 495 + posts[it.uri]?.first, 405 496 ) 406 497 } 407 498 408 499 ListNotificationsReason.Reply -> { 409 500 val p: Post = it.record.decodeAs() 501 + val hydrated = posts[it.uri]?.first 410 502 Notification.Reply( 411 503 Pair(it.cid, it.uri), 412 504 p, 413 505 it.author, 414 506 p.createdAt.toStdlibInstant(), 415 - !it.isRead 507 + !it.isRead, 508 + hydrated, 416 509 ) 417 510 } 418 511