feat: support links in markdown

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-09-17 04:30:16 +02:00
parent 92a21296fe
commit a2381fa005
7 changed files with 108 additions and 19 deletions

View File

@ -29,4 +29,13 @@ class Members {
fun clear() { fun clear() {
memberCache.clear() memberCache.clear()
} }
/**
* Returns a Map of userId to server-nickname for the given serverId.
*/
fun markdownMemberMapFor(serverId: String): Map<String, String> {
return memberCache[serverId]?.mapNotNull { (userId, member) ->
member.nickname?.let { userId to member.nickname }
}?.toMap() ?: emptyMap()
}
} }

View File

@ -6,6 +6,7 @@ import android.net.Uri
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.TextUtils import android.text.TextUtils
import android.text.format.DateUtils import android.text.format.DateUtils
import android.text.method.LinkMovementMethod
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -293,6 +294,8 @@ fun Message(
textSize = 16f textSize = 16f
typeface = ResourcesCompat.getFont(ctx, R.font.inter) typeface = ResourcesCompat.getFont(ctx, R.font.inter)
movementMethod = LinkMovementMethod.getInstance()
setTextColor(contentColor.toArgb()) setTextColor(contentColor.toArgb())
} }
}, },

View File

@ -2,6 +2,7 @@ package chat.revolt.components.generic
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.TextUtils import android.text.TextUtils
import android.text.method.LinkMovementMethod
import android.util.Log import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.ViewGroup import android.view.ViewGroup
@ -24,13 +25,10 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import chat.revolt.R import chat.revolt.R
import chat.revolt.api.RevoltAPI 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.MarkdownContext
import chat.revolt.internals.markdown.MarkdownParser import chat.revolt.internals.markdown.MarkdownParser
import chat.revolt.internals.markdown.MarkdownState import chat.revolt.internals.markdown.MarkdownState
import chat.revolt.internals.markdown.TimestampRule import chat.revolt.internals.markdown.addRevoltRules
import chat.revolt.internals.markdown.UserMentionRule
import chat.revolt.internals.markdown.createCodeRule import chat.revolt.internals.markdown.createCodeRule
import chat.revolt.internals.markdown.createInlineCodeRule import chat.revolt.internals.markdown.createInlineCodeRule
import com.discord.simpleast.core.simple.SimpleMarkdownRules import com.discord.simpleast.core.simple.SimpleMarkdownRules
@ -58,12 +56,9 @@ fun UIMarkdown(
LaunchedEffect(text) { LaunchedEffect(text) {
val parser = MarkdownParser() val parser = MarkdownParser()
.addRules( .addRules(
SimpleMarkdownRules.createEscapeRule(), SimpleMarkdownRules.createEscapeRule()
UserMentionRule(),
ChannelMentionRule(),
CustomEmoteRule(),
TimestampRule(),
) )
.addRevoltRules()
.addRules( .addRules(
createCodeRule(context, codeBlockColor.toArgb()), createCodeRule(context, codeBlockColor.toArgb()),
createInlineCodeRule(context, codeBlockColor.toArgb()), createInlineCodeRule(context, codeBlockColor.toArgb()),
@ -102,6 +97,8 @@ fun UIMarkdown(
setMaxLines(maxLines) setMaxLines(maxLines)
setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize.value) setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize.value)
movementMethod = LinkMovementMethod.getInstance()
layoutParams = ViewGroup.LayoutParams( layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT

View File

@ -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
}
}

View File

@ -1,6 +1,7 @@
package chat.revolt.internals.markdown package chat.revolt.internals.markdown
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.format.DateUtils import android.text.format.DateUtils
import android.util.Log import android.util.Log
import com.discord.simpleast.core.node.Node import com.discord.simpleast.core.node.Node
@ -102,3 +103,15 @@ class TimestampNode(private val timestamp: Long, private val modifier: String? =
} }
} }
} }
class LinkNode(val content: String, val url: String = content) : Node<MarkdownContext>() {
override fun render(builder: SpannableStringBuilder, renderContext: MarkdownContext) {
builder.append(content)
builder.setSpan(
LinkSpan(url),
builder.length - content.length,
builder.length,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}

View File

@ -67,6 +67,42 @@ class TimestampRule<S> :
} }
} }
const val RE_LINK =
"<?https?://(www\\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\\.[a-z]{2,4}\\b([-a-zA-Z0-9@:%_+.~#?&/=]*)>?"
class LinkRule<S> : Rule<MarkdownContext, Node<MarkdownContext>, S>(
Pattern.compile("^$RE_LINK")
) {
override fun parse(
matcher: Matcher,
parser: Parser<MarkdownContext, in Node<MarkdownContext>, S>,
state: S
): ParseSpec<MarkdownContext, S> {
val url = matcher.group(0)!!.trimStart('<').trimEnd('>')
return ParseSpec.createTerminal(
LinkNode(url),
state
)
}
}
class NamedLinkRule<S> : Rule<MarkdownContext, Node<MarkdownContext>, S>(
Pattern.compile("^\\[([^]]+)]\\(($RE_LINK)\\)")
) {
override fun parse(
matcher: Matcher,
parser: Parser<MarkdownContext, in Node<MarkdownContext>, S>,
state: S
): ParseSpec<MarkdownContext, S> {
val content = matcher.group(1)!!
val url = matcher.group(2)!!.trimStart('<').trimEnd('>')
return ParseSpec.createTerminal(
LinkNode(content, url),
state
)
}
}
fun <RC, S> createInlineCodeRule(context: Context, backgroundColor: Int): Rule<RC, Node<RC>, S> { fun <RC, S> createInlineCodeRule(context: Context, backgroundColor: Int): Rule<RC, Node<RC>, S> {
return CodeRules.createInlineCodeRule( return CodeRules.createInlineCodeRule(
{ listOf(TextAppearanceSpan(context, R.style.Code_TextAppearance)) }, { listOf(TextAppearanceSpan(context, R.style.Code_TextAppearance)) },
@ -156,3 +192,14 @@ fun <RC> createCodeRule(
} }
} }
} }
fun MarkdownParser.addRevoltRules(): MarkdownParser {
return addRules(
UserMentionRule(),
ChannelMentionRule(),
CustomEmoteRule(),
TimestampRule(),
NamedLinkRule(),
LinkRule(),
)
}

View File

@ -77,13 +77,10 @@ import chat.revolt.components.screens.chat.AttachmentManager
import chat.revolt.components.screens.chat.ChannelHeader import chat.revolt.components.screens.chat.ChannelHeader
import chat.revolt.components.screens.chat.ReplyManager import chat.revolt.components.screens.chat.ReplyManager
import chat.revolt.components.screens.chat.TypingIndicator 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.MarkdownContext
import chat.revolt.internals.markdown.MarkdownParser import chat.revolt.internals.markdown.MarkdownParser
import chat.revolt.internals.markdown.MarkdownState import chat.revolt.internals.markdown.MarkdownState
import chat.revolt.internals.markdown.TimestampRule import chat.revolt.internals.markdown.addRevoltRules
import chat.revolt.internals.markdown.UserMentionRule
import chat.revolt.internals.markdown.createCodeRule import chat.revolt.internals.markdown.createCodeRule
import chat.revolt.internals.markdown.createInlineCodeRule import chat.revolt.internals.markdown.createInlineCodeRule
import chat.revolt.sheets.ChannelInfoSheet import chat.revolt.sheets.ChannelInfoSheet
@ -300,11 +297,8 @@ fun ChannelScreen(
val parser = MarkdownParser() val parser = MarkdownParser()
.addRules( .addRules(
SimpleMarkdownRules.createEscapeRule(), SimpleMarkdownRules.createEscapeRule(),
UserMentionRule(),
ChannelMentionRule(),
CustomEmoteRule(),
TimestampRule(),
) )
.addRevoltRules()
.addRules( .addRules(
createCodeRule(context, codeBlockColor.toArgb()), createCodeRule(context, codeBlockColor.toArgb()),
createInlineCodeRule(context, codeBlockColor.toArgb()), createInlineCodeRule(context, codeBlockColor.toArgb()),
@ -320,7 +314,11 @@ fun ChannelScreen(
parser = parser, parser = parser,
initialState = MarkdownState(0), initialState = MarkdownState(0),
renderContext = MarkdownContext( renderContext = MarkdownContext(
memberMap = mapOf(), memberMap = viewModel.activeChannel?.server?.let { serverId ->
RevoltAPI.members.markdownMemberMapFor(
serverId
)
} ?: mapOf(),
userMap = RevoltAPI.userCache.toMap(), userMap = RevoltAPI.userCache.toMap(),
channelMap = RevoltAPI.channelCache.mapValues { ch -> channelMap = RevoltAPI.channelCache.mapValues { ch ->
ch.value.name ?: ch.value.id ?: "#DeletedChannel" ch.value.name ?: ch.value.id ?: "#DeletedChannel"