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

Configure Feed

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

*: Add CLAUDE.md and deduplicate thread context posts in timeline

Add CLAUDE.md with project structure, build commands, architecture
patterns, and guidelines. Filter out posts whose CID already appears
as thread context (root/parent) of another post in the feed.

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

geesawra 63ef1951 cc6b5959

+164 -1
+146
CLAUDE.md
··· 1 + # Monarch - Bluesky Client for Android 2 + 3 + ## Building & Running 4 + 5 + ```bash 6 + ./gradlew assembleDebug # Build debug APK 7 + ./gradlew compileDebugKotlin # Fast compile check (no full APK) 8 + ./gradlew installDebug # Install on connected device 9 + ./gradlew test # Run unit tests 10 + ``` 11 + 12 + The worktree may not have `local.properties` — copy it from the main repo root if the build fails with "SDK location not found". 13 + 14 + ## Architecture 15 + 16 + MVVM with Jetpack Compose. Single-activity app (`MainActivity`) with NavHost navigation. 17 + 18 + **Navigation routes** are defined in `ViewList` enum in `MainActivity.kt`: Login, Main, ShowThread, Profile. Navigation uses slide animations. New screens should follow this pattern. 19 + 20 + **Dependency injection**: Hilt. `TimelineViewModel` uses assisted injection via `BlueskyConn`. 21 + 22 + **State management**: `TimelineUiState` data class in `TimelineViewModel`, exposed as `mutableStateOf`. All UI state lives here — profile, search, timeline, notifications. 23 + 24 + ## Tech Stack 25 + 26 + | Concern | Library | Version | 27 + |---------|---------|---------| 28 + | UI | Jetpack Compose + Material 3 | BOM 2025.10.01, M3 1.5.0-alpha07 | 29 + | Networking | Ktor (OkHttp engine) | 3.3.1 | 30 + | Bluesky SDK | sh.christian.ozone:bluesky | 0.3.3 | 31 + | Images | Coil 3 | 3.3.0 | 32 + | Video | Media3 ExoPlayer | 1.8.0 | 33 + | DI | Hilt | 2.57.2 | 34 + | Zoomable images | Telephoto | 0.18.0 | 35 + | Serialization | KotlinX Serialization JSON | 1.9.0 | 36 + | Data persistence | DataStore Preferences | 1.1.7 | 37 + 38 + ## Project Structure 39 + 40 + ``` 41 + app/src/main/java/industries/geesawra/monarch/ 42 + ├── MainActivity.kt # Entry point, NavHost, Coil setup 43 + ├── MainView.kt # Main screen: tabs, top bar, bottom nav, feeds drawer 44 + ├── LoginView.kt # Authentication screen 45 + ├── ThreadView.kt # Post thread viewer 46 + ├── ProfileView.kt # Profile viewer + editor 47 + ├── SearchView.kt # Search tab (posts + people) 48 + ├── SkeetView.kt # Single post rendering (reused everywhere) 49 + ├── ShowSkeets.kt # LazyColumn of posts with thread context 50 + ├── ComposeView.kt # Post composition bottom sheet 51 + ├── NotificationsView.kt # Notifications tab 52 + ├── TimelinePostActionsView.kt # Like/repost/reply/share buttons 53 + ├── PostImageGallery.kt # Image grid display 54 + ├── GalleryViewer.kt # Full-screen zoomable image viewer 55 + ├── ConditionalCard.kt # Card wrapper utility 56 + ├── LikeRepostRowView.kt # Avatar row for grouped notifications 57 + ├── VectorImages.kt # Custom vector icons 58 + ├── datalayer/ 59 + │ ├── Bluesky.kt # API client (BlueskyConn), all network calls 60 + │ ├── TimelineViewModel.kt # ViewModel, all UI state, business logic 61 + │ ├── Models.kt # SkeetData, Notification, ThreadPost, etc. 62 + │ ├── LinkPreview.kt # OpenGraph metadata fetching 63 + │ └── Compressor.kt # Image compression for uploads 64 + ├── ui/theme/ 65 + │ ├── Theme.kt # Material 3 dynamic color theme 66 + │ ├── Color.kt # Color definitions 67 + │ └── Type.kt # Typography scale 68 + └── thirdpartyforks/ # Forked third-party compose libraries 69 + ``` 70 + 71 + ## Key Patterns 72 + 73 + ### API Layer (Bluesky.kt) 74 + 75 + All API methods follow this pattern: 76 + ```kotlin 77 + suspend fun someMethod(): Result<T> { 78 + return runCatching { 79 + create().onFailure { 80 + return Result.failure(LoginException(it.message)) 81 + } 82 + val ret = client!!.someXrpcCall(params) 83 + return when (ret) { 84 + is AtpResponse.Failure<*> -> Result.failure(Exception("...")) 85 + is AtpResponse.Success<T> -> Result.success(ret.response) 86 + } 87 + } 88 + } 89 + ``` 90 + 91 + `create()` initializes the authenticated client from DataStore if not already set. Always call it first. 92 + 93 + ### ViewModel (TimelineViewModel.kt) 94 + 95 + Methods that update UI launch coroutines via `viewModelScope.launch` and update `uiState` via `copy()`. Error handling distinguishes `LoginException` (auth failure → redirect to login) from general errors (show snackbar). 96 + 97 + ### Post Data (Models.kt) 98 + 99 + `SkeetData` is the universal post model. Three factory methods: 100 + - `fromFeedViewPost()` — timeline items (has resolved embeds) 101 + - `fromPostView()` — thread/profile posts (has resolved embeds) 102 + - `fromPost()` — raw record conversion (manually constructs embed URLs via CDN) 103 + 104 + **Important**: `fromPostView` should be preferred over `fromPost` when a `PostView` is available, because it uses the already-hydrated `embed` field. `fromPost` manually converts `PostEmbedUnion` → `PostViewEmbedUnion` and doesn't handle all types. 105 + 106 + ### Composable Reuse 107 + 108 + - `SkeetView` is the core post renderer — used in timeline, threads, profiles, search, notifications 109 + - `ShowSkeets` wraps `SkeetView` in a `LazyColumn` with thread context rendering 110 + - `Card` wraps individual posts in both `ShowSkeets` and standalone contexts 111 + - When adding new views that show posts, reuse `SkeetView` + `Card` rather than building custom layouts 112 + 113 + ### Adding New Tabs 114 + 115 + 1. Add entry to `TabBarDestinations` enum in `MainView.kt` 116 + 2. Add string resource in `res/values/strings.xml` 117 + 3. Add `when` cases for: title, navigationIcon, actions, floatingActionButton, and content rendering 118 + 4. Create `LazyListState` for the new tab in `InnerTimelineView` 119 + 120 + ### Adding New Screens (Full Navigation) 121 + 122 + 1. Add route to `ViewList` enum in `MainActivity.kt` 123 + 2. Add `composable(route = ...)` block in the `NavHost` 124 + 3. Pass callbacks for navigation (back, thread tap, profile tap, etc.) 125 + 126 + ## Ozone SDK Notes 127 + 128 + The Bluesky SDK (`sh.christian.ozone:bluesky:0.3.3`) provides typed XRPC methods on `BlueskyApi`. Key types: 129 + 130 + - `AtpResponse<T>` — sealed: `Success` or `Failure` 131 + - `AtUri`, `Did`, `Cid`, `Handle`, `RKey`, `Nsid` — AT Protocol identifiers 132 + - `ProfileViewDetailed` — full profile with viewer state (following, muted, blocked) 133 + - `PostView` — post with resolved embeds 134 + - `FeedViewPost` — timeline item (post + reason + reply context) 135 + - `ViewerState` — relationship metadata (following, followedBy, muted, blocking) 136 + 137 + For record creation, use `BlueskyJson.encodeAsJsonContent()` to convert typed records to `JsonContent` for `createRecord`/`putRecord`. 138 + 139 + ## Guidelines 140 + 141 + - Use Material 3 / Material You components and patterns 142 + - Don't add comments to code 143 + - Don't edit more than what's explicitly requested 144 + - Compile with `./gradlew compileDebugKotlin` to verify changes 145 + - Reuse existing components — avoid building verticals that are hard to reuse 146 + - Prefer `fromPostView` over `fromPost` when `PostView` data is available
+18 -1
app/src/main/java/industries/geesawra/monarch/ShowSkeets.kt
··· 31 31 import androidx.compose.ui.unit.dp 32 32 import app.bsky.feed.FeedViewPostReasonUnion 33 33 import industries.geesawra.monarch.datalayer.SkeetData 34 + import sh.christian.ozone.api.Cid 34 35 import industries.geesawra.monarch.datalayer.TimelineViewModel 35 36 import sh.christian.ozone.api.Did 36 37 ··· 47 48 onSeeMoreTap: ((SkeetData) -> Unit)? = null, 48 49 onProfileTap: ((Did) -> Unit)? = null, 49 50 ) { 51 + // Collect CIDs already shown as thread context (root/parent) to avoid duplicates 52 + val threadContextCids = remember(data) { 53 + if (isShowingThread) emptySet() 54 + else { 55 + val cids = mutableSetOf<Cid>() 56 + data.forEach { skeet -> 57 + val isRepost = skeet.reason is FeedViewPostReasonUnion.ReasonRepost 58 + if (!isRepost) { 59 + skeet.root()?.cid?.let { cids.add(it) } 60 + skeet.parent().first?.cid?.let { cids.add(it) } 61 + } 62 + } 63 + cids 64 + } 65 + } 66 + 50 67 LazyColumn( 51 68 state = state, 52 69 userScrollEnabled = isScrollEnabled, ··· 56 73 verticalArrangement = Arrangement.spacedBy(16.dp), 57 74 ) { 58 75 itemsIndexed( 59 - items = data.filter { !it.replyToNotFollowing }, 76 + items = data.filter { !it.replyToNotFollowing && it.cid !in threadContextCids }, 60 77 key = { _, skeet -> skeet.key() } 61 78 ) { idx, skeet -> 62 79 Card(