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() {
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.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())
}
},

View File

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

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
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("<invalid timestamp>")
}
}
}
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> {
return CodeRules.createInlineCodeRule(
{ listOf(TextAppearanceSpan(context, R.style.Code_TextAppearance)) },
@ -155,4 +191,15 @@ 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.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"