From 75f486a0f5bb42b5c78cf3a6f26e98cea14521e4 Mon Sep 17 00:00:00 2001
From: SpiritCroc <dev@spiritcroc.de>
Date: Wed, 20 Jul 2022 18:19:06 +0200
Subject: [PATCH] Collapse details tags

Change-Id: I545958f06e412819c0f1cdb9037abc69c1442808
---
 .../app/features/html/DetailsTagHandler.kt    |  58 +++++
 .../features/html/DetailsTagPostProcessor.kt  | 201 ++++++++++++++++++
 .../app/features/html/EventHtmlRenderer.kt    |   2 +
 3 files changed, 261 insertions(+)
 create mode 100644 vector/src/main/java/im/vector/app/features/html/DetailsTagHandler.kt
 create mode 100644 vector/src/main/java/im/vector/app/features/html/DetailsTagPostProcessor.kt

diff --git a/vector/src/main/java/im/vector/app/features/html/DetailsTagHandler.kt b/vector/src/main/java/im/vector/app/features/html/DetailsTagHandler.kt
new file mode 100644
index 0000000000..cf656ef433
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/html/DetailsTagHandler.kt
@@ -0,0 +1,58 @@
+package im.vector.app.features.html
+
+import android.text.Spanned
+import io.noties.markwon.MarkwonVisitor
+import io.noties.markwon.html.HtmlTag
+import io.noties.markwon.html.MarkwonHtmlRenderer
+import io.noties.markwon.html.TagHandler
+import java.util.Collections
+
+/**
+ * https://github.com/noties/Markwon/issues/181#issuecomment-571296484
+ */
+
+class DetailsTagHandler: TagHandler() {
+
+    override fun handle(visitor: MarkwonVisitor, renderer: MarkwonHtmlRenderer, tag: HtmlTag) {
+        var summaryEnd = -1
+        var summaryStart = -1
+        for (child in tag.asBlock.children()) {
+
+            if (!child.isClosed) {
+                continue
+            }
+
+            if ("summary" == child.name()) {
+                summaryStart = child.start()
+                summaryEnd = child.end()
+            }
+
+            val tagHandler = renderer.tagHandler(child.name())
+            if (tagHandler != null) {
+                tagHandler.handle(visitor, renderer, child)
+            } else if (child.isBlock) {
+                visitChildren(visitor, renderer, child.asBlock)
+            }
+        }
+
+        if (summaryEnd > -1 && summaryStart > -1) {
+            val summary = visitor.builder().subSequence(summaryStart, summaryEnd)
+            val summarySpan = DetailsSummarySpan(summary)
+            visitor.builder().setSpan(summarySpan, summaryStart, summaryEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+            visitor.builder().setSpan(DetailsParsingSpan(summarySpan), tag.start(), tag.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+        }
+    }
+
+    override fun supportedTags(): Collection<String> {
+        return Collections.singleton("details")
+    }
+}
+
+data class DetailsSummarySpan(val text: CharSequence)
+
+enum class DetailsSpanState { DORMANT_CLOSE, DORMANT_OPEN, CLOSED, OPENED }
+
+data class DetailsParsingSpan(
+    val summary: DetailsSummarySpan,
+    var state: DetailsSpanState = DetailsSpanState.CLOSED
+)
diff --git a/vector/src/main/java/im/vector/app/features/html/DetailsTagPostProcessor.kt b/vector/src/main/java/im/vector/app/features/html/DetailsTagPostProcessor.kt
new file mode 100644
index 0000000000..a264e55204
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/html/DetailsTagPostProcessor.kt
@@ -0,0 +1,201 @@
+package im.vector.app.features.html
+
+import android.text.SpannableStringBuilder
+import android.text.Spanned
+import android.text.TextPaint
+import android.text.style.ClickableSpan
+import android.view.View
+import android.widget.TextView
+import io.noties.markwon.AbstractMarkwonPlugin
+import io.noties.markwon.image.AsyncDrawableScheduler
+
+/**
+ * https://github.com/noties/Markwon/issues/181#issuecomment-571296484
+ */
+
+class DetailsTagPostProcessor constructor(
+        private val eventHtmlRenderer: EventHtmlRenderer
+) : AbstractMarkwonPlugin() {
+
+    override fun afterSetText(textView: TextView) {
+        postProcessDetails(SpannableStringBuilder(textView.text), textView, true)
+    }
+
+    /**
+     * Post-process details statements in the text. They act like `<spoiler>` or `<cut>` tag in some websites
+     * @param spanned text to be modified to cut out details tags and insert replacements instead of them
+     * @param view resulting text view to accept the modified spanned string
+     * @param onBind whether we call this externally or internally
+     */
+    private fun postProcessDetails(spanned: SpannableStringBuilder, view: TextView, onBind: Boolean) {
+        val spans = spanned.getSpans(0, spanned.length, DetailsParsingSpan::class.java)
+        spans.sortBy { spanned.getSpanStart(it) }
+
+        // if we have no details, proceed as usual (single text-view)
+        if (spans.isNullOrEmpty()) {
+            // no details
+            return
+        }
+
+        for (span in spans) {
+            val startIdx = spanned.getSpanStart(span)
+            val endIdx = spanned.getSpanEnd(span)
+
+            val summaryStartIdx = spanned.getSpanStart(span.summary)
+            val summaryEndIdx = spanned.getSpanEnd(span.summary)
+
+            // details tags can be nested, skip them if they were hidden
+            if (startIdx == -1 || endIdx == -1) {
+                continue
+            }
+
+            // On re-bind, reset span state
+            if (onBind) {
+                span.state = when (span.state) {
+                    DetailsSpanState.DORMANT_CLOSE -> DetailsSpanState.CLOSED
+                    DetailsSpanState.DORMANT_OPEN -> DetailsSpanState.OPENED
+                    else -> span.state
+                }
+            }
+
+            // replace text inside spoiler tag with just spoiler summary that is clickable
+            val summaryText = when (span.state) {
+                // Make sure to not convert the summary to string by accident, to not lose existing spans (like clickable links)
+                DetailsSpanState.CLOSED -> {
+                    SpannableStringBuilder(span.summary.text).apply {
+                        insert(0, "▶ ")
+                        if (endIdx < spanned.length-1) {
+                            append("\n\n")
+                        }
+                    }
+                }
+                DetailsSpanState.OPENED -> {
+                    SpannableStringBuilder(span.summary.text).apply {
+                        insert(0, "▼ ")
+                        if (endIdx < spanned.length-1) {
+                            append("\n\n")
+                        }
+                    }
+                }
+                else -> ""
+            }
+
+            when (span.state) {
+
+                DetailsSpanState.CLOSED -> {
+                    span.state = DetailsSpanState.DORMANT_CLOSE
+                    spanned.removeSpan(span.summary) // will be added later
+
+                    // spoiler tag must be closed, all the content under it must be hidden
+
+                    // retrieve content under spoiler tag and hide it
+                    // if it is shown, it should be put in blockquote to distinguish it from text before and after
+                    val innerSpanned = spanned.subSequence(summaryEndIdx, endIdx) as SpannableStringBuilder
+                    spanned.replace(summaryStartIdx, endIdx, summaryText)
+                    spanned.setSpan(span.summary, startIdx, startIdx + summaryText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+
+                    // expand text on click
+                    val wrapper = object : ClickableSpan() {
+
+                        // replace wrappers with real previous spans on click
+                        override fun onClick(widget: View) {
+                            span.state = DetailsSpanState.OPENED
+
+                            val start = spanned.getSpanStart(this)
+                            val end = spanned.getSpanEnd(this)
+
+                            spanned.removeSpan(this)
+                            spanned.insert(end, innerSpanned)
+
+                            // make details span cover all expanded text
+                            spanned.removeSpan(span)
+                            spanned.setSpan(span, start, end + innerSpanned.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+
+                            // edge-case: if the span around this text is now too short, expand it as well
+                            spanned.getSpans(end, end, Any::class.java)
+                                .filter { spanned.getSpanEnd(it) == end }
+                                .forEach {
+                                    if (it is DetailsSummarySpan) {
+                                        // don't expand summaries, they are meant to end there
+                                        return@forEach
+                                    }
+
+                                    val bqStart = spanned.getSpanStart(it)
+                                    spanned.removeSpan(it)
+                                    spanned.setSpan(it, bqStart, end + innerSpanned.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+                                }
+
+                            postProcessAndSetText(spanned, view)
+
+                            AsyncDrawableScheduler.schedule(view)
+                        }
+
+                        override fun updateDrawState(ds: TextPaint) {
+                            // Override without setting any color to preserve original colors
+                            //ds.color = ThemeUtils.getColor(view.context, R.attr.vctr_content_primary)
+                        }
+                    }
+                    spanned.setSpan(wrapper, startIdx, startIdx + summaryText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+                    view.text = spanned
+                }
+
+                DetailsSpanState.OPENED -> {
+                    span.state = DetailsSpanState.DORMANT_OPEN
+
+                    // put the hidden text into blockquote if needed
+                    /*
+                    var bq = spanned.getSpans(summaryEndIdx, endIdx, BlockQuoteSpan::class.java)
+                        .firstOrNull { spanned.getSpanStart(it) == summaryEndIdx && spanned.getSpanEnd(it) == endIdx }
+                    if (bq == null) {
+                        bq = BlockQuoteSpan(eventHtmlRenderer.theme)
+                        spanned.setSpan(bq, summaryEndIdx, endIdx, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+                    }
+                     */
+
+                    // content under spoiler tag is shown, but should be hidden again on click
+                    // change summary text to opened variant
+                    spanned.replace(summaryStartIdx, summaryEndIdx, summaryText)
+                    spanned.setSpan(span.summary, startIdx, startIdx + summaryText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+                    val wrapper = object : ClickableSpan() {
+
+                        // hide text again on click
+                        override fun onClick(widget: View) {
+                            span.state = DetailsSpanState.CLOSED
+
+                            spanned.removeSpan(this)
+
+                            postProcessAndSetText(spanned, view)
+                        }
+
+                        override fun updateDrawState(ds: TextPaint) {
+                            // Override without setting any color to preserve original colors
+                            //ds.color = ThemeUtils.getColor(view.context, R.attr.vctr_content_primary)
+                        }
+                    }
+                    spanned.setSpan(wrapper, startIdx, startIdx + summaryText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+                    view.text = spanned
+                }
+
+                DetailsSpanState.DORMANT_CLOSE,
+                DetailsSpanState.DORMANT_OPEN -> {
+                    // this state is present so that details spans that were already processed won't be processed again
+                    // nothing should be done
+                }
+            }
+        }
+    }
+
+    private fun postProcessAndSetText(spanned: SpannableStringBuilder, view: TextView) {
+        view.text = spanned
+
+        eventHtmlRenderer.plugins.forEach { plugin ->
+            if (plugin is DetailsTagPostProcessor) {
+                // Keep dormant state by not using the external interface that resets it
+                plugin.postProcessDetails(spanned, view, false)
+            } else {
+                plugin.afterSetText(view)
+            }
+        }
+    }
+
+}
diff --git a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt
index 70687dfe8b..b65d0059bc 100644
--- a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt
+++ b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt
@@ -105,6 +105,7 @@ class EventHtmlRenderer @Inject constructor(
                                     }
                         }
                     },
+                    DetailsTagPostProcessor(this),
                     GlideImagesPlugin.create(object: GlideImagesPlugin.GlideStore {
                         override fun load(drawable: AsyncDrawable): RequestBuilder<Drawable> {
                             val url = drawable.destination
@@ -235,6 +236,7 @@ class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: C
 
     override fun configureHtml(plugin: HtmlPlugin) {
         plugin
+                .addHandler(DetailsTagHandler())
                 .addHandler(ListHandlerWithInitialStart())
                 .addHandler(FontTagHandler())
                 .addHandler(ParagraphHandler(DimensionConverter(resources)))