From a2381fa00555623006c681be2ec1948a4d58c28f Mon Sep 17 00:00:00 2001 From: Infi Date: Sun, 17 Sep 2023 04:30:16 +0200 Subject: [PATCH] feat: support links in markdown Signed-off-by: Infi --- .../java/chat/revolt/api/internals/Members.kt | 9 ++++ .../chat/revolt/components/chat/Message.kt | 3 ++ .../revolt/components/generic/Markdown.kt | 15 +++--- .../revolt/internals/markdown/LinkSpan.kt | 22 +++++++++ .../internals/markdown/MarkdownNodes.kt | 13 +++++ .../internals/markdown/MarkdownRules.kt | 49 ++++++++++++++++++- .../chat/views/channel/ChannelScreen.kt | 16 +++--- 7 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/chat/revolt/internals/markdown/LinkSpan.kt diff --git a/app/src/main/java/chat/revolt/api/internals/Members.kt b/app/src/main/java/chat/revolt/api/internals/Members.kt index f13b4dfb..68fdba8c 100644 --- a/app/src/main/java/chat/revolt/api/internals/Members.kt +++ b/app/src/main/java/chat/revolt/api/internals/Members.kt @@ -29,4 +29,13 @@ class Members { fun clear() { memberCache.clear() } + + /** + * Returns a Map of userId to server-nickname for the given serverId. + */ + fun markdownMemberMapFor(serverId: String): Map { + return memberCache[serverId]?.mapNotNull { (userId, member) -> + member.nickname?.let { userId to member.nickname } + }?.toMap() ?: emptyMap() + } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/components/chat/Message.kt b/app/src/main/java/chat/revolt/components/chat/Message.kt index 39a889d4..07378f73 100644 --- a/app/src/main/java/chat/revolt/components/chat/Message.kt +++ b/app/src/main/java/chat/revolt/components/chat/Message.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.text.SpannableStringBuilder import android.text.TextUtils import android.text.format.DateUtils +import android.text.method.LinkMovementMethod import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -293,6 +294,8 @@ fun Message( textSize = 16f typeface = ResourcesCompat.getFont(ctx, R.font.inter) + movementMethod = LinkMovementMethod.getInstance() + setTextColor(contentColor.toArgb()) } }, diff --git a/app/src/main/java/chat/revolt/components/generic/Markdown.kt b/app/src/main/java/chat/revolt/components/generic/Markdown.kt index 435f5685..65a62124 100644 --- a/app/src/main/java/chat/revolt/components/generic/Markdown.kt +++ b/app/src/main/java/chat/revolt/components/generic/Markdown.kt @@ -2,6 +2,7 @@ package chat.revolt.components.generic import android.text.SpannableStringBuilder import android.text.TextUtils +import android.text.method.LinkMovementMethod import android.util.Log import android.util.TypedValue import android.view.ViewGroup @@ -24,13 +25,10 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.res.ResourcesCompat import chat.revolt.R import chat.revolt.api.RevoltAPI -import chat.revolt.internals.markdown.ChannelMentionRule -import chat.revolt.internals.markdown.CustomEmoteRule import chat.revolt.internals.markdown.MarkdownContext import chat.revolt.internals.markdown.MarkdownParser import chat.revolt.internals.markdown.MarkdownState -import chat.revolt.internals.markdown.TimestampRule -import chat.revolt.internals.markdown.UserMentionRule +import chat.revolt.internals.markdown.addRevoltRules import chat.revolt.internals.markdown.createCodeRule import chat.revolt.internals.markdown.createInlineCodeRule import com.discord.simpleast.core.simple.SimpleMarkdownRules @@ -58,12 +56,9 @@ fun UIMarkdown( LaunchedEffect(text) { val parser = MarkdownParser() .addRules( - SimpleMarkdownRules.createEscapeRule(), - UserMentionRule(), - ChannelMentionRule(), - CustomEmoteRule(), - TimestampRule(), + SimpleMarkdownRules.createEscapeRule() ) + .addRevoltRules() .addRules( createCodeRule(context, codeBlockColor.toArgb()), createInlineCodeRule(context, codeBlockColor.toArgb()), @@ -102,6 +97,8 @@ fun UIMarkdown( setMaxLines(maxLines) setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize.value) + movementMethod = LinkMovementMethod.getInstance() + layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT diff --git a/app/src/main/java/chat/revolt/internals/markdown/LinkSpan.kt b/app/src/main/java/chat/revolt/internals/markdown/LinkSpan.kt new file mode 100644 index 00000000..08a8c5ae --- /dev/null +++ b/app/src/main/java/chat/revolt/internals/markdown/LinkSpan.kt @@ -0,0 +1,22 @@ +package chat.revolt.internals.markdown + +import android.net.Uri +import android.text.TextPaint +import android.text.style.ClickableSpan +import android.view.View +import androidx.browser.customtabs.CustomTabsIntent + +class LinkSpan(private val url: String) : ClickableSpan() { + override fun onClick(widget: View) { + val customTab = CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + + customTab.launchUrl(widget.context, Uri.parse(url)) + } + + override fun updateDrawState(ds: TextPaint) { + ds.color = ds.linkColor + ds.isUnderlineText = false + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/internals/markdown/MarkdownNodes.kt b/app/src/main/java/chat/revolt/internals/markdown/MarkdownNodes.kt index 913f4cea..85a65f7c 100644 --- a/app/src/main/java/chat/revolt/internals/markdown/MarkdownNodes.kt +++ b/app/src/main/java/chat/revolt/internals/markdown/MarkdownNodes.kt @@ -1,6 +1,7 @@ package chat.revolt.internals.markdown import android.text.SpannableStringBuilder +import android.text.Spanned import android.text.format.DateUtils import android.util.Log import com.discord.simpleast.core.node.Node @@ -101,4 +102,16 @@ class TimestampNode(private val timestamp: Long, private val modifier: String? = builder.append("") } } +} + +class LinkNode(val content: String, val url: String = content) : Node() { + override fun render(builder: SpannableStringBuilder, renderContext: MarkdownContext) { + builder.append(content) + builder.setSpan( + LinkSpan(url), + builder.length - content.length, + builder.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/internals/markdown/MarkdownRules.kt b/app/src/main/java/chat/revolt/internals/markdown/MarkdownRules.kt index a2e7aa24..e10da4a0 100644 --- a/app/src/main/java/chat/revolt/internals/markdown/MarkdownRules.kt +++ b/app/src/main/java/chat/revolt/internals/markdown/MarkdownRules.kt @@ -67,6 +67,42 @@ class TimestampRule : } } +const val RE_LINK = + "?" + +class LinkRule : Rule, S>( + Pattern.compile("^$RE_LINK") +) { + override fun parse( + matcher: Matcher, + parser: Parser, S>, + state: S + ): ParseSpec { + val url = matcher.group(0)!!.trimStart('<').trimEnd('>') + return ParseSpec.createTerminal( + LinkNode(url), + state + ) + } +} + +class NamedLinkRule : Rule, S>( + Pattern.compile("^\\[([^]]+)]\\(($RE_LINK)\\)") +) { + override fun parse( + matcher: Matcher, + parser: Parser, S>, + state: S + ): ParseSpec { + val content = matcher.group(1)!! + val url = matcher.group(2)!!.trimStart('<').trimEnd('>') + return ParseSpec.createTerminal( + LinkNode(content, url), + state + ) + } +} + fun createInlineCodeRule(context: Context, backgroundColor: Int): Rule, S> { return CodeRules.createInlineCodeRule( { listOf(TextAppearanceSpan(context, R.style.Code_TextAppearance)) }, @@ -155,4 +191,15 @@ fun createCodeRule( ) } } -} \ No newline at end of file +} + +fun MarkdownParser.addRevoltRules(): MarkdownParser { + return addRules( + UserMentionRule(), + ChannelMentionRule(), + CustomEmoteRule(), + TimestampRule(), + NamedLinkRule(), + LinkRule(), + ) +} diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index 6385e0c5..ad223ce5 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -77,13 +77,10 @@ import chat.revolt.components.screens.chat.AttachmentManager import chat.revolt.components.screens.chat.ChannelHeader import chat.revolt.components.screens.chat.ReplyManager import chat.revolt.components.screens.chat.TypingIndicator -import chat.revolt.internals.markdown.ChannelMentionRule -import chat.revolt.internals.markdown.CustomEmoteRule import chat.revolt.internals.markdown.MarkdownContext import chat.revolt.internals.markdown.MarkdownParser import chat.revolt.internals.markdown.MarkdownState -import chat.revolt.internals.markdown.TimestampRule -import chat.revolt.internals.markdown.UserMentionRule +import chat.revolt.internals.markdown.addRevoltRules import chat.revolt.internals.markdown.createCodeRule import chat.revolt.internals.markdown.createInlineCodeRule import chat.revolt.sheets.ChannelInfoSheet @@ -300,11 +297,8 @@ fun ChannelScreen( val parser = MarkdownParser() .addRules( SimpleMarkdownRules.createEscapeRule(), - UserMentionRule(), - ChannelMentionRule(), - CustomEmoteRule(), - TimestampRule(), ) + .addRevoltRules() .addRules( createCodeRule(context, codeBlockColor.toArgb()), createInlineCodeRule(context, codeBlockColor.toArgb()), @@ -320,7 +314,11 @@ fun ChannelScreen( parser = parser, initialState = MarkdownState(0), renderContext = MarkdownContext( - memberMap = mapOf(), + memberMap = viewModel.activeChannel?.server?.let { serverId -> + RevoltAPI.members.markdownMemberMapFor( + serverId + ) + } ?: mapOf(), userMap = RevoltAPI.userCache.toMap(), channelMap = RevoltAPI.channelCache.mapValues { ch -> ch.value.name ?: ch.value.id ?: "#DeletedChannel"