A cheap attempt at a native Bluesky client for Android

ComposeView: reintroduce support for rich-text clipboard

Refactoring to facet support reverted that change. Bring it back, with pain.

+105 -78
+105 -78
app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 33 33 import androidx.compose.foundation.layout.size 34 34 import androidx.compose.foundation.layout.windowInsetsPadding 35 35 import androidx.compose.foundation.text.KeyboardOptions 36 + import androidx.compose.foundation.text.input.OutputTransformation 37 + import androidx.compose.foundation.text.input.TextFieldBuffer 38 + import androidx.compose.foundation.text.input.TextFieldLineLimits 39 + import androidx.compose.foundation.text.input.clearText 40 + import androidx.compose.foundation.text.input.rememberTextFieldState 36 41 import androidx.compose.foundation.verticalScroll 37 42 import androidx.compose.material.icons.Icons 38 43 import androidx.compose.material.icons.automirrored.filled.Send ··· 65 70 import androidx.compose.ui.graphics.Color 66 71 import androidx.compose.ui.platform.LocalFocusManager 67 72 import androidx.compose.ui.platform.LocalSoftwareKeyboardController 68 - import androidx.compose.ui.text.AnnotatedString 69 73 import androidx.compose.ui.text.SpanStyle 70 - import androidx.compose.ui.text.buildAnnotatedString 71 74 import androidx.compose.ui.text.input.KeyboardCapitalization 72 75 import androidx.compose.ui.text.input.KeyboardType 73 - import androidx.compose.ui.text.input.TextFieldValue 74 - import androidx.compose.ui.text.withStyle 75 76 import androidx.compose.ui.unit.dp 76 77 import app.bsky.richtext.Facet 77 78 import app.bsky.richtext.FacetByteSlice ··· 101 102 val charCount = remember { mutableIntStateOf(0) } 102 103 val wasEdited = remember { mutableStateOf(false) } 103 104 val maxChars = 300 104 - val composeFieldState = remember { mutableStateOf(TextFieldValue("")) } 105 + val textfieldState = rememberTextFieldState() 105 106 val facets = remember { mutableListOf<Facet>() } 106 107 val mediaSelected = remember { mutableStateOf(listOf<Uri>()) } 107 108 val mediaSelectedIsVideo = remember { mutableStateOf(false) } ··· 109 110 LaunchedEffect(scaffoldState.bottomSheetState.targetValue) { 110 111 when (scaffoldState.bottomSheetState.targetValue) { 111 112 SheetValue.Hidden -> { 112 - composeFieldState.value = TextFieldValue("") 113 + textfieldState.clearText() 113 114 keyboardController?.hide() 114 115 focusManager.clearFocus() 115 116 charCount.intValue = 0 ··· 229 230 } 230 231 } 231 232 232 - LaunchedEffect(composeFieldState.value.text) { 233 - if (composeFieldState.value.text.isEmpty()) { 233 + LaunchedEffect(textfieldState.text) { 234 + if (textfieldState.text.isEmpty()) { 234 235 wasEdited.value = false 235 236 } else { 236 237 wasEdited.value = true 237 - charCount.intValue = composeFieldState.value.text.length 238 + charCount.intValue = textfieldState.text.length 238 239 } 239 240 } 240 241 241 242 val urlColor = MaterialTheme.colorScheme.primary 242 243 244 + class FacetTextTransform() : OutputTransformation { 245 + override fun TextFieldBuffer.transformOutput() { 246 + val a = readFacets(originalText.toString()) 247 + facets.clear() 248 + facets.addAll(a) 249 + 250 + facets.forEach { 251 + addStyle( 252 + SpanStyle(color = urlColor), 253 + it.index.byteStart.toInt(), 254 + it.index.byteEnd.toInt() 255 + ) 256 + } 257 + } 258 + } 259 + 243 260 OutlinedTextField( 244 261 modifier = Modifier 245 262 .fillMaxWidth() 246 263 .heightIn(min = 250.dp) 247 264 .focusRequester(focusRequester) 248 265 .contentReceiver(receiveContentListener), 249 - value = composeFieldState.value, 250 - onValueChange = { 251 - val a = annotated(it.text, urlColor) 252 - facets.clear() 253 - facets.addAll(a.facets) 254 - composeFieldState.value = 255 - it.copy(annotatedString = a.annotated) 256 - }, 257 266 keyboardOptions = KeyboardOptions( 258 267 capitalization = KeyboardCapitalization.Sentences, 259 - keyboardType = KeyboardType.Text, 268 + autoCorrectEnabled = true, 269 + keyboardType = KeyboardType.Email, 260 270 ), 261 271 label = { 262 272 if (wasEdited.value) { 263 273 Text( 264 274 text = "${maxChars - charCount.intValue}", 265 - color = if (composeFieldState.value.text.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 275 + color = if (textfieldState.text.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 266 276 ) 267 277 } else { 268 278 Text( ··· 270 280 ) 271 281 } 272 282 }, 273 - isError = composeFieldState.value.text.length > maxChars, 274 - maxLines = 10, 283 + isError = textfieldState.text.length > maxChars, 284 + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10), 285 + state = textfieldState, 286 + outputTransformation = FacetTextTransform(), 275 287 ) 276 288 289 + // OutlinedTextField( 290 + // modifier = Modifier 291 + // .fillMaxWidth() 292 + // .heightIn(min = 250.dp) 293 + // .focusRequester(focusRequester) 294 + // .contentReceiver(receiveContentListener), 295 + // value = composeFieldState.value, 296 + // onValueChange = { 297 + // val a = annotated(it.text, urlColor) 298 + // facets.clear() 299 + // facets.addAll(a.facets) 300 + // composeFieldState.value = 301 + // it.copy(annotatedString = a.annotated) 302 + // }, 303 + // keyboardOptions = KeyboardOptions( 304 + // capitalization = KeyboardCapitalization.Sentences, 305 + // keyboardType = KeyboardType.Text, 306 + // ), 307 + // label = { 308 + // if (wasEdited.value) { 309 + // Text( 310 + // text = "${maxChars - charCount.intValue}", 311 + // color = if (composeFieldState.value.text.length > maxChars) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface 312 + // ) 313 + // } else { 314 + // Text( 315 + // text = "Less cringe this time, okay?", 316 + // ) 317 + // } 318 + // }, 319 + // isError = composeFieldState.value.text.length > maxChars, 320 + // maxLines = 10, 321 + // ) 322 + 277 323 ActionRow( 278 324 context, 279 325 uploadingPost, 280 326 pickMedia, 281 - composeFieldState.value.text, 327 + textfieldState.text.toString(), 282 328 mediaSelected, 283 329 mediaSelectedIsVideo, 284 330 coroutineScope, ··· 430 476 } 431 477 } 432 478 433 - private data class FacetString( 434 - val annotated: AnnotatedString, 435 - val facets: List<Facet> 436 - ) 479 + val tokensRegexp = Regex("(\\S+)") 480 + 437 481 438 - private fun annotated(data: String, urlColor: Color): FacetString { 482 + private fun readFacets(data: String): List<Facet> { 439 483 val facets = mutableListOf<Facet>() 440 - val annotatedString = buildAnnotatedString { 441 - val tokens = Regex("(\\S+)").findAll(data) 442 - var lastIndex = 0 443 484 444 - for (token in tokens) { 445 - if (token.range.first > lastIndex) { 446 - append(data.substring(lastIndex, token.range.first)) 447 - } 448 - 449 - val s = token.value 450 - val startByte = 451 - data.substring(0, token.range.first).encodeToByteArray().size 452 - val endByte = 453 - data.substring(0, token.range.last + 1).encodeToByteArray().size 485 + for (token in tokensRegexp.findAll(data)) { 486 + val s = token.value 487 + val startByte = 488 + data.substring(0, token.range.first).encodeToByteArray().size 489 + val endByte = 490 + data.substring(0, token.range.last + 1).encodeToByteArray().size 454 491 455 - if (URLUtil.isHttpUrl(s) || URLUtil.isHttpsUrl(s)) { 456 - withStyle(SpanStyle(color = urlColor)) { append(s) } 492 + if (URLUtil.isHttpUrl(s) || URLUtil.isHttpsUrl(s)) { 493 + facets.add( 494 + Facet( 495 + index = FacetByteSlice(startByte.toLong(), endByte.toLong()), 496 + features = listOf( 497 + FacetFeatureUnion.Link( 498 + value = FacetLink( 499 + uri = sh.christian.ozone.api.Uri(s) 500 + ) 501 + ) 502 + ) 503 + ) 504 + ) 505 + } else if (s.startsWith("#") && s.length > 1) { 506 + val tag = s.substring(1) 507 + if (tag.isNotEmpty() && !tag.contains(" ") && tag.length <= 64) { 457 508 facets.add( 458 509 Facet( 459 - index = FacetByteSlice(startByte.toLong(), endByte.toLong()), 510 + index = FacetByteSlice( 511 + startByte.toLong(), 512 + endByte.toLong() 513 + ), 460 514 features = listOf( 461 - FacetFeatureUnion.Link( 462 - value = FacetLink( 463 - uri = sh.christian.ozone.api.Uri(s) 515 + FacetFeatureUnion.Tag( 516 + value = FacetTag( 517 + tag = s.removePrefix("#"), 464 518 ) 465 519 ) 466 520 ) 467 521 ) 468 522 ) 469 - } else if (s.startsWith("#") && s.length > 1) { 470 - withStyle(SpanStyle(color = urlColor)) { append(s) } 471 - val tag = s.substring(1) 472 - if (tag.isNotEmpty() && !tag.contains(" ") && tag.length <= 64) { 473 - facets.add( 474 - Facet( 475 - index = FacetByteSlice( 476 - startByte.toLong(), 477 - endByte.toLong() 478 - ), 479 - features = listOf( 480 - FacetFeatureUnion.Tag( 481 - value = FacetTag( 482 - tag = s.removePrefix("#"), 483 - ) 484 - ) 485 - ) 486 - ) 487 - ) 488 - } 489 - } else if (s.startsWith("@") && s.length > 1) { 490 - withStyle(SpanStyle(color = urlColor)) { append(s) } 491 - // TODO: mentions go here, need DID resolution 523 + } 524 + } else if (s.startsWith("@") && s.length > 1) { 525 + // TODO: mentions go here, need DID resolution 492 526 // val tag = s.substring(1) 493 527 // if (tag.isNotEmpty() && !tag.contains(" ") && tag.length <= 64) { 494 528 // facets.add( ··· 507 541 // ) 508 542 // ) 509 543 // } 510 - } else { 511 - append(s) 512 - } 513 - lastIndex = token.range.last + 1 514 544 } 545 + } 515 546 516 - if (lastIndex < data.length) { 517 - append(data.substring(lastIndex)) 518 - } 519 - } 520 - return FacetString(annotated = annotatedString, facets = facets) 547 + return facets 521 548 }