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.

Merge branch 'worktree-typed-snacking-peach'

geesawra 0131af56 ce57935b

+216 -12
+160 -9
app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 43 43 import androidx.compose.material.icons.automirrored.filled.Send 44 44 import androidx.compose.material.icons.filled.CameraRoll 45 45 import androidx.compose.material.icons.filled.Close 46 + import androidx.compose.material.icons.filled.Shield 46 47 import androidx.compose.material3.BottomSheetScaffoldState 47 48 import androidx.compose.material3.Button 48 49 import androidx.compose.material3.ButtonDefaults 49 50 import androidx.compose.material3.Card 51 + import androidx.compose.material3.Checkbox 50 52 import androidx.compose.material3.CircularWavyProgressIndicator 53 + import androidx.compose.material3.ListItem 54 + import androidx.compose.material3.LocalContentColor 55 + import androidx.compose.material3.ModalBottomSheet 56 + import androidx.compose.material3.rememberModalBottomSheetState 57 + import androidx.compose.material3.SegmentedButton 58 + import androidx.compose.material3.SegmentedButtonDefaults 59 + import androidx.compose.material3.SingleChoiceSegmentedButtonRow 51 60 import androidx.compose.material3.ExperimentalMaterial3Api 52 61 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 53 62 import androidx.compose.material3.Icon ··· 58 67 import androidx.compose.material3.Text 59 68 import androidx.compose.material3.TextButton 60 69 import androidx.compose.runtime.Composable 70 + import androidx.compose.runtime.getValue 71 + import androidx.compose.runtime.setValue 61 72 import androidx.compose.runtime.LaunchedEffect 62 73 import androidx.compose.runtime.MutableState 63 74 import androidx.compose.runtime.mutableIntStateOf ··· 82 93 import app.bsky.richtext.FacetLink 83 94 import app.bsky.richtext.FacetTag 84 95 import com.atproto.repo.StrongRef 96 + import app.bsky.feed.ThreadgateAllowUnion 97 + import app.bsky.feed.ThreadgateFollowerRule 98 + import app.bsky.feed.ThreadgateFollowingRule 99 + import app.bsky.feed.ThreadgateMentionRule 85 100 import industries.geesawra.monarch.datalayer.AvatarShape 86 101 import industries.geesawra.monarch.datalayer.SettingsState 87 102 import industries.geesawra.monarch.datalayer.SkeetData ··· 134 149 val facets = remember { mutableListOf<Facet>() } 135 150 val mediaSelected = remember { mutableStateOf(listOf<Uri>()) } 136 151 val mediaSelectedIsVideo = remember { mutableStateOf(false) } 152 + val threadgateRules = remember { mutableStateOf<List<ThreadgateAllowUnion>?>(null) } 137 153 val mentionResults = remember { mutableStateOf(listOf<ProfileViewBasic>()) } 138 154 val showMentionDropdown = remember { mutableStateOf(false) } 139 155 val mentionDids = remember { mutableStateMapOf<String, Did>() } ··· 634 650 inReplyTo.value, 635 651 isQuotePost.value, 636 652 facets = facets, 637 - linkPreview = if (!linkPreviewDismissed.value) linkPreview.value else null 653 + linkPreview = if (!linkPreviewDismissed.value) linkPreview.value else null, 654 + threadgateRules = threadgateRules, 638 655 ) 639 656 640 657 Spacer(modifier = Modifier.height(8.dp)) ··· 698 715 inReplyToData: SkeetData? = null, 699 716 isQuotePost: Boolean = false, 700 717 facets: List<Facet> = listOf(), 701 - linkPreview: LinkPreviewData? = null 718 + linkPreview: LinkPreviewData? = null, 719 + threadgateRules: MutableState<List<ThreadgateAllowUnion>?>, 702 720 ) { 721 + var showThreadgateSheet by remember { mutableStateOf(false) } 722 + 723 + if (showThreadgateSheet) { 724 + ThreadgateSheet( 725 + currentRules = threadgateRules.value, 726 + onDismiss = { showThreadgateSheet = false }, 727 + onApply = { rules -> 728 + threadgateRules.value = rules 729 + showThreadgateSheet = false 730 + } 731 + ) 732 + } 733 + 703 734 Row( 704 735 modifier = Modifier 705 736 .fillMaxWidth() 706 737 .padding( 707 738 vertical = 8.dp, 708 - ), // Internal padding for the button row 739 + ), 709 740 horizontalArrangement = Arrangement.SpaceBetween, 710 741 verticalAlignment = Alignment.CenterVertically 711 742 ) { 712 - TextButton( 713 - onClick = { 714 - pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) 743 + Row { 744 + TextButton( 745 + onClick = { 746 + pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) 747 + } 748 + ) { 749 + Icon(Icons.Default.CameraRoll, contentDescription = "Attach media") 715 750 } 716 - ) { 717 - Icon(Icons.Default.CameraRoll, contentDescription = "Attach media") 751 + TextButton(onClick = { showThreadgateSheet = true }) { 752 + Icon( 753 + Icons.Default.Shield, 754 + contentDescription = "Reply settings", 755 + tint = if (threadgateRules.value != null) MaterialTheme.colorScheme.primary else LocalContentColor.current 756 + ) 757 + } 718 758 } 719 759 720 760 val postButtonEnabled = remember(postText, mediaSelected.value) { ··· 751 791 } else { 752 792 null 753 793 }, 754 - linkPreview = linkPreview 794 + linkPreview = linkPreview, 795 + threadgateRules = threadgateRules.value, 755 796 ).onSuccess { 756 797 coroutineScope.launch { 757 798 scaffoldState.bottomSheetState.hide() ··· 858 899 } 859 900 860 901 return facets 902 + } 903 + 904 + @OptIn(ExperimentalMaterial3Api::class) 905 + @Composable 906 + private fun ThreadgateSheet( 907 + currentRules: List<ThreadgateAllowUnion>?, 908 + onDismiss: () -> Unit, 909 + onApply: (List<ThreadgateAllowUnion>?) -> Unit, 910 + ) { 911 + var nobody by remember { mutableStateOf(currentRules != null && currentRules.isEmpty()) } 912 + var followers by remember { 913 + mutableStateOf(currentRules?.any { it is ThreadgateAllowUnion.FollowerRule } == true) 914 + } 915 + var following by remember { 916 + mutableStateOf(currentRules?.any { it is ThreadgateAllowUnion.FollowingRule } == true) 917 + } 918 + var mentions by remember { 919 + mutableStateOf(currentRules?.any { it is ThreadgateAllowUnion.MentionRule } == true) 920 + } 921 + 922 + ModalBottomSheet( 923 + onDismissRequest = onDismiss, 924 + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), 925 + ) { 926 + Column( 927 + modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 32.dp) 928 + ) { 929 + Text( 930 + text = "Who can reply", 931 + style = MaterialTheme.typography.titleLarge, 932 + fontWeight = FontWeight.Bold, 933 + modifier = Modifier.padding(bottom = 16.dp) 934 + ) 935 + 936 + SingleChoiceSegmentedButtonRow( 937 + modifier = Modifier 938 + .fillMaxWidth() 939 + .padding(bottom = 16.dp) 940 + ) { 941 + SegmentedButton( 942 + selected = !nobody, 943 + onClick = { nobody = false }, 944 + shape = SegmentedButtonDefaults.itemShape(0, 2), 945 + ) { Text("Anyone") } 946 + SegmentedButton( 947 + selected = nobody, 948 + onClick = { 949 + nobody = true 950 + followers = false 951 + following = false 952 + mentions = false 953 + }, 954 + shape = SegmentedButtonDefaults.itemShape(1, 2), 955 + ) { Text("Nobody") } 956 + } 957 + 958 + ListItem( 959 + headlineContent = { Text("Your followers") }, 960 + leadingContent = { 961 + Checkbox( 962 + checked = followers, 963 + onCheckedChange = { followers = it }, 964 + enabled = !nobody, 965 + ) 966 + }, 967 + modifier = Modifier.clickable(enabled = !nobody) { followers = !followers } 968 + ) 969 + ListItem( 970 + headlineContent = { Text("People you follow") }, 971 + leadingContent = { 972 + Checkbox( 973 + checked = following, 974 + onCheckedChange = { following = it }, 975 + enabled = !nobody, 976 + ) 977 + }, 978 + modifier = Modifier.clickable(enabled = !nobody) { following = !following } 979 + ) 980 + ListItem( 981 + headlineContent = { Text("People you mention") }, 982 + leadingContent = { 983 + Checkbox( 984 + checked = mentions, 985 + onCheckedChange = { mentions = it }, 986 + enabled = !nobody, 987 + ) 988 + }, 989 + modifier = Modifier.clickable(enabled = !nobody) { mentions = !mentions } 990 + ) 991 + 992 + Spacer(modifier = Modifier.height(16.dp)) 993 + 994 + Button( 995 + onClick = { 996 + if (nobody) { 997 + onApply(emptyList()) 998 + } else if (!followers && !following && !mentions) { 999 + onApply(null) 1000 + } else { 1001 + val rules = mutableListOf<ThreadgateAllowUnion>() 1002 + if (followers) rules += ThreadgateAllowUnion.FollowerRule(ThreadgateFollowerRule) 1003 + if (following) rules += ThreadgateAllowUnion.FollowingRule(ThreadgateFollowingRule) 1004 + if (mentions) rules += ThreadgateAllowUnion.MentionRule(ThreadgateMentionRule) 1005 + onApply(rules) 1006 + } 1007 + }, 1008 + modifier = Modifier.fillMaxWidth() 1009 + ) { Text("Done") } 1010 + } 1011 + } 861 1012 }
+20
app/src/main/java/industries/geesawra/monarch/SkeetView.kt
··· 820 820 style = MaterialTheme.typography.labelMedium, 821 821 ) 822 822 823 + if (skeet.threadgate != null) { 824 + Row( 825 + verticalAlignment = Alignment.CenterVertically, 826 + modifier = Modifier.padding(top = 2.dp) 827 + ) { 828 + Icon( 829 + imageVector = Icons.Filled.Shield, 830 + contentDescription = null, 831 + modifier = Modifier.size(12.dp), 832 + tint = MaterialTheme.colorScheme.outline, 833 + ) 834 + Spacer(modifier = Modifier.width(4.dp)) 835 + Text( 836 + text = "Replies limited", 837 + style = MaterialTheme.typography.labelSmall, 838 + color = MaterialTheme.colorScheme.outline, 839 + ) 840 + } 841 + } 842 + 823 843 if (showLabels) { 824 844 CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { 825 845 FlowRow(
+27 -1
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 46 46 import app.bsky.feed.PostReplyRef 47 47 import app.bsky.feed.PostView 48 48 import app.bsky.feed.Repost 49 + import app.bsky.feed.Threadgate 50 + import app.bsky.feed.ThreadgateAllowUnion 49 51 import app.bsky.labeler.GetServicesQueryParams 50 52 import app.bsky.labeler.GetServicesResponse 51 53 import app.bsky.labeler.GetServicesResponseViewUnion ··· 693 695 quotePostRef: StrongRef? = null, 694 696 facets: List<Facet> = listOf(), 695 697 linkPreview: LinkPreviewData? = null, 698 + threadgateRules: List<ThreadgateAllowUnion>? = null, 696 699 ): Result<Unit> { 697 700 return runCatching { 698 701 create().onFailure { ··· 774 777 ) 775 778 return when (postRes) { 776 779 is AtpResponse.Failure<*> -> Result.failure(Exception("Could not create post: ${postRes.error?.message}")) 777 - is AtpResponse.Success<*> -> Result.success(Unit) 780 + is AtpResponse.Success<CreateRecordResponse> -> { 781 + if (threadgateRules != null) { 782 + createThreadgate(postRes.response.uri, threadgateRules) 783 + } 784 + Result.success(Unit) 785 + } 778 786 } 779 787 } 788 + } 789 + 790 + private suspend fun createThreadgate(postUri: AtUri, rules: List<ThreadgateAllowUnion>) { 791 + val record = BlueskyJson.encodeAsJsonContent( 792 + Threadgate( 793 + post = postUri, 794 + allow = rules, 795 + createdAt = Clock.System.now().toDeprecatedInstant(), 796 + ) 797 + ) 798 + pdsClient!!.createRecord( 799 + CreateRecordRequest( 800 + repo = session!!.handle, 801 + collection = Nsid("app.bsky.feed.threadgate"), 802 + record = record, 803 + rkey = postUri.rkey(), 804 + ) 805 + ) 780 806 } 781 807 782 808 suspend fun fetchRecord(uri: AtUri): Result<JsonContent> {
+4
app/src/main/java/industries/geesawra/monarch/datalayer/Models.kt
··· 31 31 import app.bsky.feed.ReplyRef 32 32 import app.bsky.feed.ReplyRefParentUnion 33 33 import app.bsky.feed.ReplyRefRootUnion 34 + import app.bsky.feed.ThreadgateView 34 35 import app.bsky.richtext.Facet 35 36 import app.bsky.richtext.FacetFeatureUnion 36 37 import com.atproto.label.Label ··· 109 110 val createdAt: Instant? = null, 110 111 val facets: List<Facet> = listOf(), 111 112 val postLabels: List<Label> = listOf(), 113 + val threadgate: ThreadgateView? = null, 112 114 val blocked: Boolean = false, 113 115 val notFound: Boolean = false, 114 116 val following: Boolean = false, ··· 132 134 authorHandle = post.post.author.handle, 133 135 authorLabels = post.post.author.labels, 134 136 postLabels = post.post.labels, 137 + threadgate = post.post.threadgate, 135 138 verified = post.post.author.verification?.verifiedStatus == VerifiedStatus.Valid, 136 139 content = content.text, 137 140 embed = post.post.embed, ··· 237 240 authorHandle = post.author.handle, 238 241 authorLabels = post.author.labels, 239 242 postLabels = post.labels, 243 + threadgate = post.threadgate, 240 244 verified = post.author.verification?.verifiedStatus == VerifiedStatus.Valid, 241 245 content = content.text, 242 246 facets = content.facets,
+5 -2
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 25 25 import app.bsky.feed.PostReplyRef 26 26 import app.bsky.feed.PostViewEmbedUnion 27 27 import app.bsky.feed.Repost 28 + import app.bsky.feed.ThreadgateAllowUnion 28 29 import app.bsky.feed.ThreadViewPostReplieUnion 29 30 import app.bsky.graph.Follow 30 31 import app.bsky.notification.ListNotificationsReason ··· 669 670 quotePostRef: StrongRef? = null, 670 671 facets: List<Facet> = listOf(), 671 672 linkPreview: LinkPreviewData? = null, 673 + threadgateRules: List<ThreadgateAllowUnion>? = null, 672 674 ): Result<Unit> { 673 675 return bskyConn.post( 674 676 content, ··· 677 679 replyRef, 678 680 quotePostRef, 679 681 facets, 680 - linkPreview = linkPreview 681 - ) // TODO: maybe refactor this to use uistate.Error? 682 + linkPreview = linkPreview, 683 + threadgateRules = threadgateRules, 684 + ) 682 685 } 683 686 684 687 fun feeds(): Job {