[1.453.*] Pre-release merge (#1038)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+40
-9
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+71
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user