tangled
alpha
login
or
join now
geesawra.industries
/
jerry-no
8
fork
atom
A cheap attempt at a native Bluesky client for Android
8
fork
atom
overview
issues
pulls
pipelines
*: some resemblance of thread loading
geesawra.industries
5 months ago
28b7148d
19754b9e
+252
-45
9 changed files
expand all
collapse all
unified
split
app
src
main
java
industries
geesawra
monarch
ConditionalCard.kt
MainActivity.kt
MainView.kt
NotificationsView.kt
ShowSkeets.kt
SkeetView.kt
ThreadView.kt
datalayer
Bluesky.kt
TimelineViewModel.kt
+10
-3
app/src/main/java/industries/geesawra/monarch/ConditionalCard.kt
···
1
1
package industries.geesawra.monarch
2
2
3
3
+
import androidx.compose.foundation.clickable
3
4
import androidx.compose.foundation.layout.Arrangement
4
5
import androidx.compose.foundation.layout.Column
5
6
import androidx.compose.foundation.layout.fillMaxSize
···
14
15
import androidx.compose.ui.unit.dp
15
16
16
17
@Composable
17
17
-
fun ConditionalCard(text: String, wrapWithCard: Boolean = true) {
18
18
+
fun ConditionalCard(
19
19
+
modifier: Modifier = Modifier,
20
20
+
onTap: () -> Unit = {},
21
21
+
text: String,
22
22
+
wrapWithCard: Boolean = true
23
23
+
) {
18
24
if (wrapWithCard) {
19
25
OutlinedCard(
20
20
-
modifier = Modifier
26
26
+
modifier = modifier
21
27
.height(80.dp)
22
28
.padding(8.dp)
23
29
.fillMaxWidth()
···
25
31
Column(
26
32
modifier = Modifier
27
33
.fillMaxSize()
28
28
-
.padding(start = 16.dp),
34
34
+
.clickable { onTap() },
29
35
verticalArrangement = Arrangement.Center
30
36
) {
31
37
Text(
38
38
+
modifier = Modifier.padding(start = 16.dp),
32
39
text = text,
33
40
color = MaterialTheme.colorScheme.onSurfaceVariant
34
41
)
+27
-9
app/src/main/java/industries/geesawra/monarch/MainActivity.kt
···
5
5
import androidx.activity.ComponentActivity
6
6
import androidx.activity.compose.setContent
7
7
import androidx.activity.enableEdgeToEdge
8
8
-
import androidx.compose.animation.EnterTransition
9
8
import androidx.compose.animation.ExperimentalSharedTransitionApi
10
10
-
import androidx.compose.animation.scaleOut
9
9
+
import androidx.compose.animation.slideInHorizontally
10
10
+
import androidx.compose.animation.slideOutHorizontally
11
11
import androidx.compose.foundation.layout.Box
12
12
+
import androidx.compose.foundation.layout.WindowInsets
12
13
import androidx.compose.foundation.layout.fillMaxSize
14
14
+
import androidx.compose.foundation.layout.statusBars
15
15
+
import androidx.compose.foundation.layout.windowInsetsPadding
13
16
import androidx.compose.foundation.rememberScrollState
14
17
import androidx.compose.foundation.verticalScroll
15
18
import androidx.compose.material3.ExperimentalMaterial3Api
···
18
21
import androidx.compose.runtime.rememberCoroutineScope
19
22
import androidx.compose.ui.Alignment
20
23
import androidx.compose.ui.Modifier
21
21
-
import androidx.compose.ui.graphics.TransformOrigin
22
24
import androidx.compose.ui.platform.LocalContext
23
25
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
24
26
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
···
44
46
enum class ViewList() {
45
47
Login,
46
48
Main,
49
49
+
ShowThread,
47
50
}
48
51
49
52
@AndroidEntryPoint
···
106
109
navController = navController,
107
110
startDestination = initialRoute,
108
111
modifier = Modifier.fillMaxSize(),
109
109
-
popExitTransition = {
110
110
-
scaleOut(
111
111
-
targetScale = 0.9f,
112
112
-
transformOrigin = TransformOrigin(0.5f, 0.5f)
113
113
-
)
112
112
+
enterTransition = {
113
113
+
slideInHorizontally(initialOffsetX = { it })
114
114
+
},
115
115
+
exitTransition = {
116
116
+
slideOutHorizontally(targetOffsetX = { -it })
114
117
},
115
118
popEnterTransition = {
116
116
-
EnterTransition.None
119
119
+
slideInHorizontally(initialOffsetX = { -it })
120
120
+
},
121
121
+
popExitTransition = {
122
122
+
slideOutHorizontally(targetOffsetX = { it })
117
123
},
118
124
) {
119
125
composable(route = ViewList.Main.name) {
···
122
128
coroutineScope = rememberCoroutineScope(),
123
129
onLoginError = {
124
130
navController.navigate(ViewList.Login.name)
131
131
+
},
132
132
+
onThreadTap = {
133
133
+
navController.navigate(ViewList.ShowThread.name)
125
134
}
126
135
)
127
136
}
137
137
+
composable(route = ViewList.ShowThread.name) {
138
138
+
ThreadView(
139
139
+
modifier = Modifier
140
140
+
.windowInsetsPadding(WindowInsets.statusBars),
141
141
+
timelineViewModel = timelineViewModel,
142
142
+
coroutineScope = rememberCoroutineScope(),
143
143
+
)
144
144
+
}
145
145
+
128
146
composable(route = ViewList.Login.name) {
129
147
Surface(
130
148
modifier = Modifier.fillMaxSize(),
+8
-1
app/src/main/java/industries/geesawra/monarch/MainView.kt
···
95
95
timelineViewModel: TimelineViewModel,
96
96
coroutineScope: CoroutineScope,
97
97
onLoginError: () -> Unit,
98
98
+
onThreadTap: (SkeetData) -> Unit,
98
99
) {
99
100
val scrollState = rememberScrollState()
100
101
val scaffoldState = rememberBottomSheetScaffoldState(
···
153
154
withDismissAction = true,
154
155
)
155
156
}
157
157
+
},
158
158
+
onSeeMoreTap = {
159
159
+
onThreadTap(it)
156
160
}
157
161
)
158
162
}
···
169
173
fobOnClick: () -> Unit,
170
174
loginError: () -> Unit,
171
175
onError: (String) -> Unit,
176
176
+
onSeeMoreTap: (SkeetData) -> Unit,
172
177
) {
173
178
var currentDestination by rememberSaveable { mutableStateOf(TabBarDestinations.TIMELINE) }
174
179
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
···
429
434
state = timelineState,
430
435
modifier = Modifier.padding(values),
431
436
onReplyTap = onReplyTap,
432
432
-
isScrollEnabled = isScrollEnabled
437
437
+
data = timelineViewModel.uiState.skeets,
438
438
+
isScrollEnabled = isScrollEnabled,
439
439
+
onSeeMoreTap = onSeeMoreTap
433
440
)
434
441
435
442
TabBarDestinations.NOTIFICATIONS -> NotificationsView(
+2
-7
app/src/main/java/industries/geesawra/monarch/NotificationsView.kt
···
8
8
import androidx.compose.foundation.layout.width
9
9
import androidx.compose.foundation.lazy.LazyColumn
10
10
import androidx.compose.foundation.lazy.LazyListState
11
11
-
import androidx.compose.material3.CardDefaults
11
11
+
import androidx.compose.material3.Card
12
12
import androidx.compose.material3.CircularProgressIndicator
13
13
-
import androidx.compose.material3.ElevatedCard
14
13
import androidx.compose.runtime.Composable
15
14
import androidx.compose.runtime.LaunchedEffect
16
15
import androidx.compose.runtime.derivedStateOf
···
43
42
) {
44
43
viewModel.uiState.notifications.forEach { notif ->
45
44
item(notif.createdAt()) {
46
46
-
ElevatedCard(
47
47
-
elevation = CardDefaults.elevatedCardElevation(
48
48
-
defaultElevation = 0.dp
49
49
-
)
50
50
-
) {
45
45
+
Card {
51
46
RenderNotification(
52
47
viewModel = viewModel,
53
48
notification = notif,
+32
-22
app/src/main/java/industries/geesawra/monarch/ShowSkeets.kt
···
9
9
import androidx.compose.foundation.layout.width
10
10
import androidx.compose.foundation.lazy.LazyColumn
11
11
import androidx.compose.foundation.lazy.LazyListState
12
12
+
import androidx.compose.foundation.lazy.items
12
13
import androidx.compose.foundation.lazy.rememberLazyListState
13
14
import androidx.compose.foundation.shape.RoundedCornerShape
14
14
-
import androidx.compose.material3.CardDefaults
15
15
+
import androidx.compose.material3.Card
15
16
import androidx.compose.material3.CircularProgressIndicator
16
16
-
import androidx.compose.material3.ElevatedCard
17
17
import androidx.compose.material3.VerticalDivider
18
18
import androidx.compose.runtime.Composable
19
19
import androidx.compose.runtime.LaunchedEffect
···
34
34
viewModel: TimelineViewModel,
35
35
isScrollEnabled: Boolean,
36
36
state: LazyListState = rememberLazyListState(),
37
37
+
data: List<SkeetData>,
38
38
+
isShowingThread: Boolean = false,
39
39
+
shouldFetchMoreData: Boolean = true,
37
40
onReplyTap: (SkeetData, Boolean) -> Unit = { _, _ -> },
41
41
+
onSeeMoreTap: ((SkeetData) -> Unit)? = null,
38
42
) {
39
43
LazyColumn(
40
44
state = state,
···
44
48
.padding(horizontal = 16.dp),
45
49
verticalArrangement = Arrangement.spacedBy(16.dp),
46
50
) {
47
47
-
viewModel.uiState.skeets.filter {
48
48
-
!it.replyToNotFollowing
49
49
-
}.forEach { skeet ->
50
50
-
item(key = skeet.key()) {
51
51
-
ElevatedCard(
52
52
-
elevation = CardDefaults.elevatedCardElevation(
53
53
-
defaultElevation = 0.dp
54
54
-
)
55
55
-
) {
56
56
-
val isRepost = when (skeet.reason) {
57
57
-
is FeedViewPostReasonUnion.ReasonRepost -> true
58
58
-
else -> false
59
59
-
}
51
51
+
items(
52
52
+
items = data.filter { !it.replyToNotFollowing },
53
53
+
key = { it.key() }
54
54
+
) { skeet ->
55
55
+
Card {
56
56
+
val isRepost = when (skeet.reason) {
57
57
+
is FeedViewPostReasonUnion.ReasonRepost -> true
58
58
+
else -> false
59
59
+
}
60
60
61
61
-
val root = skeet.root()
62
62
-
val (parent, parentsParent) = skeet.parent()
61
61
+
val root = skeet.root()
62
62
+
val (parent, parentsParent) = skeet.parent()
63
63
64
64
+
65
65
+
if (!isShowingThread) {
64
66
if (!isRepost) {
65
67
root?.let {
66
68
SkeetView(
···
73
75
74
76
parent?.let {
75
77
if ((parentsParent?.cid != root?.cid) && root?.cid != null) {
76
76
-
ConditionalCard("See more")
78
78
+
ConditionalCard(
79
79
+
text = "See more",
80
80
+
onTap = {
81
81
+
if (onSeeMoreTap != null) {
82
82
+
viewModel.setThread(root)
83
83
+
onSeeMoreTap(root)
84
84
+
}
85
85
+
}
86
86
+
)
77
87
78
88
VerticalDivider(
79
89
thickness = 4.dp,
···
92
102
)
93
103
}
94
104
}
105
105
+
}
95
106
96
107
97
97
-
SkeetView(viewModel = viewModel, skeet = skeet, onReplyTap = onReplyTap)
98
98
-
}
108
108
+
SkeetView(viewModel = viewModel, skeet = skeet, onReplyTap = onReplyTap)
99
109
}
100
110
}
101
111
102
102
-
if (viewModel.uiState.isFetchingMoreTimeline && viewModel.uiState.skeets.isNotEmpty()) {
112
112
+
if (viewModel.uiState.isFetchingMoreTimeline && data.isNotEmpty() && shouldFetchMoreData) {
103
113
item {
104
114
Box(
105
115
modifier = Modifier
···
135
145
}
136
146
137
147
LaunchedEffect(endOfListReached) {
138
138
-
if (endOfListReached && viewModel.uiState.skeets.isNotEmpty()) {
148
148
+
if (endOfListReached && viewModel.uiState.skeets.isNotEmpty() && shouldFetchMoreData) {
139
149
viewModel.fetchTimeline()
140
150
}
141
151
}
+2
-2
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
···
71
71
inThread: Boolean = false,
72
72
) {
73
73
if (skeet.blocked) {
74
74
-
ConditionalCard("Blocked :(", wrapWithCard = !nested)
74
74
+
ConditionalCard(text = "Blocked :(", wrapWithCard = !nested)
75
75
return
76
76
}
77
77
78
78
if (skeet.notFound) {
79
79
-
ConditionalCard("Post not found", wrapWithCard = !nested)
79
79
+
ConditionalCard(text = "Post not found", wrapWithCard = !nested)
80
80
return
81
81
}
82
82
+84
app/src/main/java/industries/geesawra/monarch/ThreadView.kt
···
1
1
+
package industries.geesawra.monarch
2
2
+
3
3
+
import android.util.Log
4
4
+
import androidx.compose.foundation.layout.fillMaxSize
5
5
+
import androidx.compose.foundation.layout.padding
6
6
+
import androidx.compose.material3.ExperimentalMaterial3Api
7
7
+
import androidx.compose.material3.MaterialTheme
8
8
+
import androidx.compose.material3.Scaffold
9
9
+
import androidx.compose.material3.Text
10
10
+
import androidx.compose.material3.TopAppBar
11
11
+
import androidx.compose.material3.TopAppBarColors
12
12
+
import androidx.compose.material3.TopAppBarDefaults
13
13
+
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
14
14
+
import androidx.compose.material3.rememberTopAppBarState
15
15
+
import androidx.compose.runtime.Composable
16
16
+
import androidx.compose.runtime.LaunchedEffect
17
17
+
import androidx.compose.runtime.mutableStateOf
18
18
+
import androidx.compose.runtime.remember
19
19
+
import androidx.compose.ui.Modifier
20
20
+
import androidx.compose.ui.input.nestedscroll.nestedScroll
21
21
+
import industries.geesawra.monarch.datalayer.TimelineViewModel
22
22
+
import kotlinx.coroutines.CoroutineScope
23
23
+
24
24
+
@OptIn(ExperimentalMaterial3Api::class)
25
25
+
@Composable
26
26
+
fun ThreadView(
27
27
+
modifier: Modifier = Modifier,
28
28
+
timelineViewModel: TimelineViewModel,
29
29
+
coroutineScope: CoroutineScope
30
30
+
) {
31
31
+
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
32
32
+
rememberTopAppBarState()
33
33
+
)
34
34
+
35
35
+
val isRefreshing = remember { mutableStateOf(true) }
36
36
+
37
37
+
PullToRefreshBox(
38
38
+
modifier = modifier,
39
39
+
isRefreshing = isRefreshing.value,
40
40
+
onRefresh = {},
41
41
+
) {
42
42
+
Scaffold(
43
43
+
containerColor = MaterialTheme.colorScheme.background,
44
44
+
modifier = modifier
45
45
+
.fillMaxSize()
46
46
+
.nestedScroll(scrollBehavior.nestedScrollConnection),
47
47
+
topBar = {
48
48
+
TopAppBar(
49
49
+
colors = TopAppBarColors(
50
50
+
containerColor = MaterialTheme.colorScheme.background,
51
51
+
scrolledContainerColor = MaterialTheme.colorScheme.background,
52
52
+
navigationIconContentColor = MaterialTheme.colorScheme.onBackground, // Ensuring correct contrast
53
53
+
titleContentColor = MaterialTheme.colorScheme.onBackground,
54
54
+
actionIconContentColor = MaterialTheme.colorScheme.onBackground,
55
55
+
subtitleContentColor = MaterialTheme.colorScheme.onBackground
56
56
+
),
57
57
+
title = {
58
58
+
Text("Thread")
59
59
+
},
60
60
+
scrollBehavior = scrollBehavior,
61
61
+
)
62
62
+
},
63
63
+
) { padding ->
64
64
+
LaunchedEffect(Unit) {
65
65
+
timelineViewModel.getThread {
66
66
+
Log.d("ThreadView", "Thread retrieved")
67
67
+
isRefreshing.value = false
68
68
+
}
69
69
+
}
70
70
+
71
71
+
if (timelineViewModel.uiState.currentlyShownThread.size != 1) {
72
72
+
ShowSkeets(
73
73
+
modifier = Modifier.padding(padding),
74
74
+
viewModel = timelineViewModel,
75
75
+
isScrollEnabled = true,
76
76
+
data = timelineViewModel.uiState.currentlyShownThread,
77
77
+
shouldFetchMoreData = false,
78
78
+
isShowingThread = true,
79
79
+
)
80
80
+
}
81
81
+
}
82
82
+
}
83
83
+
}
84
84
+
+21
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
···
26
26
import app.bsky.feed.GetFeedGeneratorsQueryParams
27
27
import app.bsky.feed.GetFeedQueryParams
28
28
import app.bsky.feed.GetFeedResponse
29
29
+
import app.bsky.feed.GetPostThreadQueryParams
30
30
+
import app.bsky.feed.GetPostThreadResponse
29
31
import app.bsky.feed.GetPostsQueryParams
30
32
import app.bsky.feed.GetPostsResponse
31
33
import app.bsky.feed.GetTimelineQueryParams
···
982
984
983
985
suspend fun deleteRepost(rKey: RKey): Result<Unit> {
984
986
return deleteRecord(rKey, "app.bsky.feed.repost")
987
987
+
}
988
988
+
989
989
+
suspend fun getThread(uri: AtUri): Result<GetPostThreadResponse> {
990
990
+
return runCatching {
991
991
+
create().onFailure {
992
992
+
return Result.failure(LoginException(it.message))
993
993
+
}
994
994
+
995
995
+
val res = client!!.getPostThread(
996
996
+
GetPostThreadQueryParams(
997
997
+
uri = uri,
998
998
+
)
999
999
+
)
1000
1000
+
1001
1001
+
return when (res) {
1002
1002
+
is AtpResponse.Failure<*> -> Result.failure(Exception("Could not get thread: ${res.error?.message}"))
1003
1003
+
is AtpResponse.Success<GetPostThreadResponse> -> Result.success(res.response)
1004
1004
+
}
1005
1005
+
}
985
1006
}
986
1007
987
1008
private suspend fun deleteRecord(rKey: RKey, collection: String): Result<Unit> {
+66
-1
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
···
12
12
import app.bsky.actor.ProfileView
13
13
import app.bsky.actor.ProfileViewDetailed
14
14
import app.bsky.feed.GeneratorView
15
15
+
import app.bsky.feed.GetPostThreadResponseThreadUnion
15
16
import app.bsky.feed.Like
16
17
import app.bsky.feed.Post
17
18
import app.bsky.feed.PostEmbedUnion
18
19
import app.bsky.feed.PostReplyRef
19
20
import app.bsky.feed.Repost
21
21
+
import app.bsky.feed.ThreadViewPostReplieUnion
20
22
import app.bsky.graph.Follow
21
23
import app.bsky.notification.ListNotificationsReason
22
24
import com.atproto.repo.StrongRef
···
53
55
val notificationsCursor: String? = null,
54
56
55
57
val cidInteractedWith: Map<Cid, RKey> = mapOf(),
58
58
+
59
59
+
val currentlyShownThread: List<SkeetData> = listOf(),
56
60
57
61
val loginError: String? = null,
58
62
val error: String? = null,
···
195
199
isFetchingMoreNotifications = false,
196
200
error = "Failed to fetch notifications: ${it.message}"
197
201
)
198
198
-
}.getOrThrow()
202
202
+
}.getOrNull()
203
203
+
204
204
+
if (rawNotifs == null) {
205
205
+
return@launch
206
206
+
}
199
207
200
208
val repeatable = mutableListOf<Notification>()
201
209
···
491
499
)
492
500
then()
493
501
}
502
502
+
}
503
503
+
}
504
504
+
505
505
+
fun setThread(tappedElement: SkeetData) {
506
506
+
uiState = uiState.copy(currentlyShownThread = listOf(tappedElement))
507
507
+
}
508
508
+
509
509
+
fun getThread(then: () -> Unit) {
510
510
+
viewModelScope.launch {
511
511
+
bskyConn.getThread(uiState.currentlyShownThread.first().uri).onFailure {
512
512
+
uiState = when (it) {
513
513
+
is LoginException -> uiState.copy(loginError = it.message)
514
514
+
else -> uiState.copy(error = it.message)
515
515
+
}
516
516
+
}.onSuccess {
517
517
+
uiState = uiState.copy(
518
518
+
currentlyShownThread = uiState.currentlyShownThread + run {
519
519
+
when (it.thread) {
520
520
+
is GetPostThreadResponseThreadUnion.BlockedPost -> listOf(
521
521
+
SkeetData(
522
522
+
blocked = true
523
523
+
)
524
524
+
)
525
525
+
526
526
+
is GetPostThreadResponseThreadUnion.NotFoundPost -> listOf(
527
527
+
SkeetData(
528
528
+
notFound = true
529
529
+
)
530
530
+
)
531
531
+
532
532
+
is GetPostThreadResponseThreadUnion.ThreadViewPost -> (it.thread as GetPostThreadResponseThreadUnion.ThreadViewPost).value.replies.mapNotNull { r ->
533
533
+
when (r) {
534
534
+
is ThreadViewPostReplieUnion.BlockedPost -> SkeetData(
535
535
+
blocked = true
536
536
+
)
537
537
+
538
538
+
is ThreadViewPostReplieUnion.NotFoundPost -> SkeetData(
539
539
+
notFound = true
540
540
+
)
541
541
+
542
542
+
is ThreadViewPostReplieUnion.ThreadViewPost -> SkeetData.fromPostView(
543
543
+
r.value.post,
544
544
+
r.value.post.author
545
545
+
)
546
546
+
547
547
+
is ThreadViewPostReplieUnion.Unknown -> null
548
548
+
}
549
549
+
}
550
550
+
551
551
+
is GetPostThreadResponseThreadUnion.Unknown -> listOf()
552
552
+
}
553
553
+
}
554
554
+
)
555
555
+
556
556
+
then()
557
557
+
}
558
558
+
494
559
}
495
560
}
496
561
}