Skip to content

Commit

Permalink
Open lemmy-verse links in Jerboa (#692)
Browse files Browse the repository at this point in the history
* Implement !community@instance and @user@instance links

They merely pop up a toast saying it's unimplemented, but the links are
being created and handled.

* Open community and user urls if they match regex

---------

Co-authored-by: Tyler Little <[email protected]>
  • Loading branch information
twizmwazin and beatgammit authored Jun 16, 2023
1 parent dcaf991 commit 7fdc045
Show file tree
Hide file tree
Showing 7 changed files with 212 additions and 14 deletions.
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ dependencies {

// optional - Test helpers
testImplementation "androidx.room:room-testing:2.5.1"
testImplementation "pl.pragmatists:JUnitParams:1.1.1"

// optional - Paging 3 Integration
implementation "androidx.room:room-paging:2.5.1"
Expand Down
11 changes: 6 additions & 5 deletions app/src/main/java/com/jerboa/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,6 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

MarkdownHelper.init(
this,
appSettingsViewModel.appSettings.value?.useCustomTabs ?: true,
appSettingsViewModel.appSettings.value?.usePrivateTabs ?: false,
)
window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))

val accountSync = getCurrentAccountSync(accountViewModel)
Expand All @@ -123,6 +118,12 @@ class MainActivity : ComponentActivity() {
val navController = rememberAnimatedNavController()
val ctx = LocalContext.current

MarkdownHelper.init(
navController,
appSettingsViewModel.appSettings.value?.useCustomTabs ?: true,
appSettingsViewModel.appSettings.value?.usePrivateTabs ?: false,
)

ShowChangelog(appSettingsViewModel = appSettingsViewModel)

AnimatedNavHost(
Expand Down
36 changes: 33 additions & 3 deletions app/src/main/java/com/jerboa/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.core.util.PatternsCompat
import androidx.navigation.NavController
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.jerboa.api.API
Expand Down Expand Up @@ -420,20 +421,49 @@ fun parseUrl(url: String): String? {
return null
}

fun openLink(url: String, ctx: Context, useCustomTab: Boolean, usePrivateTab: Boolean) {
fun looksLikeCommunityUrl(url: String): Pair<String, String>? {
val pattern = Regex("^https?://([^/]+)/c/([^/&?]+)")
val match = pattern.find(url)
if (match != null) {
val (host, community) = match.destructured
return Pair(host, community)
}
return null
}

fun looksLikeUserUrl(url: String): Pair<String, String>? {
val pattern = Regex("^https?://([^/]+)/u/([^/&?]+)")
val match = pattern.find(url)
if (match != null) {
val (host, user) = match.destructured
return Pair(host, user)
}
return null
}

fun openLink(url: String, navController: NavController, useCustomTab: Boolean, usePrivateTab: Boolean) {
val url = parseUrl(url) ?: return

looksLikeUserUrl(url)?.let { it ->
navController.navigate("${it.first}/u/${it.second}")
return
}
looksLikeCommunityUrl(url)?.let { it ->
navController.navigate("${it.first}/c/${it.second}")
return
}

if (useCustomTab) {
val intent = CustomTabsIntent.Builder()
.build().apply {
if (usePrivateTab) {
intent.putExtra("com.google.android.apps.chrome.EXTRA_OPEN_NEW_INCOGNITO_TAB", true)
}
}
intent.launchUrl(ctx, Uri.parse(url))
intent.launchUrl(navController.context, Uri.parse(url))
} else {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
ctx.startActivity(intent)
navController.context.startActivity(intent)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ fun CommentFooterLine(
@Preview
@Composable
fun CommentNodesPreview() {
MarkdownHelper.init(LocalContext.current, useCustomTabs = true, usePrivateTabs = false)
MarkdownHelper.init(LocalContext.current)
val comments = listOf(
sampleSecondReplyCommentView,
sampleCommentView,
Expand Down
102 changes: 99 additions & 3 deletions app/src/main/java/com/jerboa/ui/components/common/MarkdownHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package com.jerboa.ui.components.common

import android.content.Context
import android.os.Build
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.URLSpan
import android.text.util.Linkify
import android.util.TypedValue
import android.view.View
Expand All @@ -20,44 +23,137 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.res.ResourcesCompat
import androidx.navigation.NavController
import coil.ImageLoader
import com.jerboa.R
import com.jerboa.convertSpToPx
import com.jerboa.openLink
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonConfiguration
import io.noties.markwon.MarkwonPlugin
import io.noties.markwon.MarkwonVisitor
import io.noties.markwon.SpannableBuilder
import io.noties.markwon.core.CorePlugin
import io.noties.markwon.core.CorePlugin.OnTextAddedListener
import io.noties.markwon.core.CoreProps
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.html.HtmlPlugin
import io.noties.markwon.image.coil.CoilImagesPlugin
import io.noties.markwon.linkify.LinkifyPlugin
import org.commonmark.node.Link
import java.util.regex.Pattern

/**
* pattern that matches all valid communities; intended to be loose
*/
const val communityPatternFragment: String = """[a-zA-Z0-9_]{3,}"""

/**
* pattern to match all valid instances
*/
const val instancePatternFragment: String =
"""([a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]\.)+[a-zA-Z]{2,}"""

/**
* pattern to match all valid usernames
*/
const val userPatternFragment: String = """[a-zA-Z0-9_]{3,}"""

/**
* Pattern to match lemmy's unique community pattern, e.g. !commmunity[@instance]
*/
val lemmyCommunityPattern: Pattern =
Pattern.compile("(?:^|\\s)!($communityPatternFragment)(?:@($instancePatternFragment))?\\b")

/**
* Pattern to match lemmy's unique user pattern, e.g. @user[@instance]
*/
val lemmyUserPattern: Pattern =
Pattern.compile("(?:^|\\s)@($userPatternFragment)(?:@($instancePatternFragment))?\\b")

/**
* Plugin to turn Lemmy-specific URIs into clickable links.
*/
class LemmyLinkPlugin : AbstractMarkwonPlugin() {
override fun configure(registry: MarkwonPlugin.Registry) {
registry.require(CorePlugin::class.java) { it.addOnTextAddedListener(LemmyTextAddedListener()) }
}

private class LemmyTextAddedListener : OnTextAddedListener {
override fun onTextAdded(visitor: MarkwonVisitor, text: String, start: Int) {
// we will be using the link that is used by markdown (instead of directly applying URLSpan)
val spanFactory = visitor.configuration().spansFactory().get(
Link::class.java,
) ?: return

// don't re-use builder (thread safety achieved for
// render calls from different threads and ... better performance)
val builder = SpannableStringBuilder(text)
if (addLinks(builder)) {
// target URL span specifically
val spans = builder.getSpans(0, builder.length, URLSpan::class.java)
if (!spans.isNullOrEmpty()) {
val renderProps = visitor.renderProps()
val spannableBuilder = visitor.builder()
for (span in spans) {
CoreProps.LINK_DESTINATION[renderProps] = span.url
SpannableBuilder.setSpans(
spannableBuilder,
spanFactory.getSpans(visitor.configuration(), renderProps),
start + builder.getSpanStart(span),
start + builder.getSpanEnd(span),
)
}
}
}
}

fun addLinks(text: Spannable): Boolean {
val communityLinkAdded = Linkify.addLinks(text, lemmyCommunityPattern, null)
val userLinkAdded = Linkify.addLinks(text, lemmyUserPattern, null)

return communityLinkAdded || userLinkAdded
}
}
}

object MarkdownHelper {
private var markwon: Markwon? = null

fun init(context: Context, useCustomTabs: Boolean, usePrivateTabs: Boolean) {
fun init(navController: NavController, useCustomTabs: Boolean, usePrivateTabs: Boolean) {
val context = navController.context
val loader = ImageLoader.Builder(context)
.crossfade(true)
.placeholder(R.drawable.ic_launcher_foreground)
.build()

markwon = Markwon.builder(context)
.usePlugin(CoilImagesPlugin.create(context, loader))
// email urls interfere with lemmy links
.usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS))
.usePlugin(LemmyLinkPlugin())
.usePlugin(StrikethroughPlugin.create())
.usePlugin(TablePlugin.create(context))
.usePlugin(HtmlPlugin.create())
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
builder.linkResolver { view, link ->
openLink(link, view.context, useCustomTabs, usePrivateTabs)
builder.linkResolver { _, link ->
openLink(link, navController, useCustomTabs, usePrivateTabs)
}
}
})
.build()
}

/*
* This is a workaround for previews.
*/
fun init(context: Context) {
markwon = Markwon.builder(context).build()
}

@Composable
fun CreateMarkdownView(
markdown: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ fun AboutActivity(
val snackbarHostState = remember { SnackbarHostState() }

fun openLink(link: String) {
openLink(link, ctx, useCustomTabs, usePrivateTabs)
openLink(link, navController, useCustomTabs, usePrivateTabs)
}

Scaffold(
Expand All @@ -80,7 +80,7 @@ fun AboutActivity(
)
},
onClick = {
openLink("$githubUrl/blob/main/RELEASES.md", ctx, useCustomTabs, usePrivateTabs)
openLink("$githubUrl/blob/main/RELEASES.md", navController, useCustomTabs, usePrivateTabs)
},
)
SettingsDivider()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.jerboa.ui.components.common

import junitparams.JUnitParamsRunner
import junitparams.Parameters
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(JUnitParamsRunner::class)
class LemmyLinkPluginTest {
@Test
@Parameters(method = "communitySuccessCases")
fun testCommunityValid(pattern: String, community: String, instance: String?) {
val matcher = lemmyCommunityPattern.matcher(pattern)

assertTrue(matcher.find())
assertEquals(community, matcher.group(1))
assertEquals(instance, matcher.group(2))
}

@Test
@Parameters(
value = [
"a!community",
"[email protected]",
"!co",
],
)
fun testCommunityInvalid(pattern: String) {
assertFalse(lemmyCommunityPattern.matcher(pattern).find())
}

@Test
@Parameters(method = "userSuccessCases")
fun testUserValid(pattern: String, user: String, instance: String?) {
val matcher = lemmyUserPattern.matcher(pattern)

assertTrue(matcher.find())
assertEquals(user, matcher.group(1))
assertEquals(instance, matcher.group(2))
}

@Test
@Parameters(
value = [
"a@user",
"!@[email protected]",
"@co",
],
)
fun testUserInvalid(pattern: String) {
assertFalse(lemmyUserPattern.matcher(pattern).find())
}

fun communitySuccessCases() = listOf(
listOf("!community", "community", null),
listOf(" !community.", "community", null),
listOf("[email protected]", "community", "instance.ml"),
listOf("[email protected]!", "community", "instance.ml"),
)

fun userSuccessCases() = listOf(
listOf("@user", "user", null),
listOf(" @user.", "user", null),
listOf("@[email protected]", "user", "instance.ml"),
listOf("@[email protected]!", "user", "instance.ml"),
)
}

0 comments on commit 7fdc045

Please sign in to comment.