[1.453.*] Pre-release merge (#1038)

This commit is contained in:
tramline-github[bot]
2026-01-12 16:07:54 +00:00
committed by GitHub
11 changed files with 288 additions and 41 deletions
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- Links to other posts now open in the app itself
## [1.59.0] - 2026-01-09
### Fixed
+4
View File
@@ -6,6 +6,8 @@
*/
@file:Suppress("UnstableApiUsage")
import dev.msfjarvis.claw.gradle.addTestDependencies
plugins {
id("dev.msfjarvis.claw.android-application")
id("dev.msfjarvis.claw.rename-artifacts")
@@ -118,4 +120,6 @@ dependencies {
compileOnly(libs.androidx.compose.glance.preview)
runtimeOnly(libs.androidx.profileinstaller)
addTestDependencies(project)
}
+46
View File
@@ -0,0 +1,46 @@
<!--
~ Copyright © Harsh Shandilya.
~ Use of this source code is governed by an MIT-style
~ license that can be found in the LICENSE file or at
~ https://opensource.org/licenses/MIT.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="claw-debug"
android:host="comments" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="claw-debug"
android:host="hottest" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="claw-debug"
android:host="newest" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="claw-debug"
android:host="saved" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -79,6 +79,7 @@ class MainActivity(
windowSizeClass = windowSizeClass,
setWebUri = { url -> webUri = url },
deepLinkDestination = deepLinkDestination,
clearDeepLink = { deepLinkDestination = null },
)
}
}
@@ -91,7 +92,7 @@ class MainActivity(
private fun handleIntent(intent: Intent) {
val data = intent.data
if (data != null && data.scheme == "claw") {
if (data != null && data.scheme == BuildConfig.DEEPLINK_SCHEME) {
when (data.host) {
"comments" -> {
val postId = data.pathSegments.firstOrNull()
@@ -34,6 +34,7 @@ import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import com.slack.eithernet.ApiResult
import dev.msfjarvis.claw.android.BuildConfig
import dev.msfjarvis.claw.android.ClawApplication
import dev.msfjarvis.claw.android.MainActivity
import dev.msfjarvis.claw.model.LobstersPost
@@ -81,10 +82,11 @@ class HottestPostsWidget : GlanceAppWidget() {
.padding(horizontal = 24.dp, vertical = 10.dp)
.clickable(
actionStartActivity(
Intent(Intent.ACTION_VIEW, "claw://hottest".toUri()).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setClass(LocalContext.current, MainActivity::class.java)
}
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://hottest".toUri())
.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setClass(LocalContext.current, MainActivity::class.java)
}
)
),
) {
@@ -33,6 +33,7 @@ import androidx.glance.preview.Preview
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import dev.msfjarvis.claw.android.BuildConfig
import dev.msfjarvis.claw.android.ClawApplication
import dev.msfjarvis.claw.android.MainActivity
import dev.msfjarvis.claw.model.UIPost
@@ -76,10 +77,11 @@ class SavedPostsWidget : GlanceAppWidget() {
.padding(horizontal = 24.dp, vertical = 10.dp)
.clickable(
actionStartActivity(
Intent(Intent.ACTION_VIEW, "claw://saved".toUri()).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setClass(LocalContext.current, MainActivity::class.java)
}
Intent(Intent.ACTION_VIEW, "${BuildConfig.DEEPLINK_SCHEME}://saved".toUri())
.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setClass(LocalContext.current, MainActivity::class.java)
}
)
),
) {
@@ -30,6 +30,7 @@ import androidx.glance.preview.ExperimentalGlancePreviewApi
import androidx.glance.preview.Preview
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import dev.msfjarvis.claw.android.BuildConfig
import dev.msfjarvis.claw.android.MainActivity
import dev.msfjarvis.claw.android.R
import dev.msfjarvis.claw.model.UIPost
@@ -40,10 +41,14 @@ fun WidgetPostEntry(post: UIPost, modifier: GlanceModifier = GlanceModifier) {
val titleStyle = MaterialTheme.typography.bodyMedium
val commentsAction =
actionStartActivity(
Intent(Intent.ACTION_VIEW, "claw://comments/${post.shortId}".toUri()).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setClass(LocalContext.current, MainActivity::class.java)
}
Intent(
Intent.ACTION_VIEW,
"${BuildConfig.DEEPLINK_SCHEME}://comments/${post.shortId}".toUri(),
)
.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setClass(LocalContext.current, MainActivity::class.java)
}
)
val postAction =
if (post.url.startsWith('/') || post.url.isEmpty()) commentsAction
@@ -8,6 +8,7 @@ package dev.msfjarvis.claw.android.ui.screens
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
@@ -87,6 +88,7 @@ fun LobstersPostsScreen(
setWebUri: (String?) -> Unit,
modifier: Modifier = Modifier,
deepLinkDestination: NavKey? = null,
clearDeepLink: () -> Unit = {},
viewModel: ClawViewModel = metroViewModel(),
tagFilterViewModel: TagFilterViewModel = metroViewModel(key = "tag_filter"),
) {
@@ -113,7 +115,8 @@ fun LobstersPostsScreen(
LaunchedEffect(deepLinkDestination) {
if (deepLinkDestination != null) {
navigateTo(backStack, deepLinkDestination)
navigateTo(backStack, deepLinkDestination, allowStacking = true)
clearDeepLink()
}
}
@@ -132,7 +135,9 @@ fun LobstersPostsScreen(
// endregion
val postActions = remember {
PostActions(context, uriHandler, viewModel) { navigateTo(backStack, Comments(it)) }
PostActions(context, uriHandler, viewModel) {
navigateTo(backStack, Comments(it), allowStacking = false)
}
}
BackHandler(enabled = backStack.size > 1) { backStack.removeAt(backStack.lastIndex) }
@@ -221,9 +226,14 @@ fun LobstersPostsScreen(
)
}
entry<Comments>(metadata = ListDetailSceneStrategy.detailPane()) { dest ->
val commentsPostActions = remember {
PostActions(context, uriHandler, viewModel) {
navigateTo(backStack, Comments(it), allowStacking = true)
}
}
CommentsPage(
postId = dest.postId,
postActions = postActions,
postActions = commentsPostActions,
contentPadding = contentPadding,
openUserProfile = { navigateTo(backStack, User(it)) },
)
@@ -289,20 +299,41 @@ fun LobstersPostsScreen(
}
}
private fun navigateTo(backStack: MutableList<NavKey>, destination: NavKey) {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun navigateTo(
backStack: MutableList<NavKey>,
destination: NavKey,
allowStacking: Boolean = false,
) {
if (destination is TopLevelDestination) {
backStack.clear()
if (destination != Hottest) {
backStack.add(Hottest)
}
backStack.add(destination)
return
}
val existingEntry =
backStack.firstOrNull {
it is NonStackable && it::class.java.isAssignableFrom(destination::class.java)
if (destination is NonStackable) {
if (allowStacking && destination is Comments) {
val lastItem = backStack.lastOrNull()
if (lastItem is Comments && lastItem.postId == destination.postId) {
return
}
backStack.add(destination)
return
}
val existingEntry =
backStack.firstOrNull {
it is NonStackable && it::class.java.isAssignableFrom(destination::class.java)
}
if (existingEntry != null) {
backStack.remove(existingEntry)
}
if (destination is NonStackable && existingEntry != null) {
backStack.remove(existingEntry)
}
backStack.add(destination)
}
@@ -0,0 +1,71 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.ui.screens
import androidx.navigation3.runtime.NavKey
import com.google.common.truth.Truth.assertThat
import dev.msfjarvis.claw.android.ui.navigation.Comments
import dev.msfjarvis.claw.android.ui.navigation.Hottest
import dev.msfjarvis.claw.android.ui.navigation.Newest
import dev.msfjarvis.claw.android.ui.navigation.Saved
import org.junit.jupiter.api.Test
class BackStackBehaviorTest {
private fun makeBackStack(vararg keys: NavKey) = mutableListOf(*keys)
@Test
fun `navigating from between top level destinations prevents stacking`() {
val backStack = makeBackStack(Hottest)
navigateTo(backStack, Newest)
assertThat(backStack).containsExactly(Hottest, Newest).inOrder()
navigateTo(backStack, Saved)
assertThat(backStack).containsExactly(Hottest, Saved).inOrder()
}
@Test
fun `NonStackable destinations do not stack by default`() {
val backStack = makeBackStack(Hottest, Comments("abc123"))
navigateTo(backStack, Comments("def456"))
assertThat(backStack).containsExactly(Hottest, Comments("def456")).inOrder()
}
@Test
fun `NonStackable destinations stack when allowStacking = true`() {
val backStack = makeBackStack(Hottest, Comments("abc123"))
navigateTo(backStack, Comments("def456"), allowStacking = true)
assertThat(backStack).containsExactly(Hottest, Comments("abc123"), Comments("def456")).inOrder()
}
@Test
fun `Same destination cannot be stacked on itself`() {
val backStack = makeBackStack(Hottest, Comments("abc123"))
navigateTo(backStack, Comments("abc123"), allowStacking = true)
assertThat(backStack).containsExactly(Hottest, Comments("abc123")).inOrder()
}
@Test
fun `Same destination can be stacked with a gap in between`() {
val backStack = makeBackStack(Hottest, Comments("abc123"), Comments("def456"))
navigateTo(backStack, Comments("abc123"), allowStacking = true)
assertThat(backStack)
.containsExactly(Hottest, Comments("abc123"), Comments("def456"), Comments("abc123"))
.inOrder()
}
}
@@ -50,6 +50,22 @@ class AndroidCommonPlugin : Plugin<Project> {
animationsDisabled = true
unitTests.isReturnDefaultValues = true
}
buildFeatures.buildConfig = true
buildTypes.configureEach {
when (name) {
"release" -> {
buildConfigField("String", "DEEPLINK_SCHEME", "\"claw\"")
}
"debug" -> {
buildConfigField("String", "DEEPLINK_SCHEME", "\"claw-debug\"")
}
"internal" -> {
buildConfigField("String", "DEEPLINK_SCHEME", "\"claw\"")
}
}
}
}
project.extensions.findByType<ApplicationExtension>()?.lint?.configureLint(project)
project.extensions.findByType<LibraryExtension>()?.lint?.configureLint(project)
@@ -12,13 +12,19 @@ import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextDecoration
import androidx.core.net.toUri
import be.digitalia.compose.htmlconverter.HtmlStyle
import be.digitalia.compose.htmlconverter.htmlToAnnotatedString
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.common.BuildConfig
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
@@ -28,25 +34,29 @@ internal fun ThemedRichText(text: String, modifier: Modifier = Modifier) {
val linkColor = MaterialTheme.colorScheme.onSurface
val convertedText =
remember(text) {
htmlToAnnotatedString(
// Lobsters seems to insert literal newlines between paragraphs for some reason which makes
// the resultant view come out rather ugly. We strip those out by hand and let the standard
// paragraph formatting handle separating individual blocks.
html = text.replace("</p>\\n<p>", "</p><p>"),
style =
HtmlStyle(
textLinkStyles =
TextLinkStyles(
style =
SpanStyle(
background = linkBackground,
color = linkColor,
fontWeight = FontWeight.Bold,
textDecoration = TextDecoration.Underline,
)
)
),
)
val annotatedString =
htmlToAnnotatedString(
// Lobsters seems to insert literal newlines between paragraphs for some reason which
// makes
// the resultant view come out rather ugly. We strip those out by hand and let the
// standard
// paragraph formatting handle separating individual blocks.
html = text.replace("</p>\\n<p>", "</p><p>"),
style =
HtmlStyle(
textLinkStyles =
TextLinkStyles(
style =
SpanStyle(
background = linkBackground,
color = linkColor,
fontWeight = FontWeight.Bold,
textDecoration = TextDecoration.Underline,
)
)
),
)
rewriteLobstersLinksToDeepLinks(annotatedString)
}
Text(
text = convertedText,
@@ -56,6 +66,61 @@ internal fun ThemedRichText(text: String, modifier: Modifier = Modifier) {
)
}
@OptIn(ExperimentalTextApi::class)
private fun rewriteLobstersLinksToDeepLinks(annotatedString: AnnotatedString): AnnotatedString {
val linkAnnotations = annotatedString.getLinkAnnotations(0, annotatedString.length)
if (linkAnnotations.isEmpty()) {
return annotatedString
}
return AnnotatedString.Builder(annotatedString)
.apply {
linkAnnotations.forEach { annotation ->
val link = annotation.item
if (link is LinkAnnotation.Url) {
val url = link.url
val rewrittenUrl = rewriteUrlIfLobstersPost(url)
addLink(
LinkAnnotation.Url(
rewrittenUrl,
styles = link.styles,
linkInteractionListener = link.linkInteractionListener,
),
start = annotation.start,
end = annotation.end,
)
}
}
}
.toAnnotatedString()
}
private val lobstersUri = LobstersApi.BASE_URL.toUri()
private fun rewriteUrlIfLobstersPost(url: String): String {
return try {
val uri = url.toUri()
if (
uri.scheme in setOf("http", "https") &&
uri.host == lobstersUri.host &&
uri.path?.startsWith("/s/") == true
) {
val pathSegments = uri.path?.split("/").orEmpty()
if (pathSegments.size >= 3) {
val shortId = pathSegments[2]
if (shortId.isNotEmpty()) {
return "${BuildConfig.DEEPLINK_SCHEME}://comments/$shortId"
}
}
}
url
} catch (_: Exception) {
url
}
}
@ThemePreviews
@Composable
internal fun ThemedRichTextPreview() {