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.

ComposeView: Add @mention typeahead and link preview cards

- @mention typeahead with searchActorsTypeahead API, DID resolution,
dropdown UI, and mention facet creation
- Link preview cards with OG metadata fetching, preview UI with
thumbnail/title/description, and External embed on post
- Stable OutputTransformation with char-based indices for highlighting
links, hashtags, and resolved mentions

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

geesawra 713d3218 b2188f74

+467 -38
+316 -37
app/src/main/java/industries/geesawra/monarch/ComposeView.kt
··· 71 71 import androidx.compose.ui.platform.LocalFocusManager 72 72 import androidx.compose.ui.platform.LocalSoftwareKeyboardController 73 73 import androidx.compose.ui.text.SpanStyle 74 + import androidx.compose.ui.text.TextRange 74 75 import androidx.compose.ui.text.input.KeyboardCapitalization 75 76 import androidx.compose.ui.text.input.KeyboardType 76 77 import androidx.compose.ui.unit.dp ··· 84 85 import industries.geesawra.monarch.datalayer.TimelineViewModel 85 86 import kotlinx.coroutines.CoroutineScope 86 87 import kotlinx.coroutines.launch 88 + import kotlinx.coroutines.delay 89 + import app.bsky.actor.ProfileViewBasic 90 + import app.bsky.richtext.FacetMention 91 + import sh.christian.ozone.api.Did 92 + import androidx.compose.material3.DropdownMenu 93 + import androidx.compose.material3.DropdownMenuItem 94 + import industries.geesawra.monarch.datalayer.LinkPreviewData 95 + import industries.geesawra.monarch.datalayer.LinkPreviewFetcher 96 + import coil3.compose.AsyncImage 97 + import coil3.request.ImageRequest 98 + import androidx.compose.ui.platform.LocalContext 99 + import androidx.compose.ui.layout.ContentScale 100 + import androidx.compose.material3.IconButton 101 + import androidx.compose.material3.HorizontalDivider 102 + import androidx.compose.ui.text.font.FontWeight 103 + import androidx.compose.ui.text.style.TextOverflow 104 + import java.net.URI 87 105 88 - @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) 106 + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) 89 107 @Composable 90 108 fun ComposeView( 91 109 context: Context, ··· 106 124 val facets = remember { mutableListOf<Facet>() } 107 125 val mediaSelected = remember { mutableStateOf(listOf<Uri>()) } 108 126 val mediaSelectedIsVideo = remember { mutableStateOf(false) } 127 + val mentionResults = remember { mutableStateOf(listOf<ProfileViewBasic>()) } 128 + val showMentionDropdown = remember { mutableStateOf(false) } 129 + val mentionDids = remember { mutableMapOf<String, Did>() } 130 + 131 + val linkPreview = remember { mutableStateOf<LinkPreviewData?>(null) } 132 + val linkPreviewLoading = remember { mutableStateOf(false) } 133 + val linkPreviewDismissed = remember { mutableStateOf(false) } 134 + val linkPreviewCache = remember { mutableMapOf<String, LinkPreviewData?>() } 109 135 110 136 LaunchedEffect(scaffoldState.bottomSheetState.targetValue) { 111 137 when (scaffoldState.bottomSheetState.targetValue) { ··· 118 144 isQuotePost.value = false 119 145 mediaSelected.value = listOf() 120 146 mediaSelectedIsVideo.value = false 147 + mentionResults.value = listOf() 148 + showMentionDropdown.value = false 149 + mentionDids.clear() 150 + linkPreview.value = null 151 + linkPreviewLoading.value = false 152 + linkPreviewDismissed.value = false 153 + linkPreviewCache.clear() 121 154 } 122 155 123 156 SheetValue.PartiallyExpanded, SheetValue.Expanded -> { ··· 239 272 } 240 273 } 241 274 275 + // Mention typeahead: detect @query and search 276 + LaunchedEffect(textfieldState.text, textfieldState.selection) { 277 + val text = textfieldState.text.toString() 278 + val cursorPos = textfieldState.selection.min 279 + 280 + if (cursorPos <= 0 || text.isEmpty()) { 281 + showMentionDropdown.value = false 282 + return@LaunchedEffect 283 + } 284 + 285 + // Find the last @ before cursor that is preceded by whitespace or is at start 286 + val textBeforeCursor = text.substring(0, cursorPos) 287 + val atIndex = textBeforeCursor.lastIndexOf('@') 288 + 289 + if (atIndex < 0) { 290 + showMentionDropdown.value = false 291 + return@LaunchedEffect 292 + } 293 + 294 + // @ must be at start or preceded by whitespace 295 + if (atIndex > 0 && !textBeforeCursor[atIndex - 1].isWhitespace()) { 296 + showMentionDropdown.value = false 297 + return@LaunchedEffect 298 + } 299 + 300 + val query = textBeforeCursor.substring(atIndex + 1) 301 + 302 + // No whitespace allowed in the query part 303 + if (query.contains(' ') || query.isEmpty()) { 304 + showMentionDropdown.value = false 305 + return@LaunchedEffect 306 + } 307 + 308 + delay(300) 309 + 310 + timelineViewModel.searchActorsTypeahead(query) 311 + .onSuccess { 312 + mentionResults.value = it 313 + showMentionDropdown.value = it.isNotEmpty() 314 + } 315 + .onFailure { 316 + showMentionDropdown.value = false 317 + } 318 + } 319 + 320 + // Debounced link preview fetching 321 + LaunchedEffect(textfieldState.text.toString()) { 322 + val text = textfieldState.text.toString() 323 + val firstUrl = tokensRegexp.findAll(text) 324 + .map { it.value } 325 + .firstOrNull { URLUtil.isHttpUrl(it) || URLUtil.isHttpsUrl(it) } 326 + 327 + if (firstUrl == null) { 328 + linkPreview.value = null 329 + linkPreviewLoading.value = false 330 + linkPreviewDismissed.value = false 331 + return@LaunchedEffect 332 + } 333 + 334 + // If user dismissed this URL, do not re-fetch 335 + if (linkPreviewDismissed.value && linkPreview.value == null) { 336 + return@LaunchedEffect 337 + } 338 + 339 + // If already showing preview for same URL, skip 340 + if (linkPreview.value?.url == firstUrl) { 341 + return@LaunchedEffect 342 + } 343 + 344 + // Check cache 345 + if (linkPreviewCache.containsKey(firstUrl)) { 346 + linkPreview.value = linkPreviewCache[firstUrl] 347 + linkPreviewLoading.value = false 348 + return@LaunchedEffect 349 + } 350 + 351 + // Debounce 500ms 352 + delay(500) 353 + 354 + linkPreviewLoading.value = true 355 + val preview = LinkPreviewFetcher.fetch(firstUrl) 356 + linkPreviewCache[firstUrl] = preview 357 + linkPreview.value = preview 358 + linkPreviewLoading.value = false 359 + linkPreviewDismissed.value = false 360 + } 361 + 242 362 val urlColor = MaterialTheme.colorScheme.primary 243 363 244 364 LaunchedEffect(textfieldState.text) { 245 365 val data = textfieldState.text.toString() 246 - val computed = readFacets(data) 366 + val computed = readFacets(data, mentionDids) 247 367 facets.clear() 248 368 facets.addAll(computed) 249 369 } 250 370 251 371 val facetHighlighter = remember { 252 - OutputTransformation { 253 - for (token in tokensRegexp.findAll(originalText)) { 254 - val s = token.value 255 - if (URLUtil.isHttpUrl(s) || URLUtil.isHttpsUrl(s)) { 256 - addStyle( 257 - SpanStyle(color = urlColor), 258 - token.range.first, 259 - token.range.last + 1, 260 - ) 261 - } else if (s.startsWith("#") && s.length > 1) { 262 - val tag = s.substring(1) 263 - if (tag.isNotEmpty() && !tag.contains(" ") && tag.length <= 64) { 372 + object : OutputTransformation { 373 + override fun TextFieldBuffer.transformOutput() { 374 + for (token in tokensRegexp.findAll(originalText)) { 375 + val s = token.value 376 + if (URLUtil.isHttpUrl(s) || URLUtil.isHttpsUrl(s)) { 264 377 addStyle( 265 378 SpanStyle(color = urlColor), 266 379 token.range.first, 267 380 token.range.last + 1, 268 381 ) 382 + } else if (s.startsWith("#") && s.length > 1) { 383 + val tag = s.substring(1) 384 + if (tag.isNotEmpty() && !tag.contains(" ") && tag.length <= 64) { 385 + addStyle( 386 + SpanStyle(color = urlColor), 387 + token.range.first, 388 + token.range.last + 1, 389 + ) 390 + } 391 + } else if (s.startsWith("@") && s.length > 1) { 392 + val handle = s.removePrefix("@") 393 + if (mentionDids.containsKey(handle)) { 394 + addStyle( 395 + SpanStyle(color = urlColor), 396 + token.range.first, 397 + token.range.last + 1, 398 + ) 399 + } 269 400 } 270 401 } 271 402 } ··· 301 432 outputTransformation = facetHighlighter, 302 433 ) 303 434 435 + DropdownMenu( 436 + expanded = showMentionDropdown.value, 437 + onDismissRequest = { showMentionDropdown.value = false }, 438 + ) { 439 + mentionResults.value.forEach { profile -> 440 + DropdownMenuItem( 441 + text = { 442 + Column { 443 + profile.displayName?.let { name -> 444 + Text( 445 + text = name, 446 + style = MaterialTheme.typography.bodyMedium, 447 + ) 448 + } 449 + Text( 450 + text = "@${profile.handle.handle}", 451 + style = MaterialTheme.typography.bodySmall, 452 + color = MaterialTheme.colorScheme.onSurfaceVariant, 453 + ) 454 + } 455 + }, 456 + onClick = { 457 + val text = textfieldState.text.toString() 458 + val cursorPos = textfieldState.selection.min 459 + val textBeforeCursor = text.substring(0, cursorPos) 460 + val atIndex = textBeforeCursor.lastIndexOf('@') 461 + 462 + if (atIndex >= 0) { 463 + val fullHandle = profile.handle.handle 464 + val replacement = "@$fullHandle " 465 + val afterCursor = text.substring(cursorPos) 466 + val newText = text.substring(0, atIndex) + replacement + afterCursor 467 + 468 + textfieldState.edit { 469 + replace(0, length, newText) 470 + val newCursorPos = atIndex + replacement.length 471 + selection = TextRange(newCursorPos, newCursorPos) 472 + } 473 + 474 + mentionDids[fullHandle] = profile.did 475 + } 476 + 477 + showMentionDropdown.value = false 478 + } 479 + ) 480 + } 481 + } 482 + 304 483 // OutlinedTextField( 305 484 // modifier = Modifier 306 485 // .fillMaxWidth() ··· 335 514 // maxLines = 10, 336 515 // ) 337 516 517 + // Link preview card 518 + if (linkPreviewLoading.value) { 519 + OutlinedCard( 520 + modifier = Modifier 521 + .fillMaxWidth() 522 + .padding(vertical = 8.dp) 523 + ) { 524 + Box( 525 + modifier = Modifier 526 + .fillMaxWidth() 527 + .padding(16.dp), 528 + contentAlignment = Alignment.Center 529 + ) { 530 + CircularWavyProgressIndicator() 531 + } 532 + } 533 + } else if (linkPreview.value != null && !linkPreviewDismissed.value) { 534 + val preview = linkPreview.value!! 535 + OutlinedCard( 536 + modifier = Modifier 537 + .fillMaxWidth() 538 + .padding(vertical = 8.dp) 539 + ) { 540 + Box { 541 + Column( 542 + modifier = Modifier.fillMaxWidth() 543 + ) { 544 + preview.imageUrl?.let { imgUrl -> 545 + AsyncImage( 546 + model = ImageRequest.Builder(LocalContext.current) 547 + .data(imgUrl) 548 + .crossfade(true) 549 + .build(), 550 + contentScale = ContentScale.Crop, 551 + contentDescription = "Link preview thumbnail", 552 + modifier = Modifier 553 + .height(150.dp) 554 + .fillMaxWidth() 555 + ) 556 + HorizontalDivider( 557 + color = MaterialTheme.colorScheme.outlineVariant 558 + ) 559 + } 560 + preview.title?.let { title -> 561 + Text( 562 + text = title, 563 + style = MaterialTheme.typography.titleSmall, 564 + fontWeight = FontWeight.Bold, 565 + maxLines = 2, 566 + overflow = TextOverflow.Ellipsis, 567 + modifier = Modifier.padding( 568 + top = 8.dp, start = 8.dp, end = 8.dp, bottom = 4.dp 569 + ) 570 + ) 571 + } 572 + preview.description?.let { desc -> 573 + Text( 574 + text = desc, 575 + style = MaterialTheme.typography.bodySmall, 576 + maxLines = 3, 577 + overflow = TextOverflow.Ellipsis, 578 + modifier = Modifier.padding( 579 + start = 8.dp, end = 8.dp, bottom = 4.dp 580 + ) 581 + ) 582 + } 583 + Text( 584 + text = try { 585 + URI(preview.url).host ?: preview.url 586 + } catch (_: Exception) { 587 + preview.url 588 + }, 589 + style = MaterialTheme.typography.labelSmall, 590 + color = MaterialTheme.colorScheme.onSurfaceVariant, 591 + maxLines = 1, 592 + overflow = TextOverflow.Ellipsis, 593 + modifier = Modifier.padding( 594 + start = 8.dp, end = 8.dp, bottom = 8.dp 595 + ) 596 + ) 597 + } 598 + IconButton( 599 + onClick = { 600 + linkPreview.value = null 601 + linkPreviewDismissed.value = true 602 + }, 603 + modifier = Modifier.align(Alignment.TopEnd) 604 + ) { 605 + Icon( 606 + Icons.Default.Close, 607 + contentDescription = "Dismiss link preview" 608 + ) 609 + } 610 + } 611 + } 612 + } 613 + 338 614 ActionRow( 339 615 context, 340 616 uploadingPost, ··· 348 624 scaffoldState, 349 625 inReplyTo.value, 350 626 isQuotePost.value, 351 - facets = facets 627 + facets = facets, 628 + linkPreview = if (!linkPreviewDismissed.value) linkPreview.value else null 352 629 ) 353 630 354 631 Spacer(modifier = Modifier.height(8.dp)) ··· 411 688 scaffoldState: BottomSheetScaffoldState, 412 689 inReplyToData: SkeetData? = null, 413 690 isQuotePost: Boolean = false, 414 - facets: List<Facet> = listOf() 691 + facets: List<Facet> = listOf(), 692 + linkPreview: LinkPreviewData? = null 415 693 ) { 416 694 Row( 417 695 modifier = Modifier ··· 463 741 } 464 742 } else { 465 743 null 466 - } 744 + }, 745 + linkPreview = linkPreview 467 746 ).onSuccess { 468 747 coroutineScope.launch { 469 748 scaffoldState.bottomSheetState.hide() ··· 493 772 494 773 val tokensRegexp = Regex("(\\S+)") 495 774 496 - private fun readFacets(data: String): List<Facet> { 775 + private fun readFacets(data: String, mentionDids: Map<String, Did> = emptyMap()): List<Facet> { 497 776 val facets = mutableListOf<Facet>() 498 777 499 778 for (token in tokensRegexp.findAll(data)) { ··· 536 815 ) 537 816 } 538 817 } else if (s.startsWith("@") && s.length > 1) { 539 - // TODO: mentions go here, need DID resolution 540 - // val tag = s.substring(1) 541 - // if (tag.isNotEmpty() && !tag.contains(" ") && tag.length <= 64) { 542 - // facets.add( 543 - // Facet( 544 - // index = FacetByteSlice( 545 - // startByte.toLong(), 546 - // endByte.toLong() 547 - // ), 548 - // features = listOf( 549 - // FacetFeatureUnion.Mention( 550 - // value = FacetMention( 551 - // tag = s, 552 - // ) 553 - // ) 554 - // ) 555 - // ) 556 - // ) 557 - // } 818 + val handle = s.substring(1) 819 + val did = mentionDids[handle] 820 + if (did != null) { 821 + facets.add( 822 + Facet( 823 + index = FacetByteSlice( 824 + startByte.toLong(), 825 + endByte.toLong() 826 + ), 827 + features = listOf( 828 + FacetFeatureUnion.Mention( 829 + value = FacetMention( 830 + did = did, 831 + ) 832 + ) 833 + ) 834 + ) 835 + ) 836 + } 558 837 } 559 838 } 560 839
+67
app/src/main/java/industries/geesawra/monarch/datalayer/Bluesky.kt
··· 15 15 import app.bsky.actor.GetProfileQueryParams 16 16 import app.bsky.actor.GetProfileResponse 17 17 import app.bsky.actor.PreferencesUnion 18 + import app.bsky.actor.ProfileViewBasic 18 19 import app.bsky.actor.ProfileViewDetailed 20 + import app.bsky.actor.SearchActorsTypeaheadQueryParams 21 + import app.bsky.actor.SearchActorsTypeaheadResponse 19 22 import app.bsky.embed.AspectRatio 23 + import app.bsky.embed.External 24 + import app.bsky.embed.ExternalExternal 20 25 import app.bsky.embed.Images 21 26 import app.bsky.embed.ImagesImage 22 27 import app.bsky.embed.Record ··· 78 83 import io.ktor.client.request.setBody 79 84 import io.ktor.http.ContentType 80 85 import io.ktor.http.HttpStatusCode 86 + import io.ktor.http.isSuccess 81 87 import io.ktor.http.URLProtocol 82 88 import io.ktor.http.path 83 89 import io.ktor.serialization.kotlinx.KotlinxSerializationConverter ··· 551 557 replyRef: PostReplyRef? = null, 552 558 quotePostRef: StrongRef? = null, 553 559 facets: List<Facet> = listOf(), 560 + linkPreview: LinkPreviewData? = null, 554 561 ): Result<Unit> { 555 562 return runCatching { 556 563 create().onFailure { ··· 587 594 video = blob.blob, 588 595 alt = "", 589 596 aspectRatio = AspectRatio(blob.width, blob.height) 597 + ) 598 + ) 599 + } 600 + 601 + if (postEmbed == null && linkPreview != null) { 602 + var thumbBlob: Blob? = null 603 + if (linkPreview.imageUrl != null) { 604 + try { 605 + thumbBlob = uploadBlobFromUrl(linkPreview.imageUrl) 606 + } catch (_: Exception) { 607 + // Thumbnail upload failed, proceed without it 608 + } 609 + } 610 + postEmbed = PostEmbedUnion.External( 611 + value = External( 612 + external = ExternalExternal( 613 + uri = sh.christian.ozone.api.Uri(linkPreview.url), 614 + title = linkPreview.title ?: "", 615 + description = linkPreview.description ?: "", 616 + thumb = thumbBlob, 617 + ) 590 618 ) 591 619 ) 592 620 } ··· 666 694 } 667 695 } 668 696 697 + 698 + private suspend fun uploadBlobFromUrl(imageUrl: String): Blob? { 699 + val httpClient = HttpClient(OkHttp) { 700 + install(HttpTimeout) { 701 + requestTimeoutMillis = 10000 702 + connectTimeoutMillis = 5000 703 + socketTimeoutMillis = 10000 704 + } 705 + } 706 + val response = httpClient.get(imageUrl) 707 + if (!response.status.isSuccess()) return null 708 + val bytes: ByteArray = response.body() 709 + httpClient.close() 710 + val uploadResponse = client!!.uploadBlob(bytes) 711 + return when (uploadResponse) { 712 + is AtpResponse.Failure<*> -> null 713 + is AtpResponse.Success<UploadBlobResponse> -> uploadResponse.response.blob 714 + } 715 + } 669 716 private data class MediaBlob( 670 717 val blob: Blob, 671 718 val width: Long, ··· 1061 1108 return when (res) { 1062 1109 is AtpResponse.Failure<*> -> Result.failure(Exception("Could not get thread: ${res.error?.message}")) 1063 1110 is AtpResponse.Success<GetPostThreadResponse> -> Result.success(res.response) 1111 + } 1112 + } 1113 + } 1114 + 1115 + suspend fun searchActorsTypeahead(query: String): Result<List<ProfileViewBasic>> { 1116 + return runCatching { 1117 + create().onFailure { 1118 + return Result.failure(LoginException(it.message)) 1119 + } 1120 + 1121 + val res = client!!.searchActorsTypeahead( 1122 + SearchActorsTypeaheadQueryParams( 1123 + q = query, 1124 + limit = 8, 1125 + ) 1126 + ) 1127 + 1128 + return when (res) { 1129 + is AtpResponse.Failure<*> -> Result.failure(Exception("Typeahead search failed: ${res.error?.message}")) 1130 + is AtpResponse.Success<SearchActorsTypeaheadResponse> -> Result.success(res.response.actors) 1064 1131 } 1065 1132 } 1066 1133 }
+77
app/src/main/java/industries/geesawra/monarch/datalayer/LinkPreview.kt
··· 1 + package industries.geesawra.monarch.datalayer 2 + 3 + import io.ktor.client.HttpClient 4 + import io.ktor.client.engine.okhttp.OkHttp 5 + import io.ktor.client.plugins.HttpTimeout 6 + import io.ktor.client.request.get 7 + import io.ktor.client.statement.bodyAsText 8 + import io.ktor.http.isSuccess 9 + import kotlinx.coroutines.Dispatchers 10 + import kotlinx.coroutines.withContext 11 + 12 + data class LinkPreviewData( 13 + val title: String?, 14 + val description: String?, 15 + val imageUrl: String?, 16 + val url: String, 17 + ) 18 + 19 + object LinkPreviewFetcher { 20 + private val ogTitleRegex = 21 + Regex("""<meta\s+[^>]*property\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']*)["'][^>]*/?>""", RegexOption.IGNORE_CASE) 22 + private val ogTitleRegexAlt = 23 + Regex("""<meta\s+[^>]*content\s*=\s*["']([^"']*)["'][^>]*property\s*=\s*["']og:title["'][^>]*/?>""", RegexOption.IGNORE_CASE) 24 + private val ogDescRegex = 25 + Regex("""<meta\s+[^>]*property\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']*)["'][^>]*/?>""", RegexOption.IGNORE_CASE) 26 + private val ogDescRegexAlt = 27 + Regex("""<meta\s+[^>]*content\s*=\s*["']([^"']*)["'][^>]*property\s*=\s*["']og:description["'][^>]*/?>""", RegexOption.IGNORE_CASE) 28 + private val ogImageRegex = 29 + Regex("""<meta\s+[^>]*property\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']*)["'][^>]*/?>""", RegexOption.IGNORE_CASE) 30 + private val ogImageRegexAlt = 31 + Regex("""<meta\s+[^>]*content\s*=\s*["']([^"']*)["'][^>]*property\s*=\s*["']og:image["'][^>]*/?>""", RegexOption.IGNORE_CASE) 32 + private val titleTagRegex = 33 + Regex("""<title[^>]*>([^<]*)</title>""", RegexOption.IGNORE_CASE) 34 + 35 + private val httpClient = HttpClient(OkHttp) { 36 + install(HttpTimeout) { 37 + requestTimeoutMillis = 10000 38 + connectTimeoutMillis = 5000 39 + socketTimeoutMillis = 10000 40 + } 41 + } 42 + 43 + suspend fun fetch(url: String): LinkPreviewData? = withContext(Dispatchers.IO) { 44 + try { 45 + val response = httpClient.get(url) 46 + if (!response.status.isSuccess()) return@withContext null 47 + 48 + val html = response.bodyAsText() 49 + // Only parse the head section for efficiency 50 + val headEnd = html.indexOf("</head>", ignoreCase = true) 51 + val headHtml = if (headEnd > 0) html.substring(0, headEnd) else html.take(16000) 52 + 53 + val title = ogTitleRegex.find(headHtml)?.groupValues?.get(1) 54 + ?: ogTitleRegexAlt.find(headHtml)?.groupValues?.get(1) 55 + ?: titleTagRegex.find(headHtml)?.groupValues?.get(1) 56 + 57 + val description = ogDescRegex.find(headHtml)?.groupValues?.get(1) 58 + ?: ogDescRegexAlt.find(headHtml)?.groupValues?.get(1) 59 + 60 + val imageUrl = ogImageRegex.find(headHtml)?.groupValues?.get(1) 61 + ?: ogImageRegexAlt.find(headHtml)?.groupValues?.get(1) 62 + 63 + if (title == null && description == null && imageUrl == null) { 64 + return@withContext null 65 + } 66 + 67 + LinkPreviewData( 68 + title = title?.trim()?.takeIf { it.isNotEmpty() }, 69 + description = description?.trim()?.takeIf { it.isNotEmpty() }, 70 + imageUrl = imageUrl?.trim()?.takeIf { it.isNotEmpty() }, 71 + url = url, 72 + ) 73 + } catch (_: Exception) { 74 + null 75 + } 76 + } 77 + }
+7 -1
app/src/main/java/industries/geesawra/monarch/datalayer/TimelineViewModel.kt
··· 542 542 replyRef: PostReplyRef? = null, 543 543 quotePostRef: StrongRef? = null, 544 544 facets: List<Facet> = listOf(), 545 + linkPreview: LinkPreviewData? = null, 545 546 ): Result<Unit> { 546 547 return bskyConn.post( 547 548 content, ··· 549 550 video, 550 551 replyRef, 551 552 quotePostRef, 552 - facets 553 + facets, 554 + linkPreview = linkPreview 553 555 ) // TODO: maybe refactor this to use uistate.Error? 554 556 } 555 557 ··· 695 697 level = level, 696 698 replies = replies 697 699 ) 700 + } 701 + 702 + suspend fun searchActorsTypeahead(query: String): Result<List<ProfileViewBasic>> { 703 + return bskyConn.searchActorsTypeahead(query) 698 704 } 699 705 700 706 fun getThread(then: () -> Unit) {