From e444f8f729e347d55cb0f913faef2b6396ee8a21 Mon Sep 17 00:00:00 2001 From: qwerty287 Date: Fri, 11 Feb 2022 15:12:22 +0100 Subject: [PATCH] Improve md link opening (#1023) * Open issue/mention links direclty instead of using `DeepLinksActivity` * open in custom tabs if enabled * improve code Co-authored-by: qwerty287 Co-authored-by: M M Arif Reviewed-on: https://codeberg.org/gitnex/GitNex/pulls/1023 Reviewed-by: M M Arif Co-authored-by: qwerty287 Co-committed-by: qwerty287 --- .../org/mian/gitnex/helpers/Markdown.java | 445 +++++++++++++----- 1 file changed, 319 insertions(+), 126 deletions(-) diff --git a/app/src/main/java/org/mian/gitnex/helpers/Markdown.java b/app/src/main/java/org/mian/gitnex/helpers/Markdown.java index 5f779b82..51e03a9d 100644 --- a/app/src/main/java/org/mian/gitnex/helpers/Markdown.java +++ b/app/src/main/java/org/mian/gitnex/helpers/Markdown.java @@ -1,6 +1,7 @@ package org.mian.gitnex.helpers; import android.content.Context; +import android.content.Intent; import android.graphics.Typeface; import android.text.Spanned; import android.widget.TextView; @@ -9,12 +10,18 @@ import androidx.core.content.res.ResourcesCompat; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import org.commonmark.ext.gfm.tables.TableBlock; +import org.commonmark.node.AbstractVisitor; import org.commonmark.node.FencedCodeBlock; +import org.commonmark.node.Image; import org.commonmark.node.Link; import org.commonmark.node.Node; +import org.commonmark.node.Text; import org.commonmark.parser.InlineParserFactory; import org.commonmark.parser.Parser; +import org.commonmark.parser.PostProcessor; import org.mian.gitnex.R; +import org.mian.gitnex.activities.IssueDetailActivity; +import org.mian.gitnex.activities.ProfileActivity; import org.mian.gitnex.clients.PicassoService; import org.mian.gitnex.core.MainGrammarLocator; import java.util.Objects; @@ -26,6 +33,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import io.noties.markwon.AbstractMarkwonPlugin; import io.noties.markwon.Markwon; +import io.noties.markwon.MarkwonConfiguration; import io.noties.markwon.SoftBreakAddsNewLinePlugin; import io.noties.markwon.core.CorePlugin; import io.noties.markwon.core.MarkwonTheme; @@ -68,8 +76,8 @@ public class Markdown { private static final Timeout timeout = new Timeout(MAX_CLAIM_TIMEOUT_SECONDS, TimeUnit.SECONDS); - private static final ExecutorService executorService = - new ThreadPoolExecutor(MAX_POOL_SIZE / 2, MAX_POOL_SIZE, MAX_THREAD_KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, new SynchronousQueue<>()); + private static final ExecutorService executorService = new ThreadPoolExecutor(MAX_POOL_SIZE / 2, MAX_POOL_SIZE, MAX_THREAD_KEEP_ALIVE_SECONDS, + TimeUnit.SECONDS, new SynchronousQueue<>()); private static final Pool rendererPool; private static final Pool rvRendererPool; @@ -84,11 +92,15 @@ public class Markdown { config.setAllocator(new Allocator() { @Override - public Renderer allocate(Slot slot) throws Exception { + public Renderer allocate(Slot slot) { + return new Renderer(slot); } - @Override public void deallocate(Renderer poolable) throws Exception {} + @Override + public void deallocate(Renderer poolable) { + + } }); @@ -103,10 +115,14 @@ public class Markdown { @Override public RecyclerViewRenderer allocate(Slot slot) { + return new RecyclerViewRenderer(slot); } - @Override public void deallocate(RecyclerViewRenderer poolable) {} + @Override + public void deallocate(RecyclerViewRenderer poolable) { + + } }); @@ -123,7 +139,9 @@ public class Markdown { renderer.setParameters(context, markdown, textView); executorService.execute(renderer); } - } catch(InterruptedException ignored) {} + } + catch(InterruptedException ignored) { + } } public static void render(Context context, String markdown, RecyclerView recyclerView) { @@ -135,7 +153,9 @@ public class Markdown { renderer.setParameters(context, markdown, recyclerView); executorService.execute(renderer); } - } catch(InterruptedException ignored) {} + } + catch(InterruptedException ignored) { + } } private static class Renderer implements Runnable, Poolable { @@ -149,31 +169,27 @@ public class Markdown { private TextView textView; public Renderer(Slot slot) { + this.slot = slot; } private void setup() { - Prism4jTheme prism4jTheme = TinyDB.getInstance(context).getString("currentTheme").equals("dark") ? - Prism4jThemeDarkula.create() : - Prism4jThemeDefault.create(); + Prism4jTheme prism4jTheme = + TinyDB.getInstance(context).getString("currentTheme").equals("dark") ? Prism4jThemeDarkula.create() : Prism4jThemeDefault.create(); - Markwon.Builder builder = Markwon.builder(context) - .usePlugin(CorePlugin.create()) - .usePlugin(HtmlPlugin.create()) - .usePlugin(LinkifyPlugin.create(true)) - .usePlugin(SoftBreakAddsNewLinePlugin.create()) - .usePlugin(TablePlugin.create(context)) - .usePlugin(MovementMethodPlugin.create(TableAwareMovementMethod.create())) - .usePlugin(TaskListPlugin.create(context)) - .usePlugin(StrikethroughPlugin.create()) - .usePlugin(PicassoImagesPlugin.create(PicassoService.getInstance(context).get())) - .usePlugin(SyntaxHighlightPlugin.create(new Prism4j(MainGrammarLocator.getInstance()), prism4jTheme, MainGrammarLocator.DEFAULT_FALLBACK_LANGUAGE)) + Markwon.Builder builder = Markwon.builder(context).usePlugin(CorePlugin.create()).usePlugin(HtmlPlugin.create()) + .usePlugin(LinkifyPlugin.create(true)).usePlugin(SoftBreakAddsNewLinePlugin.create()).usePlugin(TablePlugin.create(context)) + .usePlugin(MovementMethodPlugin.create(TableAwareMovementMethod.create())).usePlugin(TaskListPlugin.create(context)) + .usePlugin(StrikethroughPlugin.create()).usePlugin(PicassoImagesPlugin.create(PicassoService.getInstance(context).get())).usePlugin( + SyntaxHighlightPlugin + .create(new Prism4j(MainGrammarLocator.getInstance()), prism4jTheme, MainGrammarLocator.DEFAULT_FALLBACK_LANGUAGE)) .usePlugin(new AbstractMarkwonPlugin() { private Typeface tf; private void setupTf(Context context) { + switch(TinyDB.getInstance(context).getInt("customFontId", -1)) { case 0: tf = Typeface.createFromAsset(context.getAssets(), "fonts/roboto.ttf"); @@ -189,13 +205,17 @@ public class Markdown { @Override public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) { - if(tf == null) setupTf(textView.getContext()); + + if(tf == null) { + setupTf(textView.getContext()); + } textView.setTypeface(tf); super.beforeSetText(textView, markdown); } @Override public void configureTheme(@NonNull MarkwonTheme.Builder builder) { + builder.codeBlockTypeface(Typeface.createFromAsset(context.getAssets(), "fonts/sourcecodeproregular.ttf")); builder.codeBlockMargin((int) (context.getResources().getDisplayMetrics().density * 10)); builder.blockMargin((int) (context.getResources().getDisplayMetrics().density * 10)); @@ -203,7 +223,9 @@ public class Markdown { builder.codeTypeface(Typeface.createFromAsset(context.getAssets(), "fonts/sourcecodeproregular.ttf")); builder.linkColor(ResourcesCompat.getColor(context.getResources(), R.color.lightBlue, null)); - if(tf == null) setupTf(context); + if(tf == null) { + setupTf(context); + } builder.headingTypeface(tf); } }); @@ -225,7 +247,9 @@ public class Markdown { Objects.requireNonNull(markdown); Objects.requireNonNull(textView); - if(markwon == null) setup(); + if(markwon == null) { + setup(); + } Spanned processedMarkdown = markwon.toMarkdown(markdown); @@ -248,8 +272,10 @@ public class Markdown { } public void expire() { + slot.expire(this); } + } private static class RecyclerViewRenderer implements Runnable, Poolable { @@ -264,6 +290,7 @@ public class Markdown { private MarkwonAdapter adapter; public RecyclerViewRenderer(Slot slot) { + this.slot = slot; } @@ -271,31 +298,26 @@ public class Markdown { Objects.requireNonNull(context); - Prism4jTheme prism4jTheme = TinyDB.getInstance(context).getString("currentTheme").equals("dark") ? - Prism4jThemeDarkula.create() : - Prism4jThemeDefault.create(); + Prism4jTheme prism4jTheme = + TinyDB.getInstance(context).getString("currentTheme").equals("dark") ? Prism4jThemeDarkula.create() : Prism4jThemeDefault.create(); - final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilder() - .addInlineProcessor(new IssueInlineProcessor(context)) - .addInlineProcessor(new UserInlineProcessor(context)) - .build(); + final InlineParserFactory inlineParserFactory = MarkwonInlineParser.factoryBuilder().addInlineProcessor(new IssueInlineProcessor()) + .addInlineProcessor(new UserInlineProcessor()).build(); - Markwon.Builder builder = Markwon.builder(context) - .usePlugin(CorePlugin.create()) - .usePlugin(HtmlPlugin.create()) + Markwon.Builder builder = Markwon.builder(context).usePlugin(CorePlugin.create()).usePlugin(HtmlPlugin.create()) .usePlugin(LinkifyPlugin.create(true)) // TODO not working - .usePlugin(SoftBreakAddsNewLinePlugin.create()) - .usePlugin(TableEntryPlugin.create(context)) - .usePlugin(MovementMethodPlugin.create(TableAwareMovementMethod.create())) - .usePlugin(TaskListPlugin.create(context)) - .usePlugin(StrikethroughPlugin.create()) - .usePlugin(PicassoImagesPlugin.create(PicassoService.getInstance(context).get())) - .usePlugin(SyntaxHighlightPlugin.create(new Prism4j(MainGrammarLocator.getInstance()), prism4jTheme, MainGrammarLocator.DEFAULT_FALLBACK_LANGUAGE)) + .usePlugin(SoftBreakAddsNewLinePlugin.create()).usePlugin(TableEntryPlugin.create(context)) + .usePlugin(MovementMethodPlugin.create(TableAwareMovementMethod.create())).usePlugin(TaskListPlugin.create(context)) + .usePlugin(StrikethroughPlugin.create()).usePlugin(PicassoImagesPlugin.create(PicassoService.getInstance(context).get())).usePlugin( + SyntaxHighlightPlugin + .create(new Prism4j(MainGrammarLocator.getInstance()), prism4jTheme, MainGrammarLocator.DEFAULT_FALLBACK_LANGUAGE)) .usePlugin(new AbstractMarkwonPlugin() { + private final Context context = RecyclerViewRenderer.this.context; private Typeface tf; private void setupTf(Context context) { + switch(TinyDB.getInstance(context).getInt("customFontId", -1)) { case 0: tf = Typeface.createFromAsset(context.getAssets(), "fonts/roboto.ttf"); @@ -311,18 +333,24 @@ public class Markdown { @Override public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) { - if(tf == null) setupTf(textView.getContext()); + + if(tf == null) { + setupTf(textView.getContext()); + } textView.setTypeface(tf); super.beforeSetText(textView, markdown); } @Override public void configureParser(@NonNull Parser.Builder builder) { + builder.inlineParserFactory(inlineParserFactory); + builder.postProcessor(new LinkPostProcessor(TinyDB.getInstance(context), context.getString(R.string.commentButtonText))); } @Override public void configureTheme(@NonNull MarkwonTheme.Builder builder) { + builder.codeBlockTypeface(Typeface.createFromAsset(context.getAssets(), "fonts/sourcecodeproregular.ttf")); builder.codeBlockMargin((int) (context.getResources().getDisplayMetrics().density * 10)); builder.blockMargin((int) (context.getResources().getDisplayMetrics().density * 10)); @@ -330,63 +358,73 @@ public class Markdown { builder.codeTypeface(Typeface.createFromAsset(context.getAssets(), "fonts/sourcecodeproregular.ttf")); builder.linkColor(ResourcesCompat.getColor(context.getResources(), R.color.lightBlue, null)); - if(tf == null) setupTf(context); + if(tf == null) { + setupTf(context); + } builder.headingTypeface(Typeface.create(tf, Typeface.BOLD)); } + + @Override + public void configureConfiguration(@NonNull MarkwonConfiguration.Builder builder) { + + builder.linkResolver((view, link) -> { + if(link.startsWith("gitnexuser://")) { + Intent i = new Intent(view.getContext(), ProfileActivity.class); + i.putExtra("username", link.substring(13)); + view.getContext().startActivity(i); + } + else if(link.startsWith("gitnexissue://")) { + link = link.substring(14); // remove gitnexissue:// + Intent i = new Intent(view.getContext(), IssueDetailActivity.class); + String index; + TinyDB tinyDB = TinyDB.getInstance(context); + if(link.contains("/")) { + index = link.split("#")[1]; + tinyDB.putString("repoFullName", link.split("#")[0]); + i.putExtra("openedFromLink", "true"); + } + else { + index = link.substring(1); + } + + tinyDB.putString("issueNumber", index); + i.putExtra("issueNumber", index); + view.getContext().startActivity(i); + } + else if(link.startsWith("gitnexcommit://")) { + // this is not supported by GitNex itself right now, so let's open the browser + TinyDB tinyDB = TinyDB.getInstance(context); + String instanceUrl = tinyDB.getString("instanceUrl"); + instanceUrl = instanceUrl.substring(0, instanceUrl.lastIndexOf("api/v1/")); + link = link.substring(15); + if(link.contains("/")) { + AppUtil.openUrlInBrowser(context, + instanceUrl + link.substring(0, link.lastIndexOf("/")) + "/commit/" + link.split("/")[2]); + } + else { + AppUtil.openUrlInBrowser(context, instanceUrl + tinyDB.getString("repoFullName") + "/commit/" + link); + } + } + else { + AppUtil.openUrlInBrowser(view.getContext(), link); + } + }); + super.configureConfiguration(builder); + } }); markwon = builder.build(); } private void setupAdapter() { - adapter = MarkwonAdapter.builderTextViewIsRoot(R.layout.custom_markdown_adapter) - .include(TableBlock.class, TableEntry.create(builder2 -> builder2 - .tableLayout(R.layout.custom_markdown_table, R.id.table_layout) + + adapter = MarkwonAdapter.builderTextViewIsRoot(R.layout.custom_markdown_adapter).include(TableBlock.class, TableEntry.create( + builder2 -> builder2.tableLayout(R.layout.custom_markdown_table, R.id.table_layout) .textLayoutIsRoot(R.layout.custom_markdown_adapter))) - .include(FencedCodeBlock.class, SimpleEntry.create(R.layout.custom_markdown_code_block, R.id.textCodeBlock)) - .build(); + .include(FencedCodeBlock.class, SimpleEntry.create(R.layout.custom_markdown_code_block, R.id.textCodeBlock)).build(); } public void setParameters(Context context, String markdown, RecyclerView recyclerView) { - TinyDB tinyDB = TinyDB.getInstance(context); - String instanceUrl = tinyDB.getString("instanceUrl"); - instanceUrl = instanceUrl.substring(0, instanceUrl.lastIndexOf("api/v1/")).replaceAll("\\.", "\\."); - - // first step: replace comment urls with {url without comment} (comment) - final Pattern patternComment = Pattern.compile("((? { localReference.setLayoutManager(new LinearLayoutManager(context) { + @Override public boolean canScrollVertically() { + return false; // disable RecyclerView scrolling, handeled by seperate ScrollViews } }); @@ -437,81 +479,232 @@ public class Markdown { } public void expire() { + slot.expire(this); } + } private static class IssueInlineProcessor extends InlineProcessor { - private final Context context; - - public IssueInlineProcessor(Context context) { - this.context = context; - } - - private static final Pattern RE = Pattern.compile("(?<=#)\\d+"); + private static final Pattern RE = Pattern.compile("(? i) { + lastNode = insertNode(new Text(literal.substring(foundAt, matcherCommit.start())), lastNode); + } + String shortSha = matcherCommit.group(2); + if(shortSha == null) { + return; + } + if(shortSha.length() > 10) { + shortSha = shortSha.substring(0, 10); + } + String text; + if(matcherCommit.group(1).equals(fullRepoName)) { + text = shortSha; + } + else { + text = matcherCommit.group(1) + "/" + shortSha; + } + Text contentNode = new Text(text); + Link linkNode = new Link("gitnexcommit://" + text, null); + linkNode.appendChild(contentNode); + lastNode = insertNode(linkNode, lastNode); + + i = matcherCommit.start(); + } + else if(issueStart < literal.length()) { + // next one is an issue/comment + if(matcherIssue.start() > i) { + lastNode = insertNode(new Text(literal.substring(i, matcherIssue.start())), lastNode); + } + + String text; + if(matcherIssue.group(1).equals(fullRepoName)) { + text = "#" + matcherIssue.group(2); + } + else { + text = matcherIssue.group(1) + "#" + matcherIssue.group(2); + } + Text contentNode = new Text(text); + Link linkNode = new Link("gitnexissue://" + text, null); + linkNode.appendChild(contentNode); + lastNode = insertNode(linkNode, lastNode); + + String anchor = matcherIssue.group(3); + if(anchor != null && anchor.startsWith("issuecomment-")) { + // comment + + // insert space + lastNode = insertNode(new Text(" "), lastNode); + + Text commentNode = new Text("(" + commentText + ")"); + Link linkCommentNode = new Link(matcherIssue.group(), null); + linkCommentNode.appendChild(commentNode); + lastNode = insertNode(linkCommentNode, lastNode); + } + + i = matcherIssue.end(); + } + + // reset every time to make it usable in a "pure" state + matcherCommit.reset(); + matcherIssue.reset(); + } + + if(foundAny) { + textNode.unlink(); + } + } + + private void linkifyImage(Image node) { + + final Matcher patternAttachments = Pattern.compile("(/attachments/\\S+)", Pattern.MULTILINE).matcher(node.getDestination()); + if(patternAttachments.matches()) { + node.setDestination(instanceUrl + fullRepoName + patternAttachments.group(1)); + } + } + + private class AutolinkVisitor extends AbstractVisitor { + + int inLink = 0; + + @Override + public void visit(Link link) { + + inLink++; + super.visit(link); + inLink--; + } + + @Override + public void visit(Image image) { + + super.visit(image); + linkifyImage(image); + } + + @Override + public void visit(Text text) { + + if(inLink == 0) { + link(text); + } + } + + } + + } + }