Skip to content

Commit

Permalink
Implement !community@instance and @user@instance links
Browse files Browse the repository at this point in the history
They merely pop up a toast saying it's unimplemented, but the links are
being created and handled.
  • Loading branch information
beatgammit committed Jun 15, 2023
1 parent 36bde45 commit d26bb05
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 0 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
8 changes: 8 additions & 0 deletions app/src/main/java/com/jerboa/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,14 @@ fun LazyListState.isScrolledToEnd(): Boolean {
}

fun openLink(url: String, ctx: Context, useCustomTab: Boolean, usePrivateTab: Boolean) {
if (url.startsWith("@")) {
Toast.makeText(ctx, "User info links not yet supported", Toast.LENGTH_SHORT).show()
return
} else if (url.startsWith("!")) {
Toast.makeText(ctx, "Community links not yet supported", Toast.LENGTH_SHORT).show()
return
}

if (useCustomTab) {
val intent = CustomTabsIntent.Builder()
.build().apply {
Expand Down
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 @@ -27,11 +30,93 @@ 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
Expand All @@ -44,7 +129,9 @@ object MarkdownHelper {

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())
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 d26bb05

Please sign in to comment.