diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/activities/EditCommentActivity.java b/app/src/main/java/ml/docilealligator/infinityforreddit/activities/EditCommentActivity.java index 6b358806..4f4b81ec 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/activities/EditCommentActivity.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/activities/EditCommentActivity.java @@ -132,7 +132,7 @@ public class EditCommentActivity extends BaseActivity implements UploadImageEnab mFullName = getIntent().getStringExtra(EXTRA_FULLNAME); mAccessToken = mCurrentAccountSharedPreferences.getString(SharedPreferencesUtils.ACCESS_TOKEN, null); - mCommentContent = getIntent().getStringExtra(EXTRA_CONTENT).replaceAll(">!",">!"); + mCommentContent = getIntent().getStringExtra(EXTRA_CONTENT); contentEditText.setText(mCommentContent); if (savedInstanceState != null) { diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/activities/EditPostActivity.java b/app/src/main/java/ml/docilealligator/infinityforreddit/activities/EditPostActivity.java index 2de2a47b..0d22d9b3 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/activities/EditPostActivity.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/activities/EditPostActivity.java @@ -141,7 +141,7 @@ public class EditPostActivity extends BaseActivity implements UploadImageEnabled mFullName = getIntent().getStringExtra(EXTRA_FULLNAME); mAccessToken = mCurrentAccountSharedPreferences.getString(SharedPreferencesUtils.ACCESS_TOKEN, null); titleTextView.setText(getIntent().getStringExtra(EXTRA_TITLE)); - mPostContent = getIntent().getStringExtra(EXTRA_CONTENT).replaceAll(">!",">!");; + mPostContent = getIntent().getStringExtra(EXTRA_CONTENT); contentEditText.setText(mPostContent); if (savedInstanceState != null) { diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/CommentsRecyclerViewAdapter.java b/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/CommentsRecyclerViewAdapter.java index 8c17bc2d..6e6d18d5 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/CommentsRecyclerViewAdapter.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/adapters/CommentsRecyclerViewAdapter.java @@ -9,8 +9,6 @@ import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Bundle; import android.os.Handler; -import android.text.SpannableStringBuilder; -import android.text.Spanned; import android.text.util.Linkify; import android.view.LayoutInflater; import android.view.View; @@ -36,8 +34,6 @@ import com.lsjwzh.widget.materialloadingprogressbar.CircleProgressBar; import java.util.ArrayList; import java.util.Locale; import java.util.concurrent.Executor; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import butterknife.BindView; import butterknife.ButterKnife; @@ -48,6 +44,7 @@ import io.noties.markwon.core.MarkwonTheme; import io.noties.markwon.ext.strikethrough.StrikethroughPlugin; import io.noties.markwon.html.HtmlPlugin; import io.noties.markwon.html.tag.SuperScriptHandler; +import io.noties.markwon.inlineparser.BackslashInlineProcessor; import io.noties.markwon.inlineparser.BangInlineProcessor; import io.noties.markwon.inlineparser.HtmlInlineProcessor; import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; @@ -70,11 +67,10 @@ import ml.docilealligator.infinityforreddit.customviews.CommentIndentationView; import ml.docilealligator.infinityforreddit.customviews.SpoilerOnClickTextView; import ml.docilealligator.infinityforreddit.fragments.ViewPostDetailFragment; import ml.docilealligator.infinityforreddit.markdown.SpoilerParserPlugin; -import ml.docilealligator.infinityforreddit.markdown.SpoilerSpan; +import ml.docilealligator.infinityforreddit.markdown.SuperscriptInlineProcessor; import ml.docilealligator.infinityforreddit.post.Post; import ml.docilealligator.infinityforreddit.utils.APIUtils; import ml.docilealligator.infinityforreddit.utils.SharedPreferencesUtils; -import ml.docilealligator.infinityforreddit.markdown.SuperscriptInlineProcessor; import ml.docilealligator.infinityforreddit.utils.Utils; import retrofit2.Retrofit; diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/bottomsheetfragments/CopyTextBottomSheetFragment.java b/app/src/main/java/ml/docilealligator/infinityforreddit/bottomsheetfragments/CopyTextBottomSheetFragment.java index 1e458fc7..e30156dc 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/bottomsheetfragments/CopyTextBottomSheetFragment.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/bottomsheetfragments/CopyTextBottomSheetFragment.java @@ -74,7 +74,6 @@ public class CopyTextBottomSheetFragment extends LandscapeExpandedRoundedBottomS if (markdownText != null) { //markdownText = markdownText.replaceAll("", "^").replaceAll("", ""); - markdownText = markdownText.replaceAll(">!", ">!"); copyMarkdownTextView.setOnClickListener(view -> { showCopyDialog(markdownText); dismiss(); diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/markdown/BlockQuoteWithExceptionParser.java b/app/src/main/java/ml/docilealligator/infinityforreddit/markdown/BlockQuoteWithExceptionParser.java new file mode 100644 index 00000000..f500bb93 --- /dev/null +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/markdown/BlockQuoteWithExceptionParser.java @@ -0,0 +1,85 @@ +package ml.docilealligator.infinityforreddit.markdown; + +import org.commonmark.internal.util.Parsing; +import org.commonmark.node.Block; +import org.commonmark.node.BlockQuote; +import org.commonmark.parser.block.AbstractBlockParser; +import org.commonmark.parser.block.AbstractBlockParserFactory; +import org.commonmark.parser.block.BlockContinue; +import org.commonmark.parser.block.BlockStart; +import org.commonmark.parser.block.MatchedBlockParser; +import org.commonmark.parser.block.ParserState; + +// Parse and consume a block quote except when it's a spoiler opening +public class BlockQuoteWithExceptionParser extends AbstractBlockParser { + private final BlockQuote block = new BlockQuote(); + + private static boolean isMarker(ParserState state, int index) { + CharSequence line = state.getLine(); + return state.getIndent() < Parsing.CODE_BLOCK_INDENT && index < line.length() && line.charAt(index) == '>'; + } + + private static boolean isMarkerSpoiler(ParserState state, int index) { + CharSequence line = state.getLine(); + int length = line.length(); + try { + return state.getIndent() < Parsing.CODE_BLOCK_INDENT && index < length && line.charAt(index) == '>' && (index + 1) < length && line.charAt(index + 1) == '!'; + } catch (IndexOutOfBoundsException e) { + e.printStackTrace(); + return false; + } + } + + @Override + public boolean isContainer() { + return true; + } + + @Override + public boolean canContain(Block block) { + return true; + } + + @Override + public BlockQuote getBlock() { + return block; + } + + @Override + public BlockContinue tryContinue(ParserState state) { + int nextNonSpace = state.getNextNonSpaceIndex(); + if (isMarker(state, nextNonSpace)) { + int newColumn = state.getColumn() + state.getIndent() + 1; + // optional following space or tab + if (Parsing.isSpaceOrTab(state.getLine(), nextNonSpace + 1)) { + newColumn++; + } + return BlockContinue.atColumn(newColumn); + } else { + return BlockContinue.none(); + } + } + + public static class Factory extends AbstractBlockParserFactory { + public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) { + int nextNonSpace = state.getNextNonSpaceIndex(); + // Potential for a spoiler opening + // We don't check for spoiler closing, neither does Reddit + if (isMarkerSpoiler(state, nextNonSpace)) { + // It might be a spoiler, don't consume + return BlockStart.none(); + } + // Not a spoiler then + else if (isMarker(state, nextNonSpace)) { + int newColumn = state.getColumn() + state.getIndent() + 1; + // optional following space or tab + if (Parsing.isSpaceOrTab(state.getLine(), nextNonSpace + 1)) { + newColumn++; + } + return BlockStart.of(new BlockQuoteWithExceptionParser()).atColumn(newColumn); + } else { + return BlockStart.none(); + } + } + } +} diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/markdown/SpoilerParserPlugin.java b/app/src/main/java/ml/docilealligator/infinityforreddit/markdown/SpoilerParserPlugin.java index 47c484c9..36068b9a 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/markdown/SpoilerParserPlugin.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/markdown/SpoilerParserPlugin.java @@ -7,11 +7,20 @@ import android.widget.TextView; import androidx.annotation.NonNull; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import org.commonmark.node.Block; +import org.commonmark.node.BlockQuote; +import org.commonmark.node.HtmlBlock; +import org.commonmark.node.HtmlInline; +import org.commonmark.parser.Parser; + +import java.util.LinkedHashMap; +import java.util.Set; +import java.util.Stack; import io.noties.markwon.AbstractMarkwonPlugin; -import ml.docilealligator.infinityforreddit.customtheme.CustomThemeWrapper; +import io.noties.markwon.core.CorePlugin; +import io.noties.markwon.core.spans.CodeBlockSpan; +import io.noties.markwon.core.spans.CodeSpan; public class SpoilerParserPlugin extends AbstractMarkwonPlugin { private final int textColor; @@ -26,40 +35,158 @@ public class SpoilerParserPlugin extends AbstractMarkwonPlugin { return new SpoilerParserPlugin(textColor, backgroundColor); } + @Override + public void configureParser(@NonNull Parser.Builder builder) { + builder.customBlockParserFactory(new BlockQuoteWithExceptionParser.Factory()); + + Set> blocks = CorePlugin.enabledBlockTypes(); + blocks.remove(HtmlBlock.class); + blocks.remove(HtmlInline.class); + blocks.remove(BlockQuote.class); + + builder.enabledBlockTypes(blocks); + } + @Override public void afterSetText(@NonNull TextView textView) { textView.setHighlightColor(Color.TRANSPARENT); SpannableStringBuilder markdownStringBuilder = new SpannableStringBuilder(textView.getText()); - Pattern spoilerPattern = Pattern.compile(">!(\\n?(?:.+?(?:\\n?.+?)?)\\n?)!<"); - Matcher matcher = spoilerPattern.matcher(markdownStringBuilder); - int start = 0; - boolean find = false; - while (matcher.find(start)) { - if (markdownStringBuilder.length() < 4 - || matcher.start() < 0 - || matcher.end() > markdownStringBuilder.length()) { - break; + + LinkedHashMap spoilers = parse(markdownStringBuilder); + int offset = 2; + + for (var entry : spoilers.entrySet()) { + int spoilerStart = entry.getKey() - offset; + int spoilerEnd = entry.getValue() - offset; + + CodeSpan[] codeSpans = markdownStringBuilder.getSpans(spoilerStart, spoilerEnd, CodeSpan.class); + CodeBlockSpan[] codeBlockSpans = markdownStringBuilder.getSpans(spoilerStart, spoilerEnd, CodeBlockSpan.class); + + if (codeSpans.length == 0 && codeBlockSpans.length == 0) { + markdownStringBuilder.delete(spoilerStart, spoilerStart + 2); + markdownStringBuilder.delete(spoilerEnd, spoilerEnd + 2); + SpoilerSpan spoilerSpan = new SpoilerSpan(textColor, backgroundColor); + markdownStringBuilder.setSpan(spoilerSpan, spoilerStart, spoilerEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + offset += 4; + } + s + for (CodeSpan codeSpan : codeSpans) { + int spanBeginning = markdownStringBuilder.getSpanStart(codeSpan); + int spanEnd = markdownStringBuilder.getSpanEnd(codeSpan); + if (spoilerStart < spanBeginning && spanEnd < spoilerEnd) { + markdownStringBuilder.delete(spoilerStart, spoilerStart + 2); + markdownStringBuilder.delete(spoilerEnd, spoilerEnd + 2); + SpoilerSpan spoilerSpan = new SpoilerSpan(textColor, backgroundColor); + markdownStringBuilder.setSpan(spoilerSpan, spoilerStart, spoilerEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + offset += 4; + } else { + break; + } + } + + for (CodeBlockSpan codeBlockSpan : codeBlockSpans) { + int spanBeginning = markdownStringBuilder.getSpanStart(codeBlockSpan); + int spanEnd = markdownStringBuilder.getSpanEnd(codeBlockSpan); + if (spoilerStart < spanBeginning && spanEnd < spoilerEnd) { + markdownStringBuilder.delete(spoilerStart, spoilerStart + 2); + markdownStringBuilder.delete(spoilerEnd, spoilerEnd + 2); + SpoilerSpan spoilerSpan = new SpoilerSpan(textColor, backgroundColor); + markdownStringBuilder.setSpan(spoilerSpan, spoilerStart, spoilerEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + offset += 4; + } else { + break; + } } - find = true; - markdownStringBuilder.delete(matcher.end() - 2, matcher.end()); - markdownStringBuilder.delete(matcher.start(), matcher.start() + 2); - SpoilerSpan spoilerSpan = new SpoilerSpan(textColor, backgroundColor); - markdownStringBuilder.setSpan(spoilerSpan, matcher.start(), matcher.end() - 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - start = matcher.end() - 4; } - /*Pattern escapedSpoilerOpenerPattern = Pattern.compile(">!"); - Matcher escapedSpoilerOpenerMatcher = escapedSpoilerOpenerPattern.matcher(markdownStringBuilder); - ArrayList matched = new ArrayList<>(); - while (escapedSpoilerOpenerMatcher.find()) { - matched.add(escapedSpoilerOpenerMatcher.start()); - find = true; + + textView.setText(markdownStringBuilder); + } + + // Very naive implementation, needs to be improved for efficiency and edge cases + // Don't allow more than one new line after every non-blank line + // Try not to care about recursing spoilers, we just want the outermost spoiler because + // spoiler revealing-hiding breaks with recursing spoilers + // Try not to set a spoiler span if it's inside a CodeSpan + private LinkedHashMap parse(SpannableStringBuilder markdown) { + final int MAX_NEW_LINE = 1; + var openSpoilerStack = new Stack(); + var closedSpoilerMap = new LinkedHashMap(); + int variable_max_depth = calculateBalance(0, markdown) + 1; + int new_lines = 0; + int depth = 0; + for (int i = 0; i < markdown.length(); i++) { + if (markdown.charAt(i) == '\u2000' || markdown.charAt(i) == '\t') { + continue; + } else if (markdown.charAt(i) == '>' && (i + 1) < markdown.length() && markdown.charAt(i + 1) == '!') { + openSpoilerStack.push(i + 1); + depth++; + } else if (openSpoilerStack.size() > 0 + && markdown.charAt(i) == '!' && (i + 1) < markdown.length() + && markdown.charAt(i + 1) == '<') { + var pos = i + 1; + for (int j = 0; j < depth; j++) { + if (!openSpoilerStack.isEmpty()) pos = openSpoilerStack.peek(); + if (pos + 1 <= i) { + if (!openSpoilerStack.isEmpty()) pos = openSpoilerStack.peek(); + break; + } else { + if (!openSpoilerStack.isEmpty()) pos = openSpoilerStack.pop(); + } + } + if (depth <= variable_max_depth && pos + 1 <= i) //Spoiler content cannot be zero or less length + { + openSpoilerStack.clear(); + closedSpoilerMap.put(pos + 1, i); + } + depth--; + } else if (markdown.charAt(i) == '\n') { + new_lines++; + if (openSpoilerStack.size() >= 1 && new_lines > MAX_NEW_LINE) { + openSpoilerStack.clear(); + new_lines = 0; + depth = 0; + variable_max_depth = calculateBalance(i, markdown) + 1; + } + } else { + new_lines = 0; + } + + if (openSpoilerStack.size() >= 32) // No + { + openSpoilerStack.clear(); + closedSpoilerMap.clear(); + continue; + } } - for (int i = matched.size() - 1; i >= 0; i--) { - markdownStringBuilder.replace(matched.get(i), matched.get(i) + 4, ">"); - }*/ - if (find) { - textView.setText(markdownStringBuilder); + return closedSpoilerMap; + } + + private int calculateBalance(int index, SpannableStringBuilder line) { + final int MAX_NEW_LINE = 1; + int new_lines = 0; + int opening = 0; + int closing = 0; + for (int i = index; i < line.length(); i++) { + if (line.charAt(i) == '\u0020' || line.charAt(i) == '\t') { + continue; + } else if (line.charAt(i) == '>' + && (i + 1) < line.length() + && line.charAt(i + 1) == '!') { + opening++; + } else if (line.charAt(i) == '!' && (i + 1) < line.length() + && line.charAt(i + 1) == '<') { + closing++; + } else if (line.charAt(i) == '\n') { + new_lines++; + if (new_lines > MAX_NEW_LINE) { + break; + } + } else { + new_lines = 0; + continue; + } } + return Math.abs(opening - closing); } } diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/markdown/SpoilerSpan.java b/app/src/main/java/ml/docilealligator/infinityforreddit/markdown/SpoilerSpan.java index 5be3797c..2264449c 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/markdown/SpoilerSpan.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/markdown/SpoilerSpan.java @@ -53,6 +53,7 @@ public class SpoilerSpan extends ClickableSpan { @Override public void updateDrawState(@NonNull TextPaint ds) { if (isShowing) { + ds.bgColor = backgroundColor & 0x0D000000; //Slightly darker background color for revealed spoiler super.updateDrawState(ds); } else { ds.bgColor = backgroundColor; diff --git a/app/src/main/java/ml/docilealligator/infinityforreddit/utils/Utils.java b/app/src/main/java/ml/docilealligator/infinityforreddit/utils/Utils.java index 2963a58f..f292088d 100644 --- a/app/src/main/java/ml/docilealligator/infinityforreddit/utils/Utils.java +++ b/app/src/main/java/ml/docilealligator/infinityforreddit/utils/Utils.java @@ -65,7 +65,6 @@ public class Utils { .replaceAll("((?<=[\\s])|^)/[rRuU]/[\\w-]+/{0,1}", "[$0](https://www.reddit.com$0)") .replaceAll("((?<=[\\s])|^)[rRuU]/[\\w-]+/{0,1}", "[$0](https://www.reddit.com/$0)") .replaceAll("\\^{2,}", "^") - .replaceAll(">!(\\n?(?:.+?(?:\\n?.+?)?)\\n?)!<", ">!$1!<") // html entity remains escaped inside an inline block .replaceAll("(^|^ *|\\n *)#(?!($|\\s|#))", "$0 ") .replaceAll("(^|^ *|\\n *)##(?!($|\\s|#))", "$0 ") .replaceAll("(^|^ *|\\n *)###(?!($|\\s|#))", "$0 ")