Better markdown handling

This commit fixes some of the issues with markdown rendering:
- Switch to Textview instead of RecyclerView to render Markdown
- Import Spoiler renderer from Jebora
- Import Script renderer from Jebora
- Clean out markwon plugins that were specific to reddit

Closes #130 #172 #217 and #273
This commit is contained in:
Bazsalanszky 2024-08-09 12:45:15 +02:00
parent c1c8d99de2
commit 767e75b798
13 changed files with 235 additions and 64 deletions

View File

@ -245,6 +245,7 @@ dependencies {
implementation "io.noties.markwon:simple-ext:$markwonVersion"
implementation "io.noties.markwon:inline-parser:$markwonVersion"
implementation "io.noties.markwon:image-glide:$markwonVersion"
implementation "io.noties.markwon:html:$markwonVersion"
implementation 'com.atlassian.commonmark:commonmark-ext-gfm-tables:0.14.0'
implementation 'me.saket:better-link-movement-method:2.2.0'

View File

@ -681,10 +681,7 @@ public class PostDetailRecyclerViewAdapter extends RecyclerView.Adapter<Recycler
if (mPost.getSelfText() != null && !mPost.getSelfText().equals("")) {
((PostDetailBaseViewHolder) holder).contentMarkdownView.setVisibility(View.VISIBLE);
((PostDetailBaseViewHolder) holder).contentMarkdownView.setAdapter(mMarkwonAdapter);
mMarkwonAdapter.setMarkdown(mPostDetailMarkwon, mPost.getSelfText());
// noinspection NotifyDataSetChanged
mMarkwonAdapter.notifyDataSetChanged();
mPostDetailMarkwon.setMarkdown(((PostDetailBaseViewHolder) holder).contentMarkdownView,mPost.getSelfText());
}
if (holder instanceof PostDetailBaseVideoAutoplayViewHolder) {
@ -1216,7 +1213,7 @@ public class PostDetailRecyclerViewAdapter extends RecyclerView.Adapter<Recycler
CustomTextView flairTextView;
TextView awardsTextView;
TextView upvoteRatioTextView;
RecyclerView contentMarkdownView;
TextView contentMarkdownView;
ConstraintLayout bottomConstraintLayout;
MaterialButton upvoteButton;
TextView scoreTextView;
@ -1248,7 +1245,7 @@ public class PostDetailRecyclerViewAdapter extends RecyclerView.Adapter<Recycler
CustomTextView flairTextView,
TextView awardsTextView,
TextView upvoteRatioTextView,
RecyclerView contentMarkdownView,
TextView contentMarkdownView,
ConstraintLayout bottomConstraintLayout,
MaterialButton upvoteButton,
TextView scoreTextView,
@ -1344,7 +1341,7 @@ public class PostDetailRecyclerViewAdapter extends RecyclerView.Adapter<Recycler
mActivity.startActivity(intent);
});
contentMarkdownView.setLayoutManager(new SwipeLockLinearLayoutManager(mActivity, new SwipeLockInterface() {
/*contentMarkdownView.setLayoutManager(new SwipeLockLinearLayoutManager(mActivity, new SwipeLockInterface() {
@Override
public void lockSwipe() {
((ViewPostDetailActivity) mActivity).lockSwipeRightToGoBack();
@ -1354,7 +1351,7 @@ public class PostDetailRecyclerViewAdapter extends RecyclerView.Adapter<Recycler
public void unlockSwipe() {
((ViewPostDetailActivity) mActivity).unlockSwipeRightToGoBack();
}
}));
}));*/
upvoteButton.setOnClickListener(view -> {
if (mPost.isArchived()) {
@ -1827,7 +1824,7 @@ public class PostDetailRecyclerViewAdapter extends RecyclerView.Adapter<Recycler
ImageView pauseButton,
ImageView playButton,
DefaultTimeBar progressBar,
RecyclerView contentMarkdownView,
TextView contentMarkdownView,
ConstraintLayout bottomConstraintLayout,
MaterialButton upvoteButton,
TextView scoreTextView,

View File

@ -18,6 +18,8 @@ import eu.toldi.infinityforlemmy.customviews.CustomMarkwonAdapter;
import io.noties.markwon.Markwon;
import io.noties.markwon.MarkwonPlugin;
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.glide.GlideImagesPlugin;
import io.noties.markwon.inlineparser.BangInlineProcessor;
import io.noties.markwon.inlineparser.HtmlInlineProcessor;
@ -44,35 +46,27 @@ public class MarkdownUtils {
Markwon result;
if (dataSaverEnabled) {
result = Markwon.builder(context)
.usePlugin(MarkwonInlineParserPlugin.create(plugin -> {
plugin.excludeInlineProcessor(HtmlInlineProcessor.class);
}))
.usePlugin(miscPlugin)
.usePlugin(SuperscriptPlugin.create())
.usePlugin(SpoilerParserPlugin.create(markdownColor, spoilerBackgroundColor))
.usePlugin(RedditHeadingPlugin.create())
.usePlugin(new ScriptRewriteSupportPlugin())
.usePlugin(new MarkwonSpoilerPlugin(true))
.usePlugin(HtmlPlugin.create())
.usePlugin(StrikethroughPlugin.create())
.usePlugin(MovementMethodPlugin.create(new SpoilerAwareMovementMethod()
.setOnLinkLongClickListener(onLinkLongClickListener)))
.usePlugin(TablePlugin.create(context))
.usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS))
.usePlugin(TableEntryPlugin.create(context))
.usePlugin(new MarkwonLemmyLinkPlugin())
.build();
} else {
result = Markwon.builder(context)
.usePlugin(GlideImagesPlugin.create(new GlideMarkdownLoader(mGlide)))
.usePlugin(MarkwonInlineParserPlugin.create(plugin -> {
plugin.excludeInlineProcessor(HtmlInlineProcessor.class);
}))
.usePlugin(miscPlugin)
.usePlugin(SuperscriptPlugin.create())
.usePlugin(SpoilerParserPlugin.create(markdownColor, spoilerBackgroundColor))
.usePlugin(RedditHeadingPlugin.create())
.usePlugin(new ScriptRewriteSupportPlugin())
.usePlugin(new MarkwonSpoilerPlugin(true))
.usePlugin(HtmlPlugin.create())
.usePlugin(StrikethroughPlugin.create())
.usePlugin(MovementMethodPlugin.create(new SpoilerAwareMovementMethod()
.setOnLinkLongClickListener(onLinkLongClickListener)))
.usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS))
.usePlugin(TableEntryPlugin.create(context))
.usePlugin(TablePlugin.create(context))
.usePlugin(ClickableGlideImagesPlugin.create(context))
.usePlugin(new MarkwonLemmyLinkPlugin())
.build();
@ -87,32 +81,27 @@ public class MarkdownUtils {
Markwon result;
if (dataSaverEnabled) {
result = Markwon.builder(context)
.usePlugin(MarkwonInlineParserPlugin.create(plugin -> {
plugin.excludeInlineProcessor(HtmlInlineProcessor.class);
}))
.usePlugin(miscPlugin)
.usePlugin(SuperscriptPlugin.create())
.usePlugin(RedditHeadingPlugin.create())
.usePlugin(new ScriptRewriteSupportPlugin())
.usePlugin(new MarkwonSpoilerPlugin(true))
.usePlugin(HtmlPlugin.create())
.usePlugin(StrikethroughPlugin.create())
.usePlugin(MovementMethodPlugin.create(new SpoilerAwareMovementMethod()
.setOnLinkLongClickListener(onLinkLongClickListener)))
.usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS))
.usePlugin(TableEntryPlugin.create(context))
.usePlugin(TablePlugin.create(context))
.usePlugin(new MarkwonLemmyLinkPlugin())
.build();
} else {
result = Markwon.builder(context)
.usePlugin(MarkwonInlineParserPlugin.create(plugin -> {
plugin.excludeInlineProcessor(HtmlInlineProcessor.class);
}))
.usePlugin(miscPlugin)
.usePlugin(SuperscriptPlugin.create())
.usePlugin(RedditHeadingPlugin.create())
.usePlugin(StrikethroughPlugin.create())
.usePlugin(new ScriptRewriteSupportPlugin())
.usePlugin(new MarkwonSpoilerPlugin(true))
.usePlugin(HtmlPlugin.create())
.usePlugin(MovementMethodPlugin.create(new SpoilerAwareMovementMethod()
.setOnLinkLongClickListener(onLinkLongClickListener)))
.usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS))
.usePlugin(TableEntryPlugin.create(context))
.usePlugin(TablePlugin.create(context))
.usePlugin(GlideImagesPlugin.create(context.getApplicationContext()))
.usePlugin(new MarkwonLemmyLinkPlugin())
.build();

View File

@ -0,0 +1,158 @@
package eu.toldi.infinityforlemmy.markdown
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.util.Log
import android.view.View
import android.widget.TextView
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.MarkwonPlugin
import io.noties.markwon.MarkwonVisitor
import io.noties.markwon.core.CorePlugin
import io.noties.markwon.image.AsyncDrawableScheduler
// Source copied from https://github.com/LemmyNet/jerboa/blob/main/app/src/main/java/com/jerboa/util/markwon/MarkwonSpoilerPlugin.kt
data class SpoilerTitleSpan(
val title: CharSequence,
)
class SpoilerCloseSpan
class MarkwonSpoilerPlugin(
val enableInteraction: Boolean,
) : AbstractMarkwonPlugin() {
override fun configure(registry: MarkwonPlugin.Registry) {
registry.require(CorePlugin::class.java) {
it.addOnTextAddedListener(
SpoilerTextAddedListener(),
)
}
}
private class SpoilerTextAddedListener : CorePlugin.OnTextAddedListener {
override fun onTextAdded(
visitor: MarkwonVisitor,
text: String,
start: Int,
) {
val spoilerTitleRegex = Regex("(:::\\s+spoiler\\s+)(.*)")
// Find all spoiler "start" lines
val spoilerTitles = spoilerTitleRegex.findAll(text)
for (match in spoilerTitles) {
val spoilerTitle = match.groups[2]!!.value
visitor.builder().setSpan(
SpoilerTitleSpan(spoilerTitle),
start,
start + match.groups[2]!!.range.last,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}
val spoilerCloseRegex = Regex("^(?!.*spoiler).*:::")
// Find all spoiler "end" lines
val spoilerCloses = spoilerCloseRegex.findAll(text)
for (match in spoilerCloses) {
visitor
.builder()
.setSpan(SpoilerCloseSpan(), start, start + 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}
override fun afterSetText(textView: TextView) {
try {
val spanned = SpannableStringBuilder(textView.text)
val spoilerTitleSpans =
spanned.getSpans(0, spanned.length, SpoilerTitleSpan::class.java)
val spoilerCloseSpans =
spanned.getSpans(0, spanned.length, SpoilerCloseSpan::class.java)
spoilerTitleSpans.sortBy { spanned.getSpanStart(it) }
spoilerCloseSpans.sortBy { spanned.getSpanStart(it) }
spoilerTitleSpans.forEachIndexed { index, spoilerTitleSpan ->
val spoilerStart = spanned.getSpanStart(spoilerTitleSpan)
var spoilerEnd = spanned.length
if (index < spoilerCloseSpans.size) {
val spoilerCloseSpan = spoilerCloseSpans[index]
spoilerEnd = spanned.getSpanEnd(spoilerCloseSpan)
}
var open = false
// The space at the end is necessary for the lengths to be the same
// This reduces complexity as else it would need complex logic to determine the replacement length
val getSpoilerTitle = { openParam: Boolean ->
if (openParam) "${spoilerTitleSpan.title}\n" else "${spoilerTitleSpan.title}\u200B"
}
val spoilerTitle = getSpoilerTitle(false)
val spoilerContent =
spanned.subSequence(
spanned.getSpanEnd(spoilerTitleSpan) + 1,
spoilerEnd - 3,
) as SpannableStringBuilder
// Remove spoiler content from span
spanned.replace(spoilerStart, spoilerEnd, spoilerTitle)
// Set span block title
spanned.setSpan(
spoilerTitle,
spoilerStart,
spoilerStart + spoilerTitle.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
val wrapper =
object : ClickableSpan() {
override fun onClick(p0: View) {
if (enableInteraction) {
textView.cancelPendingInputEvents()
open = !open
val spoilerStartCurrent = spanned.getSpanStart(spoilerTitle)
spanned.replace(
spoilerStartCurrent,
spoilerStartCurrent + spoilerTitle.length,
getSpoilerTitle(open),
)
if (open) {
spanned.insert(spoilerStartCurrent + spoilerTitle.length, spoilerContent)
} else {
spanned.replace(
spoilerStartCurrent + spoilerTitle.length,
spoilerStartCurrent + spoilerTitle.length + spoilerContent.length,
"",
)
}
textView.text = spanned
AsyncDrawableScheduler.schedule(textView)
}
}
override fun updateDrawState(ds: TextPaint) {
}
}
// Set spoiler block type as ClickableSpan
spanned.setSpan(
wrapper,
spoilerStart,
spoilerStart + spoilerTitle.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
textView.text = spanned
}
} catch (e: Exception) {
Log.w("jerboa", "Failed to parse spoiler tag. Format incorrect")
}
}
}

View File

@ -0,0 +1,26 @@
package eu.toldi.infinityforlemmy.markdown
import io.noties.markwon.AbstractMarkwonPlugin
// Source copied from https://github.com/LemmyNet/jerboa/blob/main/app/src/main/java/com/jerboa/util/markwon/ScriptRewriteSupportPlugin.kt
class ScriptRewriteSupportPlugin : AbstractMarkwonPlugin() {
override fun processMarkdown(markdown: String): String =
super.processMarkdown(
if (markdown.contains("^") || markdown.contains("~")) {
rewriteLemmyScriptToMarkwonScript(markdown)
} else { // Fast path: if there are no markdown characters, we don't need to do anything
markdown
},
)
companion object {
val SUPERSCRIPT_RGX = Regex("""\^([^\n^]+)\^""")
val SUBSCRIPT_RGX = Regex("""(?<!~)~([^\n~]+)~""")
fun rewriteLemmyScriptToMarkwonScript(text: String): String =
text
.replace(SUPERSCRIPT_RGX, "<sup>$1</sup>")
.replace(SUBSCRIPT_RGX, "<sub>$1</sub>")
}
}

View File

@ -256,13 +256,13 @@
android:src="@drawable/ic_link"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
<TextView
android:id="@+id/content_markdown_view_item_post_detail_gallery"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:visibility="gone"
android:nestedScrollingEnabled="false" />

View File

@ -262,13 +262,13 @@
</RelativeLayout>
<androidx.recyclerview.widget.RecyclerView
<TextView
android:id="@+id/content_markdown_view_item_post_detail_image_and_gif_autoplay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:visibility="gone"
android:nestedScrollingEnabled="false" />

View File

@ -271,13 +271,13 @@
</RelativeLayout>
<androidx.recyclerview.widget.RecyclerView
<TextView
android:id="@+id/content_markdown_view_item_post_detail_link"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:visibility="gone"
android:nestedScrollingEnabled="false" />

View File

@ -106,17 +106,17 @@
android:focusable="true"
android:longClickable="true" />
<androidx.recyclerview.widget.RecyclerView
<TextView
android:id="@+id/content_markdown_view_item_post_detail_no_preview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:visibility="gone"
android:nestedScrollingEnabled="false" >
</androidx.recyclerview.widget.RecyclerView>
</TextView>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"

View File

@ -106,17 +106,17 @@
android:textIsSelectable="true"
android:textSize="?attr/title_font_18" />
<androidx.recyclerview.widget.RecyclerView
<TextView
android:id="@+id/content_markdown_view_item_post_detail_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:visibility="gone"
android:nestedScrollingEnabled="false" >
</androidx.recyclerview.widget.RecyclerView>
</TextView>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"

View File

@ -274,13 +274,13 @@
</RelativeLayout>
<androidx.recyclerview.widget.RecyclerView
<TextView
android:id="@+id/content_markdown_view_item_post_detail_video_and_gif_preview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:visibility="gone"
android:nestedScrollingEnabled="false" />

View File

@ -255,13 +255,13 @@
</com.google.android.exoplayer2.ui.AspectRatioFrameLayout>
<androidx.recyclerview.widget.RecyclerView
<TextView
android:id="@+id/content_markdown_view_item_post_detail_video_autoplay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:visibility="gone"
android:nestedScrollingEnabled="false" />

View File

@ -258,13 +258,13 @@
</com.google.android.exoplayer2.ui.AspectRatioFrameLayout>
<androidx.recyclerview.widget.RecyclerView
<TextView
android:id="@+id/content_markdown_view_item_post_detail_video_autoplay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:visibility="gone"
android:nestedScrollingEnabled="false" />