feat: make markdown good

This commit is contained in:
infi 2026-06-05 08:29:37 +02:00
parent 5e555317e2
commit 39a025fcaa
49 changed files with 955 additions and 2470 deletions

View File

@ -278,6 +278,7 @@ dependencies {
implementation(libs.multiplatform.markdown.android) implementation(libs.multiplatform.markdown.android)
implementation(libs.multiplatform.markdown.m3) implementation(libs.multiplatform.markdown.m3)
implementation(libs.multiplatform.markdown.coil3) implementation(libs.multiplatform.markdown.coil3)
implementation(libs.multiplatform.markdown.code)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)

View File

@ -26,10 +26,8 @@ class ExperimentInstance(default: Boolean) {
* - All experiments can be disabled at once with a single toggle. * - All experiments can be disabled at once with a single toggle.
*/ */
object Experiments { object Experiments {
val useKotlinBasedMarkdownRenderer = ExperimentInstance(false)
val usePolar = ExperimentInstance(false) val usePolar = ExperimentInstance(false)
val enableServerIdentityOptions = ExperimentInstance(false) val enableServerIdentityOptions = ExperimentInstance(false)
val useFinalMarkdownRenderer = ExperimentInstance(false)
val useVoiceChats2p0 = ExperimentInstance(false) val useVoiceChats2p0 = ExperimentInstance(false)
suspend fun hydrateWithKv() { suspend fun hydrateWithKv() {
@ -41,26 +39,14 @@ object Experiments {
LoadedSettings.experimentsEnabled = kvStorage.getBoolean("experimentsEnabled") == true LoadedSettings.experimentsEnabled = kvStorage.getBoolean("experimentsEnabled") == true
} }
useKotlinBasedMarkdownRenderer.setEnabled(
kvStorage.getBoolean("exp/useKotlinBasedMarkdownRenderer") == true
)
usePolar.setEnabled( usePolar.setEnabled(
kvStorage.getBoolean("exp/usePolar") == true kvStorage.getBoolean("exp/usePolar") == true
) )
enableServerIdentityOptions.setEnabled( enableServerIdentityOptions.setEnabled(
kvStorage.getBoolean("exp/enableServerIdentityOptions") == true kvStorage.getBoolean("exp/enableServerIdentityOptions") == true
) )
useFinalMarkdownRenderer.setEnabled(
kvStorage.getBoolean("exp/useFinalMarkdownRenderer") == true
)
useVoiceChats2p0.setEnabled( useVoiceChats2p0.setEnabled(
kvStorage.getBoolean("exp/useVoiceChats2p0") == true kvStorage.getBoolean("exp/useVoiceChats2p0") == true
) )
if (useFinalMarkdownRenderer.isEnabled && useKotlinBasedMarkdownRenderer.isEnabled) {
// if jbm and fm are enabled, fm takes precedence. this should not be possible in practice
useKotlinBasedMarkdownRenderer.setEnabled(false)
kvStorage.set("exp/useKotlinBasedMarkdownRenderer", false)
}
} }
} }

View File

@ -30,31 +30,7 @@ sealed class UserCardsVariates {
data class Restricted(val predicate: () -> Boolean) : UserCardsVariates() data class Restricted(val predicate: () -> Boolean) : UserCardsVariates()
} }
@FeatureFlag("MassMentions")
sealed class MassMentionsVariates {
@Treatment(
"Enable mass mentions and role mentions for all users"
)
object Enabled : MassMentionsVariates()
@Treatment(
"Disable mass mentions and role mentions for all users"
)
object Disabled : MassMentionsVariates()
}
@FeatureFlag("FinalMarkdown")
sealed class FinalMarkdownVariates {
@Treatment(
"Enable the new FinalMarkdown library for all users"
)
object Enabled : FinalMarkdownVariates()
@Treatment(
"Disable the new FinalMarkdown library for all users"
)
object Disabled : FinalMarkdownVariates()
}
object FeatureFlags { object FeatureFlags {
@FeatureFlag("LabsAccessControl") @FeatureFlag("LabsAccessControl")
@ -82,23 +58,4 @@ object FeatureFlags {
is UserCardsVariates.Restricted -> (userCards as UserCardsVariates.Restricted).predicate() is UserCardsVariates.Restricted -> (userCards as UserCardsVariates.Restricted).predicate()
} }
@FeatureFlag("MassMentions")
var massMentions by mutableStateOf<MassMentionsVariates>(
MassMentionsVariates.Disabled
)
val massMentionsGranted: Boolean
get() = when (massMentions) {
is MassMentionsVariates.Enabled -> true
is MassMentionsVariates.Disabled -> false
}
@FeatureFlag("FinalMarkdown")
var finalMarkdown by mutableStateOf<FinalMarkdownVariates>(
FinalMarkdownVariates.Disabled
)
val finalMarkdownGranted: Boolean
get() = when (finalMarkdown) {
is FinalMarkdownVariates.Enabled -> true
is FinalMarkdownVariates.Disabled -> false
}
} }

View File

@ -28,13 +28,14 @@ import chat.stoat.api.internals.solidColor
import chat.stoat.api.routes.microservices.january.asJanuaryProxyUrl import chat.stoat.api.routes.microservices.january.asJanuaryProxyUrl
import chat.stoat.composables.chat.specialembeds.SpecialEmbedSwitch import chat.stoat.composables.chat.specialembeds.SpecialEmbedSwitch
import chat.stoat.composables.generic.RemoteImage import chat.stoat.composables.generic.RemoteImage
import chat.stoat.composables.markdown.RichMarkdown import chat.stoat.composables.markdown.prose.ChatMarkdown
import chat.stoat.core.model.schemas.Embed import chat.stoat.core.model.schemas.Embed
import chat.stoat.core.model.schemas.Embed as EmbedSchema import chat.stoat.core.model.schemas.Embed as EmbedSchema
@Composable @Composable
fun RegularEmbed( fun RegularEmbed(
embed: EmbedSchema, embed: EmbedSchema,
serverId: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onLinkClick: (String) -> Unit = {} onLinkClick: (String) -> Unit = {}
) { ) {
@ -98,8 +99,9 @@ fun RegularEmbed(
// Description // Description
embed.description?.let { embed.description?.let {
RichMarkdown( ChatMarkdown(
input = it, content = it,
serverId = serverId,
modifier = Modifier.padding(top = 8.dp) modifier = Modifier.padding(top = 8.dp)
) )
} }
@ -144,10 +146,10 @@ fun RegularEmbed(
} }
@Composable @Composable
fun Embed(embed: Embed, modifier: Modifier = Modifier, onLinkClick: (String) -> Unit) { fun Embed(embed: Embed, serverId: String? = null, modifier: Modifier = Modifier, onLinkClick: (String) -> Unit) {
Column { Column {
when (embed.type) { when (embed.type) {
else -> RegularEmbed(embed = embed, modifier = modifier, onLinkClick = onLinkClick) else -> RegularEmbed(embed = embed, serverId = serverId, modifier = modifier, onLinkClick = onLinkClick)
} }
} }
} }

View File

@ -30,15 +30,10 @@ import chat.stoat.R
import chat.stoat.api.StoatAPI import chat.stoat.api.StoatAPI
import chat.stoat.api.internals.solidColor import chat.stoat.api.internals.solidColor
import chat.stoat.api.routes.channel.fetchSingleMessage import chat.stoat.api.routes.channel.fetchSingleMessage
import chat.stoat.api.settings.Experiments
import chat.stoat.composables.generic.UserAvatar import chat.stoat.composables.generic.UserAvatar
import chat.stoat.core.model.schemas.User import chat.stoat.core.model.schemas.User
import chat.stoat.markdown.jbm.JBM
import chat.stoat.markdown.jbm.JBMRenderer
import chat.stoat.markdown.jbm.LocalJBMarkdownTreeState
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
@OptIn(JBM::class)
@Composable @Composable
fun InReplyTo( fun InReplyTo(
channelId: String, channelId: String,
@ -131,28 +126,13 @@ fun InReplyTo(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} else { } else {
if (Experiments.useKotlinBasedMarkdownRenderer.isEnabled) { Text(
CompositionLocalProvider( text = message.content!!,
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy( fontSize = 12.sp,
embedded = true, color = contentColor.copy(alpha = 0.7f),
singleLine = true, maxLines = 1,
currentServer = serverId, overflow = TextOverflow.Ellipsis
linksClickable = false )
),
LocalContentColor provides contentColor.copy(alpha = 0.7f),
LocalTextStyle provides LocalTextStyle.current.copy(fontSize = 12.sp)
) {
JBMRenderer(message.content!!)
}
} else {
Text(
text = message.content!!,
fontSize = 12.sp,
color = contentColor.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} }
} else { } else {
Text( Text(

View File

@ -35,7 +35,6 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
@ -73,8 +72,6 @@ import chat.stoat.api.internals.solidColor
import chat.stoat.api.routes.channel.react import chat.stoat.api.routes.channel.react
import chat.stoat.api.routes.channel.unreact import chat.stoat.api.routes.channel.unreact
import chat.stoat.api.routes.microservices.january.asJanuaryProxyUrl import chat.stoat.api.routes.microservices.january.asJanuaryProxyUrl
import chat.stoat.core.model.schemas.AutumnResource
import chat.stoat.core.model.schemas.User
import chat.stoat.api.settings.Experiments import chat.stoat.api.settings.Experiments
import chat.stoat.api.settings.LoadedSettings import chat.stoat.api.settings.LoadedSettings
import chat.stoat.api.settings.MessageReplyStyle import chat.stoat.api.settings.MessageReplyStyle
@ -83,15 +80,15 @@ import chat.stoat.callbacks.ActionChannel
import chat.stoat.composables.generic.RemoteImage import chat.stoat.composables.generic.RemoteImage
import chat.stoat.composables.generic.UserAvatar import chat.stoat.composables.generic.UserAvatar
import chat.stoat.composables.generic.UserAvatarWidthPlaceholder import chat.stoat.composables.generic.UserAvatarWidthPlaceholder
import chat.stoat.composables.markdown.LocalMarkdownTreeConfig import chat.stoat.composables.markdown.prose.ChatMarkdown
import chat.stoat.composables.markdown.RichMarkdown
import chat.stoat.core.model.data.STOAT_FILES import chat.stoat.core.model.data.STOAT_FILES
import chat.stoat.core.model.schemas.AutumnResource
import chat.stoat.core.model.schemas.User
import chat.stoat.internals.text.Gigamoji import chat.stoat.internals.text.Gigamoji
import chat.stoat.internals.text.GigamojiState
import chat.stoat.internals.text.MessageProcessor import chat.stoat.internals.text.MessageProcessor
import chat.stoat.markdown.jbm.JBM
import chat.stoat.markdown.jbm.JBMRenderer
import chat.stoat.markdown.jbm.LocalJBMarkdownTreeState
import chat.stoat.persistence.KVStorage import chat.stoat.persistence.KVStorage
import com.mikepenz.markdown.model.State
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import chat.stoat.core.model.schemas.Message as MessageSchema import chat.stoat.core.model.schemas.Message as MessageSchema
@ -198,7 +195,7 @@ fun formatLongAsTime(time: Long): String {
} }
@SuppressLint("UnusedBoxWithConstraintsScope") @SuppressLint("UnusedBoxWithConstraintsScope")
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class, JBM::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun Message( fun Message(
message: MessageSchema, message: MessageSchema,
@ -210,7 +207,8 @@ fun Message(
onAddReaction: () -> Unit = {}, onAddReaction: () -> Unit = {},
fromWebhook: Boolean = false, fromWebhook: Boolean = false,
webhookName: String? = null, webhookName: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
mdAst: State? = null
) { ) {
val author = StoatAPI.userCache[message.author] ?: return CircularProgressIndicator() val author = StoatAPI.userCache[message.author] ?: return CircularProgressIndicator()
val context = LocalContext.current val context = LocalContext.current
@ -442,36 +440,25 @@ fun Message(
message.content?.let { message.content?.let {
if (message.content!!.isBlank()) return@let // if only an attachment is sent if (message.content!!.isBlank()) return@let // if only an attachment is sent
if (Experiments.useKotlinBasedMarkdownRenderer.isEnabled) { val gigamoji = Gigamoji.useGigamojiForMessage(message.content!!)
CompositionLocalProvider( val fontSizeMultiplier = when (gigamoji) {
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy( GigamojiState.Single -> 5f
currentServer = StoatAPI.channelCache[message.channel]?.server, GigamojiState.Multiple -> 2f
fontSizeMultiplier = Gigamoji.useGigamojiForMessage( GigamojiState.None -> 1f
message.content!! }
) Spacer(modifier = Modifier.height(2.dp))
.let { if (mdAst != null) {
if (it) 2f else 1f ChatMarkdown(
} mdAst,
) serverId = StoatAPI.channelCache[message.channel]?.server,
) { fontSizeMultiplier = fontSizeMultiplier,
Spacer(modifier = Modifier.height(2.dp)) )
JBMRenderer(message.content!!)
}
} else { } else {
CompositionLocalProvider( ChatMarkdown(
LocalMarkdownTreeConfig provides LocalMarkdownTreeConfig.current.copy( content = message.content!!,
currentServer = StoatAPI.channelCache[message.channel]?.server, serverId = StoatAPI.channelCache[message.channel]?.server,
fontSizeMultiplier = Gigamoji.useGigamojiForMessage( fontSizeMultiplier = fontSizeMultiplier,
message.content!! )
)
.let {
if (it) 2f else 1f
}
)
) {
Spacer(modifier = Modifier.height(2.dp))
RichMarkdown(input = message.content!!)
}
} }
} }
} }
@ -531,7 +518,7 @@ fun Message(
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Embed(embed = embed, onLinkClick = { Embed(embed = embed, serverId = StoatAPI.channelCache[message.channel]?.server, onLinkClick = {
viewUrlInBrowser(context, it) viewUrlInBrowser(context, it)
}) })
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))

View File

@ -28,7 +28,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.stoat.R import chat.stoat.R
import chat.stoat.composables.markdown.RichMarkdown import chat.stoat.api.StoatAPI
import chat.stoat.composables.markdown.prose.ChatMarkdown
import chat.stoat.core.model.schemas.Message import chat.stoat.core.model.schemas.Message
enum class SystemMessageType(val type: String) { enum class SystemMessageType(val type: String) {
@ -54,6 +55,7 @@ fun String?.mention(): String {
@Composable @Composable
fun SystemMessage(message: Message) { fun SystemMessage(message: Message) {
if (message.system == null) return if (message.system == null) return
val serverId = StoatAPI.channelCache[message.channel]?.server
val systemMessageType = val systemMessageType =
SystemMessageType.entries.firstOrNull { it.type == message.system!!.type } SystemMessageType.entries.firstOrNull { it.type == message.system!!.type }
@ -81,119 +83,131 @@ fun SystemMessage(message: Message) {
when (systemMessageType) { when (systemMessageType) {
SystemMessageType.CHANNEL_OWNERSHIP_CHANGED -> { SystemMessageType.CHANNEL_OWNERSHIP_CHANGED -> {
RichMarkdown( ChatMarkdown(
stringResource( stringResource(
R.string.system_message_ownership_changed, R.string.system_message_ownership_changed,
message.system!!.from.mention(), message.system!!.from.mention(),
message.system!!.to.mention() message.system!!.to.mention()
) ),
serverId = serverId
) )
} }
SystemMessageType.CHANNEL_ICON_CHANGED -> { SystemMessageType.CHANNEL_ICON_CHANGED -> {
RichMarkdown( ChatMarkdown(
stringResource( stringResource(
R.string.system_message_channel_icon_changed, R.string.system_message_channel_icon_changed,
message.system!!.by.mention() message.system!!.by.mention()
) ),
serverId = serverId
) )
} }
SystemMessageType.CHANNEL_DESCRIPTION_CHANGED -> { SystemMessageType.CHANNEL_DESCRIPTION_CHANGED -> {
RichMarkdown( ChatMarkdown(
stringResource( stringResource(
R.string.system_message_channel_description_changed, R.string.system_message_channel_description_changed,
message.system!!.by.mention() message.system!!.by.mention()
) ),
serverId = serverId
) )
} }
SystemMessageType.CHANNEL_RENAMED -> { SystemMessageType.CHANNEL_RENAMED -> {
RichMarkdown( ChatMarkdown(
stringResource( stringResource(
R.string.system_message_channel_renamed, R.string.system_message_channel_renamed,
message.system!!.by.mention(), message.system!!.by.mention(),
"**${message.system!!.name ?: stringResource(R.string.unknown)}**" "**${message.system!!.name ?: stringResource(R.string.unknown)}**"
) ),
serverId = serverId
) )
} }
SystemMessageType.USER_REMOVE -> { SystemMessageType.USER_REMOVE -> {
RichMarkdown( ChatMarkdown(
stringResource( stringResource(
R.string.system_message_user_removed, R.string.system_message_user_removed,
message.system!!.by.mention(), message.system!!.by.mention(),
message.system!!.id.mention() message.system!!.id.mention()
) ),
serverId = serverId
) )
} }
SystemMessageType.USER_ADDED -> { SystemMessageType.USER_ADDED -> {
RichMarkdown( ChatMarkdown(
stringResource( stringResource(
R.string.system_message_user_added, R.string.system_message_user_added,
message.system!!.by.mention(), message.system!!.by.mention(),
message.system!!.id.mention() message.system!!.id.mention()
) ),
serverId = serverId
) )
} }
SystemMessageType.USER_BANNED -> { SystemMessageType.USER_BANNED -> {
RichMarkdown( ChatMarkdown(
stringResource( stringResource(
R.string.system_message_user_banned, R.string.system_message_user_banned,
message.system!!.id.mention() message.system!!.id.mention()
) ),
serverId = serverId
) )
} }
SystemMessageType.USER_KICKED -> { SystemMessageType.USER_KICKED -> {
RichMarkdown( ChatMarkdown(
stringResource( stringResource(
R.string.system_message_user_kicked, R.string.system_message_user_kicked,
message.system!!.id.mention() message.system!!.id.mention()
) ),
serverId = serverId
) )
} }
SystemMessageType.USER_LEFT -> { SystemMessageType.USER_LEFT -> {
RichMarkdown( ChatMarkdown(
stringResource( stringResource(
R.string.system_message_user_left, R.string.system_message_user_left,
message.system!!.id.mention() message.system!!.id.mention()
) ),
serverId = serverId
) )
} }
SystemMessageType.USER_JOINED -> { SystemMessageType.USER_JOINED -> {
RichMarkdown( ChatMarkdown(
stringResource( stringResource(
R.string.system_message_user_joined, R.string.system_message_user_joined,
message.system!!.id.mention() message.system!!.id.mention()
) ),
serverId = serverId
) )
} }
SystemMessageType.MESSAGE_PINNED -> { SystemMessageType.MESSAGE_PINNED -> {
RichMarkdown( ChatMarkdown(
stringResource( stringResource(
R.string.system_message_message_pinned, R.string.system_message_message_pinned,
message.system!!.by.mention() message.system!!.by.mention()
) ),
serverId = serverId
) )
} }
SystemMessageType.MESSAGE_UNPINNED -> { SystemMessageType.MESSAGE_UNPINNED -> {
RichMarkdown( ChatMarkdown(
stringResource( stringResource(
R.string.system_message_message_unpinned, R.string.system_message_message_unpinned,
message.system!!.by.mention() message.system!!.by.mention()
) ),
serverId = serverId
) )
} }
SystemMessageType.TEXT -> { SystemMessageType.TEXT -> {
message.system!!.content?.let { RichMarkdown(it) } message.system!!.content?.let { ChatMarkdown(it) }
} }
} }
} }

View File

@ -1,21 +0,0 @@
package chat.stoat.composables.markdown
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import chat.stoat.api.settings.Experiments
import chat.stoat.markdown.jbm.JBM
import chat.stoat.markdown.jbm.JBMRenderer
import chat.stoat.ndk.Stendal
@OptIn(JBM::class)
@Composable
fun RichMarkdown(input: String, modifier: Modifier = Modifier) {
Column(modifier) {
if (Experiments.useKotlinBasedMarkdownRenderer.isEnabled) {
JBMRenderer(input)
} else {
MarkdownTree(node = Stendal.render(input))
}
}
}

View File

@ -0,0 +1,408 @@
package chat.stoat.composables.markdown.prose
import android.content.Intent
import android.widget.Toast
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.core.graphics.toColorInt
import androidx.core.net.toUri
import chat.stoat.R
import chat.stoat.activities.InviteActivity
import chat.stoat.api.StoatAPI
import chat.stoat.api.internals.isUlid
import chat.stoat.api.routes.custom.fetchEmoji
import chat.stoat.api.settings.LoadedSettings
import chat.stoat.callbacks.Action
import chat.stoat.callbacks.ActionChannel
import chat.stoat.composables.generic.RemoteImage
import chat.stoat.core.model.data.STOAT_FILES
import chat.stoat.core.model.schemas.User
import chat.stoat.core.model.schemas.isInviteUri
import chat.stoat.internals.resolveTimestamp
import chat.stoat.markdown.CHANNEL_MENTION_ELEMENT_TYPE
import chat.stoat.markdown.CUSTOM_EMOTE_ELEMENT_TYPE
import chat.stoat.markdown.MASS_MENTION_ELEMENT_TYPE
import chat.stoat.markdown.ROLE_MENTION_ELEMENT_TYPE
import chat.stoat.markdown.StoatMarkdownFlavour
import chat.stoat.markdown.TIMESTAMP_ELEMENT_TYPE
import chat.stoat.markdown.USER_MENTION_ELEMENT_TYPE
import chat.stoat.ui.theme.FragmentMono
import chat.stoat.ui.theme.isThemeDark
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.model.markdownInlineContent
import com.mikepenz.markdown.model.rememberMarkdownState
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes
import kotlinx.coroutines.launch
import org.intellij.markdown.ast.getTextInNode
import org.intellij.markdown.parser.MarkdownParser
// Converts single \n to hard line breaks so chat messages render line by line, while leaving fenced
// code blocks and double-newline paragraph breaks untouched, e.g. GitHub style line breaks
internal fun easyLineBreaks(content: String): String {
val sb = StringBuilder(content.length + 16)
var i = 0
var inFence = false
while (i < content.length) {
val ch = content[i]
if (ch == '`' || ch == '~') {
val lineStart = sb.lastIndexOf('\n') + 1
val atLineStart = sb.length == lineStart || sb.substring(lineStart).isBlank()
if (atLineStart) {
val fence = ch
var run = 0
while (i + run < content.length && content[i + run] == fence) run++
if (run >= 3) {
inFence = !inFence
repeat(run) { sb.append(content[i + it]) }
i += run
continue
}
}
}
if (ch == '\n' && !inFence) {
val prev = if (i > 0) content[i - 1] else ' '
val next = if (i + 1 < content.length) content[i + 1] else ' '
if (prev != '\n' && next != '\n') {
sb.append(" ")
}
}
sb.append(ch)
i++
}
return sb.toString()
}
@Composable
fun ChatMarkdown(
content: String,
serverId: String? = null,
fontSizeMultiplier: Float = 1f,
modifier: Modifier = Modifier,
) {
val processed = remember(content) { easyLineBreaks(content) }
val flavour = remember(processed) { StoatMarkdownFlavour(processed) }
val markdownState = rememberMarkdownState(
content = processed,
flavour = flavour,
parser = remember(flavour) { MarkdownParser(flavour) },
immediate = true,
)
val state by markdownState.state.collectAsState()
ChatMarkdown(
state = state,
serverId = serverId,
fontSizeMultiplier = fontSizeMultiplier,
modifier = modifier
)
}
@Composable
fun ChatMarkdown(
state: State,
serverId: String? = null,
fontSizeMultiplier: Float = 1f,
modifier: Modifier = Modifier
) {
val fontSize = LocalTextStyle.current.fontSize * fontSizeMultiplier
val scope = rememberCoroutineScope()
val primaryColor = MaterialTheme.colorScheme.primary
val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant
val mentionLinkStyle = remember(primaryColor) {
TextLinkStyles(
SpanStyle(
color = primaryColor,
background = primaryColor.copy(alpha = 0.2f)
)
)
}
val annotator = remember(serverId, mentionLinkStyle, surfaceVariantColor) {
markdownAnnotator { content, child ->
when (child.type) {
CUSTOM_EMOTE_ELEMENT_TYPE -> {
val ulid = child.getTextInNode(content).toString().removeSurrounding(":")
appendInlineContent("custom_emote", ulid)
true
}
USER_MENTION_ELEMENT_TYPE -> {
val raw = child.getTextInNode(content).toString()
val ulid = raw.substring(2, raw.length - 1)
val member = serverId?.let { StoatAPI.members.getMember(it, ulid) }
val displayName = member?.nickname
?: StoatAPI.userCache[ulid]?.let { User.resolveDefaultName(it) }
?: ulid
withLink(LinkAnnotation.Url("stoat-user://$ulid", mentionLinkStyle)) {
append("@$displayName")
}
true
}
CHANNEL_MENTION_ELEMENT_TYPE -> {
val raw = child.getTextInNode(content).toString()
val channelId = raw.substring(2, raw.length - 1)
val channelName = StoatAPI.channelCache[channelId]?.name ?: channelId
withLink(LinkAnnotation.Url("stoat-channel://$channelId", mentionLinkStyle)) {
append("#$channelName")
}
true
}
ROLE_MENTION_ELEMENT_TYPE -> {
val raw = child.getTextInNode(content).toString()
val roleId = raw.substring(2, raw.length - 1)
if (roleId.isUlid()) {
val role = serverId?.let { StoatAPI.serverCache[it]?.roles?.get(roleId) }
val roleColor = role?.colour
?.takeIf { !it.contains("gradient") }
?.let { runCatching { Color(it.toColorInt()) }.getOrNull() }
?: primaryColor
withStyle(
SpanStyle(
color = roleColor,
background = roleColor.copy(alpha = 0.2f)
)
) {
append("@${role?.name ?: roleId}")
}
} else {
append(raw)
}
true
}
MASS_MENTION_ELEMENT_TYPE -> {
val raw = child.getTextInNode(content).toString()
val displayText = if (raw.contains("EVERYONE")) "@everyone" else "@online"
withStyle(
SpanStyle(
color = primaryColor,
background = primaryColor.copy(alpha = 0.2f)
)
) {
append(displayText)
}
true
}
TIMESTAMP_ELEMENT_TYPE -> {
val raw = child.getTextInNode(content).toString()
val inner = raw.removePrefix("<t:").removeSuffix(">")
val parts = inner.split(":", limit = 2)
val epochSeconds = parts[0].toLongOrNull()
if (epochSeconds == null) {
append(raw)
} else {
val style = parts.getOrNull(1)
withLink(
LinkAnnotation.Url(
"stoat-timestamp://$epochSeconds",
TextLinkStyles(SpanStyle(background = surfaceVariantColor))
)
) {
append(resolveTimestamp(epochSeconds, style))
}
}
true
}
else -> false
}
}
}
val context = LocalContext.current
val resources = LocalResources.current
val toolbarColor = MaterialTheme.colorScheme.surfaceContainer.toArgb()
val uriHandler = remember(scope, serverId, toolbarColor) {
object : UriHandler {
override fun openUri(uri: String) {
when {
uri.startsWith("stoat-user://") -> {
val ulid = uri.removePrefix("stoat-user://")
scope.launch { ActionChannel.send(Action.OpenUserSheet(ulid, serverId)) }
}
uri.startsWith("stoat-channel://") -> {
val channelId = uri.removePrefix("stoat-channel://")
scope.launch { ActionChannel.send(Action.SwitchChannel(channelId)) }
}
uri.startsWith("stoat-timestamp://") -> {
val epochSeconds = uri.removePrefix("stoat-timestamp://").toLongOrNull()
if (epochSeconds != null) {
Toast.makeText(
context,
resolveTimestamp(epochSeconds, "F"),
Toast.LENGTH_LONG
).show()
}
}
else -> {
try {
val parsed = uri.toUri()
if (parsed.isInviteUri()) {
context.startActivity(
Intent(context, InviteActivity::class.java)
.apply { data = parsed }
)
return
}
} catch (_: Exception) {
// no-op
}
val customTab = CustomTabsIntent.Builder()
.setShowTitle(true)
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(toolbarColor)
.build()
)
.build()
try {
customTab.launchUrl(context, uri.toUri())
} catch (_: Exception) {
Toast.makeText(
context,
resources.getString(R.string.link_type_no_intent),
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
}
val systemIsDark = isSystemInDarkTheme()
val isDarkTheme =
remember(LoadedSettings.theme) { isThemeDark(LoadedSettings.theme, systemIsDark) }
val highlightsBuilder = remember(isDarkTheme) {
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
}
CompositionLocalProvider(LocalUriHandler provides uriHandler) {
Markdown(
state = state,
annotator = annotator,
typography = markdownTypography(
h1 = MaterialTheme.typography.headlineLarge,
h2 = MaterialTheme.typography.headlineMedium,
h3 = MaterialTheme.typography.headlineSmall,
h4 = MaterialTheme.typography.titleLarge,
h5 = MaterialTheme.typography.titleMedium,
h6 = MaterialTheme.typography.titleSmall,
text = MaterialTheme.typography.bodyLarge.copy(fontSize = MaterialTheme.typography.bodyLarge.fontSize * fontSizeMultiplier),
paragraph = MaterialTheme.typography.bodyLarge.copy(fontSize = MaterialTheme.typography.bodyLarge.fontSize * fontSizeMultiplier),
code = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FragmentMono,
fontSize = MaterialTheme.typography.bodyLarge.fontSize * fontSizeMultiplier
),
inlineCode = MaterialTheme.typography.bodyLarge.copy(
fontFamily = FragmentMono,
fontSize = MaterialTheme.typography.bodyLarge.fontSize * fontSizeMultiplier
),
textLink = TextLinkStyles(
SpanStyle(
color = primaryColor,
textDecoration = TextDecoration.Underline
)
),
),
inlineContent = markdownInlineContent(
mapOf(
"custom_emote" to InlineTextContent(
Placeholder(
width = fontSize * 1.5f,
height = fontSize * 1.5f,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
)
) { ulid ->
val emote = StoatAPI.emojiCache[ulid]
if (emote == null) {
LaunchedEffect(ulid) {
try {
StoatAPI.emojiCache[ulid] = fetchEmoji(ulid)
} catch (_: Exception) {
}
}
} else {
with(LocalDensity.current) {
RemoteImage(
url = "$STOAT_FILES/emojis/$ulid",
description = emote.name,
contentScale = ContentScale.Fit,
modifier = Modifier
.width((fontSize * 1.5f).toDp())
.height((fontSize * 1.5f).toDp())
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
scope.launch { ActionChannel.send(Action.EmoteInfo(ulid)) }
},
)
}
}
}
)
),
components = markdownComponents(
image = {},
inlineImage = {},
codeBlock = {
MarkdownHighlightedCodeBlock(
content = it.content,
node = it.node,
highlightsBuilder = highlightsBuilder,
)
},
codeFence = {
MarkdownHighlightedCodeFence(
content = it.content,
node = it.node,
highlightsBuilder = highlightsBuilder,
)
},
),
modifier = modifier,
)
}
}

View File

@ -0,0 +1,16 @@
package chat.stoat.composables.markdown.prose
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.model.rememberMarkdownState
@Composable
fun UIMarkdown(input: String, modifier: Modifier = Modifier) {
Markdown(
markdownState = rememberMarkdownState(content = input, immediate = true),
components = markdownComponents(image = {}, inlineImage = {}),
modifier = modifier,
)
}

View File

@ -14,11 +14,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -31,10 +26,8 @@ import chat.stoat.core.model.schemas.ChannelType
import chat.stoat.core.model.schemas.User import chat.stoat.core.model.schemas.User
import chat.stoat.composables.generic.RemoteImage import chat.stoat.composables.generic.RemoteImage
import chat.stoat.composables.generic.UserAvatar import chat.stoat.composables.generic.UserAvatar
import chat.stoat.composables.markdown.MarkdownTree import chat.stoat.composables.markdown.prose.ChatMarkdown
import chat.stoat.core.model.data.STOAT_FILES import chat.stoat.core.model.data.STOAT_FILES
import chat.stoat.ndk.AstNode
import chat.stoat.ndk.Stendal
@Composable @Composable
fun ChannelSheetHeader( fun ChannelSheetHeader(
@ -42,16 +35,9 @@ fun ChannelSheetHeader(
channelIcon: AutumnResource? = null, channelIcon: AutumnResource? = null,
channelType: ChannelType, channelType: ChannelType,
channelDescription: String? = null, channelDescription: String? = null,
serverId: String? = null,
dmPartner: User? = null dmPartner: User? = null
) { ) {
var renderedChannelDescription by remember { mutableStateOf<AstNode?>(null) }
LaunchedEffect(channelDescription) {
if (channelDescription != null) {
renderedChannelDescription = Stendal.render(channelDescription)
}
}
Row( Row(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -102,9 +88,9 @@ fun ChannelSheetHeader(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (renderedChannelDescription != null && channelDescription?.isNotBlank() == true) { if (!channelDescription.isNullOrBlank()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
MarkdownTree(node = renderedChannelDescription!!) ChatMarkdown(channelDescription, serverId = serverId)
} }
} }
} }

View File

@ -32,7 +32,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -43,14 +47,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min import androidx.compose.ui.unit.min
import chat.stoat.R import chat.stoat.R
import chat.stoat.api.StoatAPI import chat.stoat.api.StoatAPI
import chat.stoat.core.model.schemas.Channel
import chat.stoat.core.model.schemas.Message
import chat.stoat.api.settings.LoadedSettings import chat.stoat.api.settings.LoadedSettings
import chat.stoat.api.settings.MessageReplyStyle import chat.stoat.api.settings.MessageReplyStyle
import chat.stoat.callbacks.Action import chat.stoat.callbacks.Action
import chat.stoat.callbacks.ActionChannel import chat.stoat.callbacks.ActionChannel
import chat.stoat.composables.chat.Message import chat.stoat.composables.chat.Message
import chat.stoat.core.model.schemas.Channel
import chat.stoat.core.model.schemas.Message
import chat.stoat.internals.extensions.supportSwipeReply import chat.stoat.internals.extensions.supportSwipeReply
import com.mikepenz.markdown.model.State
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
@ -70,11 +75,13 @@ fun RegularMessage(
showReactBottomSheet: () -> Unit, showReactBottomSheet: () -> Unit,
putTextAtCursorPosition: (String) -> Unit, putTextAtCursorPosition: (String) -> Unit,
replyToMessage: suspend (String) -> Unit, replyToMessage: suspend (String) -> Unit,
scope: CoroutineScope = rememberCoroutineScope() scope: CoroutineScope = rememberCoroutineScope(),
mdAst: State? = null
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
var offsetX by remember { mutableFloatStateOf(0f) } val offsetXState = remember { mutableFloatStateOf(0f) }
var offsetX by offsetXState
val animOffsetX by animateFloatAsState( val animOffsetX by animateFloatAsState(
when { when {
drawerIsOpen -> 0f drawerIsOpen -> 0f
@ -83,8 +90,28 @@ fun RegularMessage(
}, },
label = "X offset of message for Swipe to Reply" label = "X offset of message for Swipe to Reply"
) )
var markGestureInvalid by remember { mutableStateOf(false) } val markGestureInvalidState = remember { mutableStateOf(false) }
var markGestureInvalid by markGestureInvalidState
var hapticFeedbackPerformed by remember { mutableStateOf(false) } var hapticFeedbackPerformed by remember { mutableStateOf(false) }
// Invalidate swipe-to-reply only when a child (e.g. a code block) actually consumed a
// horizontal scroll, i.e. it had content to scroll. Non-scrollable code blocks produce
// consumed.x == 0 and will not block the swipe gesture.
val nestedScrollConnection = remember(offsetXState, markGestureInvalidState) {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (consumed.x != 0f) {
offsetXState.floatValue = 0f
markGestureInvalidState.value = true
}
return Offset.Zero
}
}
}
var messageHeight by remember { mutableIntStateOf(0) } var messageHeight by remember { mutableIntStateOf(0) }
val canReleaseToSend = remember(offsetX) { offsetX <= SWIPE_TO_REPLY_THRESHOLD } val canReleaseToSend = remember(offsetX) { offsetX <= SWIPE_TO_REPLY_THRESHOLD }
@ -182,10 +209,12 @@ fun RegularMessage(
}, },
fromWebhook = message.webhook != null, fromWebhook = message.webhook != null,
webhookName = message.webhook?.name, webhookName = message.webhook?.name,
mdAst = mdAst,
modifier = Modifier modifier = Modifier
.offset( .offset(
x = with(LocalDensity.current) { animOffsetX.toDp() } x = with(LocalDensity.current) { animOffsetX.toDp() }
) )
.nestedScroll(nestedScrollConnection)
.then( .then(
if (LoadedSettings.messageReplyStyle == MessageReplyStyle.SwipeFromEnd) if (LoadedSettings.messageReplyStyle == MessageReplyStyle.SwipeFromEnd)
Modifier.supportSwipeReply( Modifier.supportSwipeReply(

View File

@ -4,7 +4,6 @@ import chat.stoat.api.StoatAPI
import chat.stoat.api.internals.PermissionBit import chat.stoat.api.internals.PermissionBit
import chat.stoat.api.internals.Roles import chat.stoat.api.internals.Roles
import chat.stoat.api.internals.has import chat.stoat.api.internals.has
import chat.stoat.api.settings.FeatureFlags
import chat.stoat.composables.chat.AutocompleteSuggestion import chat.stoat.composables.chat.AutocompleteSuggestion
import chat.stoat.core.model.schemas.ChannelType import chat.stoat.core.model.schemas.ChannelType
@ -51,6 +50,7 @@ object Autocomplete {
val member = serverId?.let { StoatAPI.members.getMember(serverId, StoatAPI.selfId ?: "") } val member = serverId?.let { StoatAPI.members.getMember(serverId, StoatAPI.selfId ?: "") }
val massMentionSuggestions = listOf("everyone", "online") val massMentionSuggestions = listOf("everyone", "online")
.filter { it.startsWith(query, ignoreCase = true) } .filter { it.startsWith(query, ignoreCase = true) }
val hereIsOnlineMnemonic = if (query == "here") listOf("online") else emptyList()
val selfPermissions = StoatAPI.channelCache[channelId]?.let { ch -> val selfPermissions = StoatAPI.channelCache[channelId]?.let { ch ->
Roles.permissionFor( Roles.permissionFor(
@ -119,7 +119,7 @@ object Autocomplete {
if (query.length < 2) return emptyList() if (query.length < 2) return emptyList()
val roles = val roles =
if (selfPermissions has PermissionBit.MentionRoles && FeatureFlags.massMentionsGranted) StoatAPI.serverCache[serverId]?.roles if (selfPermissions has PermissionBit.MentionRoles) StoatAPI.serverCache[serverId]?.roles
?: emptyMap() else emptyMap() ?: emptyMap() else emptyMap()
val byNickname = StoatAPI.members.filterNamesFor(serverId, query) val byNickname = StoatAPI.members.filterNamesFor(serverId, query)
.map { m -> m to StoatAPI.userCache[m.id?.user] }.filter { (_, u) -> .map { m -> m to StoatAPI.userCache[m.id?.user] }.filter { (_, u) ->
@ -174,7 +174,7 @@ object Autocomplete {
} }
null -> emptyList() null -> emptyList()
} + if (selfPermissions has PermissionBit.MentionEveryone && FeatureFlags.massMentionsGranted) massMentionSuggestions.map { mention -> } + if (selfPermissions has PermissionBit.MentionEveryone) (massMentionSuggestions + hereIsOnlineMnemonic).map { mention ->
AutocompleteSuggestion.MassMention(mention) AutocompleteSuggestion.MassMention(mention)
} else listOf() } else listOf()
} }

View File

@ -51,6 +51,24 @@ fun resolveTimestamp(timestamp: Long, modifier: String? = null): String {
.withZone(ZoneId.systemDefault()) .withZone(ZoneId.systemDefault())
.format(javaInstant) .format(javaInstant)
// 20/04/2021
"d" -> DateTimeFormatter.ofPattern("dd/MM/yyyy")
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault())
.format(javaInstant)
// 20/04/2021, 16:20
"s" -> DateTimeFormatter.ofPattern("dd/MM/yyyy, HH:mm")
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault())
.format(javaInstant)
// 20/04/2021, 16:20:30
"S" -> DateTimeFormatter.ofPattern("dd/MM/yyyy, HH:mm:ss")
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault())
.format(javaInstant)
// 9 months ago // 9 months ago
"R" -> DateUtils.getRelativeTimeSpanString( "R" -> DateUtils.getRelativeTimeSpanString(
timestamp * 1000, timestamp * 1000,
@ -58,8 +76,11 @@ fun resolveTimestamp(timestamp: Long, modifier: String? = null): String {
DateUtils.MINUTE_IN_MILLIS DateUtils.MINUTE_IN_MILLIS
) )
// Fallback. Shouldn't happen, regex already checks for this // f as in fallback
else -> timestamp.toString() else -> DateTimeFormatter.ofPattern("dd MMMM yyyy HH:mm")
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault())
.format(javaInstant)
} }
return outString.toString() return outString.toString()

View File

@ -1,36 +1,50 @@
package chat.stoat.internals.text package chat.stoat.internals.text
private val STRING_IS_CUSTOM_EMOTES_REGEX = Regex("(?::[0-9A-HJKMNP-TV-Z]{26}:|\\s)+") private val STRING_IS_CUSTOM_EMOTES_REGEX = Regex("(?::[0-9A-HJKMNP-TV-Z]{26}:|\\s)+")
private val CUSTOM_EMOTE_REGEX = Regex(":[0-9A-HJKMNP-TV-Z]{26}:")
sealed class GigamojiState {
object None : GigamojiState()
object Single : GigamojiState()
object Multiple : GigamojiState()
val isGigamoji: Boolean get() = this != None
}
object Gigamoji { object Gigamoji {
private fun stringIsEntirelyCustomEmotes(content: String): Boolean { private fun countCustomEmotes(content: String): Int =
return STRING_IS_CUSTOM_EMOTES_REGEX.matches(content) CUSTOM_EMOTE_REGEX.findAll(content).count()
}
private fun stringIsEntirelyUnicodeEmojis(unfilteredContent: String): Boolean { private fun unicodeEmojiCount(unfilteredContent: String): Int? {
// Remove all custom emotes
val content = unfilteredContent.replace(STRING_IS_CUSTOM_EMOTES_REGEX, "") val content = unfilteredContent.replace(STRING_IS_CUSTOM_EMOTES_REGEX, "")
if (content.isBlank()) return 0
// the message is solely custom emotes var count = 0
if (content.isBlank()) return true var lastWasZwj = false
if ("[0-9A-Za-z#*]".toRegex()
.containsMatchIn(content)
) { // reject common non-emoji characters
return false
}
for (codepoint in content.codePoints()) { for (codepoint in content.codePoints()) {
if (MessageProcessor.emoji.codepointIsEmoji(codepoint)) { when {
return true Character.isWhitespace(codepoint) -> lastWasZwj = false
codepoint == 0x200D -> lastWasZwj = true
codepoint == 0xFE0F || codepoint == 0xFE0E || codepoint == 0x20E3 -> Unit
codepoint in 0x1F3FB..0x1F3FF -> lastWasZwj = false
MessageProcessor.emoji.codepointIsEmoji(codepoint) -> {
if (!lastWasZwj) count++
lastWasZwj = false
}
else -> return null
} }
} }
return count
return false
} }
fun useGigamojiForMessage(content: String): Boolean { fun useGigamojiForMessage(content: String): GigamojiState {
if (content.isBlank()) return false if (content.isBlank()) return GigamojiState.None
return stringIsEntirelyCustomEmotes(content) || stringIsEntirelyUnicodeEmojis(content) val unicodeCount = unicodeEmojiCount(content) ?: return GigamojiState.None
val total = unicodeCount + countCustomEmotes(content)
return when {
total == 0 -> GigamojiState.None
total == 1 -> GigamojiState.Single
else -> GigamojiState.Multiple
}
} }
} }

View File

@ -0,0 +1,5 @@
package chat.stoat.markdown
import org.intellij.markdown.IElementType
val CUSTOM_EMOTE_ELEMENT_TYPE: IElementType = IElementType("CUSTOM_EMOTE")

View File

@ -0,0 +1,54 @@
package chat.stoat.markdown
import chat.stoat.api.internals.isUlid
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.TokensCache
class CustomEmoteSequentialParser(private val content: String) : SequentialParser {
override fun parse(
tokens: TokensCache,
rangesToGlue: List<IntRange>,
): SequentialParser.ParsingResult {
val result = SequentialParser.ParsingResultBuilder()
val delegateIndices = RangesListBuilder()
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
while (iterator.type != null) {
if (iterator.type == MarkdownTokenTypes.COLON) {
val openEnd = iterator.end
var lookahead = iterator.advance()
var hops = 0
while (lookahead.type != null &&
lookahead.type != MarkdownTokenTypes.COLON &&
lookahead.type != MarkdownTokenTypes.EOL &&
hops < 30
) {
lookahead = lookahead.advance()
hops++
}
if (lookahead.type == MarkdownTokenTypes.COLON) {
val innerText = content.substring(openEnd, lookahead.start)
if (innerText.isUlid()) {
result.withNode(
SequentialParser.Node(
iterator.index..lookahead.index + 1,
CUSTOM_EMOTE_ELEMENT_TYPE
)
)
iterator = lookahead.advance()
continue
}
}
}
delegateIndices.put(iterator.index)
iterator = iterator.advance()
}
return result.withFurtherProcessing(delegateIndices.get())
}
}

View File

@ -0,0 +1,8 @@
package chat.stoat.markdown
import org.intellij.markdown.IElementType
val USER_MENTION_ELEMENT_TYPE: IElementType = IElementType("USER_MENTION")
val CHANNEL_MENTION_ELEMENT_TYPE: IElementType = IElementType("CHANNEL_MENTION")
val ROLE_MENTION_ELEMENT_TYPE: IElementType = IElementType("ROLE_MENTION")
val MASS_MENTION_ELEMENT_TYPE: IElementType = IElementType("MASS_MENTION")

View File

@ -0,0 +1,63 @@
package chat.stoat.markdown
import chat.stoat.api.internals.isUlid
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.TokensCache
class MentionSequentialParser(private val content: String) : SequentialParser {
override fun parse(tokens: TokensCache, rangesToGlue: List<IntRange>): SequentialParser.ParsingResult {
val result = SequentialParser.ParsingResultBuilder()
val delegateIndices = RangesListBuilder()
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
while (iterator.type != null) {
if (iterator.type == MarkdownTokenTypes.LT) {
val ltEnd = iterator.end
var lookahead = iterator.advance()
var hops = 0
while (lookahead.type != null &&
lookahead.type != MarkdownTokenTypes.GT &&
lookahead.type != MarkdownTokenTypes.EOL &&
hops < 5
) {
lookahead = lookahead.advance()
hops++
}
if (lookahead.type == MarkdownTokenTypes.GT) {
val innerText = content.substring(ltEnd, lookahead.start)
when {
innerText.length == 27 && innerText[0] == '@' && innerText.substring(1).isUlid() -> {
result.withNode(SequentialParser.Node(iterator.index..lookahead.index + 1, USER_MENTION_ELEMENT_TYPE))
iterator = lookahead.advance()
continue
}
innerText.length == 27 && innerText[0] == '#' && innerText.substring(1).isUlid() -> {
result.withNode(SequentialParser.Node(iterator.index..lookahead.index + 1, CHANNEL_MENTION_ELEMENT_TYPE))
iterator = lookahead.advance()
continue
}
innerText.length == 27 && innerText[0] == '%' && innerText.substring(1).isUlid() -> {
result.withNode(SequentialParser.Node(iterator.index..lookahead.index + 1, ROLE_MENTION_ELEMENT_TYPE))
iterator = lookahead.advance()
continue
}
innerText == "@EVERYONE" || innerText == "@ONLINE" -> {
result.withNode(SequentialParser.Node(iterator.index..lookahead.index + 1, MASS_MENTION_ELEMENT_TYPE))
iterator = lookahead.advance()
continue
}
}
}
}
delegateIndices.put(iterator.index)
iterator = iterator.advance()
}
return result.withFurtherProcessing(delegateIndices.get())
}
}

View File

@ -0,0 +1,17 @@
package chat.stoat.markdown
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.SequentialParserManager
class StoatMarkdownFlavour(private val content: String) : GFMFlavourDescriptor() {
override val sequentialParserManager = object : SequentialParserManager() {
override fun getParserSequence(): List<SequentialParser> {
return listOf(
MentionSequentialParser(content),
TimestampSequentialParser(content),
CustomEmoteSequentialParser(content),
) + super@StoatMarkdownFlavour.sequentialParserManager.getParserSequence()
}
}
}

View File

@ -0,0 +1,5 @@
package chat.stoat.markdown
import org.intellij.markdown.IElementType
val TIMESTAMP_ELEMENT_TYPE: IElementType = IElementType("TIMESTAMP")

View File

@ -0,0 +1,57 @@
package chat.stoat.markdown
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.TokensCache
class TimestampSequentialParser(private val content: String) : SequentialParser {
override fun parse(tokens: TokensCache, rangesToGlue: List<IntRange>): SequentialParser.ParsingResult {
val result = SequentialParser.ParsingResultBuilder()
val delegateIndices = RangesListBuilder()
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
while (iterator.type != null) {
if (iterator.type == MarkdownTokenTypes.LT) {
val ltEnd = iterator.end
var lookahead = iterator.advance()
var hops = 0
while (lookahead.type != null &&
lookahead.type != MarkdownTokenTypes.GT &&
lookahead.type != MarkdownTokenTypes.EOL &&
hops < 15
) {
lookahead = lookahead.advance()
hops++
}
if (lookahead.type == MarkdownTokenTypes.GT) {
val innerText = content.substring(ltEnd, lookahead.start)
if (isTimestamp(innerText)) {
result.withNode(SequentialParser.Node(iterator.index..lookahead.index + 1, TIMESTAMP_ELEMENT_TYPE))
iterator = lookahead.advance()
continue
}
}
}
delegateIndices.put(iterator.index)
iterator = iterator.advance()
}
return result.withFurtherProcessing(delegateIndices.get())
}
private fun isTimestamp(inner: String): Boolean {
if (!inner.startsWith("t:")) return false
val rest = inner.removePrefix("t:")
val colonIdx = rest.indexOf(':')
return if (colonIdx == -1) {
rest.all { it.isDigit() } && rest.isNotEmpty()
} else {
val digits = rest.substring(0, colonIdx)
val style = rest.substring(colonIdx + 1)
digits.all { it.isDigit() } && digits.isNotEmpty() && style.length == 1
}
}
}

View File

@ -1,154 +0,0 @@
package chat.stoat.markdown.jbm
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import androidx.webkit.WebViewAssetLoader
import chat.stoat.activities.InviteActivity
import chat.stoat.api.StoatAPI
import chat.stoat.api.internals.ResourceLocations
import chat.stoat.core.model.data.STOAT_FILES
import chat.stoat.core.model.data.STOAT_WEB_APP
import chat.stoat.core.model.schemas.isInviteUri
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun FallbackRenderer(content: String, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
val mdState = LocalJBMarkdownTreeState.current
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
val assetLoader = WebViewAssetLoader.Builder()
.setDomain(Uri.parse(STOAT_WEB_APP).host!!)
.addPathHandler(
"/_android_assets/",
WebViewAssetLoader.AssetsPathHandler(context)
)
.addPathHandler(
"/_android_res/",
WebViewAssetLoader.ResourcesPathHandler(context)
)
.build()
webChromeClient = object : WebChromeClient() {}
webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
return request?.let { assetLoader.shouldInterceptRequest(it.url) }
}
override fun shouldOverrideUrlLoading(
view: WebView?,
webResourceRequest: WebResourceRequest
): Boolean {
// Capture clicks on invite links
if (webResourceRequest.url.isInviteUri()) {
val intent = Intent(
context,
InviteActivity::class.java
).setAction(Intent.ACTION_VIEW)
intent.data = webResourceRequest.url
context.startActivity(intent)
return true
}
// Otherwise, open the link in the browser using androidx.browser
val customTab = CustomTabsIntent.Builder()
.setShowTitle(true)
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(colors.background.toArgb())
.build()
)
.build()
customTab.launchUrl(context, webResourceRequest.url)
// Prevent the WebView from navigating to the URL
return true
}
}
settings.apply {
javaScriptEnabled = true
setSupportZoom(false)
setSupportMultipleWindows(false)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
cacheMode = WebSettings.LOAD_NO_CACHE
}
addJavascriptInterface(
object {
@JavascriptInterface
fun getSource(): String {
return content
}
@JavascriptInterface
fun getForegroundColour(): String {
return colors.onBackground.asHexString()
}
@JavascriptInterface
fun getPrimaryColour(): String {
return colors.primary.asHexString()
}
@JavascriptInterface
fun getMentionBackgroundColour(): String {
return colors.primary.copy(alpha = 0.2f).asHexString()
}
@JavascriptInterface
fun getCustomEmoteUrl(emoteId: String): String {
return "$STOAT_FILES/emojis/$emoteId/original"
}
@JavascriptInterface
fun resolveUserMention(userId: String): String {
return MentionResolver.resolveUser(userId, mdState.currentServer)
}
@JavascriptInterface
fun userAvatar(userId: String): String {
return ResourceLocations.userAvatarUrl(StoatAPI.userCache[userId])
}
@JavascriptInterface
fun resolveChannelMention(channelId: String): String {
return MentionResolver.resolveChannel(channelId)
}
},
"Bridge"
)
setBackgroundColor(Color.TRANSPARENT)
loadUrl(
"$STOAT_WEB_APP/_android_assets/markdown/markdown.html"
)
}
}
)
}

View File

@ -1,16 +0,0 @@
package chat.stoat.markdown.jbm
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.parser.MarkdownParser
@RequiresOptIn(message = "This API is experimental and has many TODOs.")
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class JBM
@JBM
object JBMApi {
fun parse(src: String): ASTNode {
return MarkdownParser(RSMFlavourDescriptor()).buildMarkdownTreeFromString(src)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,134 +0,0 @@
package chat.stoat.markdown.jbm
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import androidx.webkit.WebViewAssetLoader
import chat.stoat.activities.InviteActivity
import chat.stoat.core.model.data.STOAT_WEB_APP
import chat.stoat.core.model.schemas.isInviteUri
internal fun Color.asHexString(includeAlphaComponent: Boolean = true): String {
val argb = toArgb()
val red = argb shr 16 and 0xff
val green = argb shr 8 and 0xff
val blue = argb and 0xff
if (!includeAlphaComponent) {
return String.format("#%02x%02x%02x", red, green, blue)
}
val alpha = (argb shr 24 and 0xff) / 255.0f
return String.format("#%02x%02x%02x%02x", red, green, blue, (alpha * 255).toInt())
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun KatexRenderer(content: String, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
val assetLoader = WebViewAssetLoader.Builder()
.setDomain(Uri.parse(STOAT_WEB_APP).host!!)
.addPathHandler(
"/_android_assets/",
WebViewAssetLoader.AssetsPathHandler(context)
)
.addPathHandler(
"/_android_res/",
WebViewAssetLoader.ResourcesPathHandler(context)
)
.build()
webChromeClient = object : WebChromeClient() {}
webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
return request?.let { assetLoader.shouldInterceptRequest(it.url) }
}
override fun shouldOverrideUrlLoading(
view: WebView?,
webResourceRequest: WebResourceRequest
): Boolean {
// Capture clicks on invite links
if (webResourceRequest.url.isInviteUri()) {
val intent = Intent(
context,
InviteActivity::class.java
).setAction(Intent.ACTION_VIEW)
intent.data = webResourceRequest.url
context.startActivity(intent)
return true
}
// Otherwise, open the link in the browser using androidx.browser
val customTab = CustomTabsIntent.Builder()
.setShowTitle(true)
.setDefaultColorSchemeParams(
CustomTabColorSchemeParams.Builder()
.setToolbarColor(colors.background.toArgb())
.build()
)
.build()
customTab.launchUrl(context, webResourceRequest.url)
// Prevent the WebView from navigating to the URL
return true
}
}
settings.apply {
javaScriptEnabled = true
setSupportZoom(false)
setSupportMultipleWindows(false)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
cacheMode = WebSettings.LOAD_NO_CACHE
}
addJavascriptInterface(
object {
@JavascriptInterface
fun getSource(): String {
return content
}
@JavascriptInterface
fun getForegroundColour(): String {
return colors.onBackground.asHexString()
}
},
"Bridge"
)
setBackgroundColor(android.graphics.Color.TRANSPARENT)
loadUrl(
"$STOAT_WEB_APP/_android_assets/katex/katex.html"
)
}
}
)
}

View File

@ -1,32 +0,0 @@
package chat.stoat.markdown.jbm
import chat.stoat.api.StoatAPI
object MentionResolver {
/**
* Resolves a user mention to its fancy representation.
* Note that this uses the new format without a leading @ unless the user is not found.
*
* @param userId The user ID to resolve.
* @param serverId The server ID to resolve the user in.
* @return The resolved user mention.
*/
fun resolveUser(userId: String, serverId: String? = null): String {
val maybeMember = serverId?.let { StoatAPI.members.getMember(serverId, userId) }
return maybeMember?.nickname
?: StoatAPI.userCache[userId]?.username
?: "<@$userId>"
}
/**
* Resolves a channel mention to its fancy representation.
*
* @param channelId The channel ID to resolve.
* @param serverId The server ID to resolve the channel in.
* @return The resolved channel mention.
*/
fun resolveChannel(channelId: String): String {
val channel = StoatAPI.channelCache[channelId]
return channel?.name?.let { name -> "#$name" } ?: "<#$channelId>"
}
}

View File

@ -1,24 +0,0 @@
package chat.stoat.markdown.jbm
import org.intellij.markdown.IElementType
import org.intellij.markdown.MarkdownElementType
object RSMElementTypes {
@JvmField
val USER_MENTION: IElementType = MarkdownElementType("USER_MENTION")
@JvmField
val CHANNEL_MENTION: IElementType = MarkdownElementType("CHANNEL_MENTION")
@JvmField
val ROLE_MENTION: IElementType = MarkdownElementType("ROLE_MENTION")
@JvmField
val MASS_MENTION: IElementType = MarkdownElementType("MASS_MENTION")
@JvmField
val CUSTOM_EMOTE: IElementType = MarkdownElementType("EMOJI")
@JvmField
val TIMESTAMP: IElementType = MarkdownElementType("TIMESTAMP")
}

View File

@ -1,42 +0,0 @@
package chat.stoat.markdown.jbm
import chat.stoat.markdown.jbm.sequentialparsers.ChannelMentionParser
import chat.stoat.markdown.jbm.sequentialparsers.CustomEmoteParser
import chat.stoat.markdown.jbm.sequentialparsers.MassMentionParser
import chat.stoat.markdown.jbm.sequentialparsers.RoleMentionParser
import chat.stoat.markdown.jbm.sequentialparsers.UserMentionParser
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
import org.intellij.markdown.flavours.gfm.StrikeThroughDelimiterParser
import org.intellij.markdown.parser.sequentialparsers.EmphasisLikeParser
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.SequentialParserManager
import org.intellij.markdown.parser.sequentialparsers.impl.AutolinkParser
import org.intellij.markdown.parser.sequentialparsers.impl.BacktickParser
import org.intellij.markdown.parser.sequentialparsers.impl.EmphStrongDelimiterParser
import org.intellij.markdown.parser.sequentialparsers.impl.ImageParser
import org.intellij.markdown.parser.sequentialparsers.impl.InlineLinkParser
import org.intellij.markdown.parser.sequentialparsers.impl.MathParser
import org.intellij.markdown.parser.sequentialparsers.impl.ReferenceLinkParser
class RSMFlavourDescriptor : GFMFlavourDescriptor() {
override val sequentialParserManager = object : SequentialParserManager() {
override fun getParserSequence(): List<SequentialParser> {
return listOf(
UserMentionParser(),
ChannelMentionParser(),
RoleMentionParser(),
MassMentionParser(),
CustomEmoteParser(),
AutolinkParser(listOf(MarkdownTokenTypes.AUTOLINK, GFMTokenTypes.GFM_AUTOLINK)),
BacktickParser(),
MathParser(),
ImageParser(),
InlineLinkParser(),
ReferenceLinkParser(),
EmphasisLikeParser(EmphStrongDelimiterParser(), StrikeThroughDelimiterParser())
)
}
}
}

View File

@ -1,40 +0,0 @@
package chat.stoat.markdown.jbm.sequentialparsers
import chat.stoat.markdown.jbm.RSMElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.TokensCache
class ChannelMentionParser : SequentialParser {
override fun parse(
tokens: TokensCache,
rangesToGlue: List<IntRange>
): SequentialParser.ParsingResult {
val result = SequentialParser.ParsingResultBuilder()
val delegateIndices = RangesListBuilder()
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
while (iterator.type != null) {
if (iterator.type == MarkdownTokenTypes.LT && iterator.charLookup(1) == '#') {
val start = iterator.index
while (iterator.type != MarkdownTokenTypes.GT && iterator.type != null) {
iterator = iterator.advance()
}
if (iterator.type == MarkdownTokenTypes.GT) {
result.withNode(
SequentialParser.Node(
start..iterator.index + 1,
RSMElementTypes.CHANNEL_MENTION
)
)
}
} else {
delegateIndices.put(iterator.index)
}
iterator = iterator.advance()
}
return result.withFurtherProcessing(delegateIndices.get())
}
}

View File

@ -1,52 +0,0 @@
package chat.stoat.markdown.jbm.sequentialparsers
import chat.stoat.markdown.jbm.RSMElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.TokensCache
class CustomEmoteParser : SequentialParser {
override fun parse(
tokens: TokensCache,
rangesToGlue: List<IntRange>
): SequentialParser.ParsingResult {
val result = SequentialParser.ParsingResultBuilder()
val delegateIndices = RangesListBuilder()
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
while (iterator.type != null) {
if (iterator.type == MarkdownTokenTypes.COLON) {
val endIterator = findNextColon(iterator.advance())
if (endIterator != null) {
result.withNode(
SequentialParser.Node(
iterator.index..endIterator.index + 1,
RSMElementTypes.CUSTOM_EMOTE
)
)
iterator = endIterator.advance()
continue
}
}
delegateIndices.put(iterator.index)
iterator = iterator.advance()
}
return result.withFurtherProcessing(delegateIndices.get())
}
private fun findNextColon(it: TokensCache.Iterator): TokensCache.Iterator? {
var iterator = it
while (iterator.type != null) {
if (iterator.type == MarkdownTokenTypes.COLON) {
return iterator
}
iterator = iterator.advance()
}
return null
}
}

View File

@ -1,17 +0,0 @@
package chat.stoat.markdown.jbm.sequentialparsers
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.TokensCache
class MassMentionParser : SequentialParser {
override fun parse(
tokens: TokensCache,
rangesToGlue: List<IntRange>
): SequentialParser.ParsingResult {
// TODO - Implement
val result = SequentialParser.ParsingResultBuilder()
val delegateIndices = RangesListBuilder()
return result.withFurtherProcessing(delegateIndices.get())
}
}

View File

@ -1,42 +0,0 @@
package chat.stoat.markdown.jbm.sequentialparsers
import chat.stoat.markdown.jbm.RSMElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.TokensCache
class RoleMentionParser : SequentialParser {
override fun parse(
tokens: TokensCache,
rangesToGlue: List<IntRange>
): SequentialParser.ParsingResult {
val result = SequentialParser.ParsingResultBuilder()
val delegateIndices = RangesListBuilder()
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
while (iterator.type != null) {
if (iterator.type == MarkdownTokenTypes.LT && iterator.charLookup(1) == '%') {
val start = iterator.index
while (iterator.type != MarkdownTokenTypes.GT && iterator.type != null) {
iterator = iterator.advance()
}
if (iterator.type == MarkdownTokenTypes.GT) {
result.withNode(
SequentialParser.Node(
start..iterator.index + 1,
RSMElementTypes.ROLE_MENTION
)
)
}
} else if (iterator.type == MarkdownTokenTypes.LT) {
delegateIndices.put(iterator.index)
} else {
delegateIndices.put(iterator.index)
}
iterator = iterator.advance()
}
return result.withFurtherProcessing(delegateIndices.get())
}
}

View File

@ -1,40 +0,0 @@
package chat.stoat.markdown.jbm.sequentialparsers
import chat.stoat.markdown.jbm.RSMElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
import org.intellij.markdown.parser.sequentialparsers.TokensCache
class UserMentionParser : SequentialParser {
override fun parse(
tokens: TokensCache,
rangesToGlue: List<IntRange>
): SequentialParser.ParsingResult {
val result = SequentialParser.ParsingResultBuilder()
val delegateIndices = RangesListBuilder()
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
while (iterator.type != null) {
if (iterator.type == MarkdownTokenTypes.LT && iterator.charLookup(1) == '@') {
val start = iterator.index
while (iterator.type != MarkdownTokenTypes.GT && iterator.type != null) {
iterator = iterator.advance()
}
if (iterator.type == MarkdownTokenTypes.GT) {
result.withNode(
SequentialParser.Node(
start..iterator.index + 1,
RSMElementTypes.USER_MENTION
)
)
}
} else {
delegateIndices.put(iterator.index)
}
iterator = iterator.advance()
}
return result.withFurtherProcessing(delegateIndices.get())
}
}

View File

@ -65,6 +65,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -103,7 +104,6 @@ import chat.stoat.composables.vectorassets.Nametag
import chat.stoat.core.model.schemas.AutumnResource import chat.stoat.core.model.schemas.AutumnResource
import chat.stoat.core.model.schemas.Metadata import chat.stoat.core.model.schemas.Metadata
import chat.stoat.internals.extensions.zero import chat.stoat.internals.extensions.zero
import chat.stoat.markdown.jbm.asHexString
import chat.stoat.screens.chat.LocalIsConnected import chat.stoat.screens.chat.LocalIsConnected
import io.github.g00fy2.quickie.QRResult import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanQRCode import io.github.g00fy2.quickie.ScanQRCode
@ -179,8 +179,8 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: ()
text = AnnotatedString.fromHtml( text = AnnotatedString.fromHtml(
stringResource( stringResource(
R.string.friends_add_by_tag_sheet_description, R.string.friends_add_by_tag_sheet_description,
"<font color=\"${Color(HL_USERNAME).asHexString(false)}\">", "<font color=\"${Color(HL_USERNAME).toArgb().let { "#%02x%02x%02x".format(it shr 16 and 0xff, it shr 8 and 0xff, it and 0xff) }}\">",
"<font color=\"${Color(HL_TAG).asHexString(false)}\">", "<font color=\"${Color(HL_TAG).toArgb().let { "#%02x%02x%02x".format(it shr 16 and 0xff, it shr 8 and 0xff, it and 0xff) }}\">",
"</font>", "</font>",
) )
), ),

View File

@ -90,6 +90,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.Placeholder
@ -122,7 +123,6 @@ import chat.stoat.composables.chat.DateDivider
import chat.stoat.composables.chat.Message import chat.stoat.composables.chat.Message
import chat.stoat.composables.chat.MessageField import chat.stoat.composables.chat.MessageField
import chat.stoat.composables.chat.SystemMessage import chat.stoat.composables.chat.SystemMessage
import chat.stoat.composables.chat.UnsupportedMessage
import chat.stoat.composables.emoji.EmojiPicker import chat.stoat.composables.emoji.EmojiPicker
import chat.stoat.composables.generic.GroupIcon import chat.stoat.composables.generic.GroupIcon
import chat.stoat.composables.generic.PresenceBadge import chat.stoat.composables.generic.PresenceBadge
@ -146,6 +146,7 @@ import chat.stoat.screens.chat.LocalIsConnected
import chat.stoat.sheets.ChannelInfoSheet import chat.stoat.sheets.ChannelInfoSheet
import chat.stoat.sheets.MessageContextSheet import chat.stoat.sheets.MessageContextSheet
import chat.stoat.sheets.ReactSheet import chat.stoat.sheets.ReactSheet
import com.mikepenz.markdown.model.State
import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.rememberShimmer
import com.valentinilk.shimmer.shimmer import com.valentinilk.shimmer.shimmer
@ -157,14 +158,12 @@ import java.io.File
import kotlin.math.max import kotlin.math.max
sealed class ChannelScreenItem { sealed class ChannelScreenItem {
data class RegularMessage(val message: Message) : ChannelScreenItem() data class RegularMessage(val message: Message, val mdAst: State?) : ChannelScreenItem()
data class ProspectiveMessage(val message: Message) : ChannelScreenItem() data class ProspectiveMessage(val message: Message, val mdAst: State?) : ChannelScreenItem()
data class FailedMessage(val message: Message) : ChannelScreenItem() data class FailedMessage(val message: Message, val mdAst: State?) : ChannelScreenItem()
data class SystemMessage(val message: Message) : ChannelScreenItem() data class SystemMessage(val message: Message) : ChannelScreenItem()
data class DateDivider(val instant: Instant) : ChannelScreenItem() data class DateDivider(val instant: Instant) : ChannelScreenItem()
data class LoadTrigger(val after: String?, val before: String?) : data class LoadTrigger(val after: String?, val before: String?) : ChannelScreenItem()
ChannelScreenItem()
data object Loading : ChannelScreenItem() data object Loading : ChannelScreenItem()
} }
@ -203,6 +202,7 @@ fun ChannelScreen(
// <editor-fold desc="State and effects"> // <editor-fold desc="State and effects">
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
val config = LocalConfiguration.current val config = LocalConfiguration.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -351,7 +351,7 @@ fun ChannelScreen(
} catch (e: Exception) { } catch (e: Exception) {
Toast.makeText( Toast.makeText(
context, context,
context.getString( resources.getString(
R.string.file_picker_chip_camera_failed R.string.file_picker_chip_camera_failed
), ),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
@ -367,7 +367,7 @@ fun ChannelScreen(
} catch (e: Exception) { } catch (e: Exception) {
Toast.makeText( Toast.makeText(
context, context,
context.getString( resources.getString(
R.string.file_picker_chip_camera_none_installed R.string.file_picker_chip_camera_none_installed
), ),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
@ -727,14 +727,6 @@ fun ChannelScreen(
} }
when (val item = viewModel.items[index]) { when (val item = viewModel.items[index]) {
is ChannelScreenItem.RegularMessage -> { is ChannelScreenItem.RegularMessage -> {
if (item.message.content?.replace("\\s".toRegex(), "")
?.contains(">>>>>>>") == true
) {
// FIXME Dirty hack to prevent a crash caused by malicious messages.
UnsupportedMessage()
return@items
}
RegularMessage( RegularMessage(
item.message, item.message,
viewModel.channel, viewModel.channel,
@ -757,7 +749,8 @@ fun ChannelScreen(
}, },
putTextAtCursorPosition = viewModel::putAtCursorPosition, putTextAtCursorPosition = viewModel::putAtCursorPosition,
replyToMessage = viewModel::addReplyTo, replyToMessage = viewModel::addReplyTo,
scope = scope scope = scope,
mdAst = item.mdAst
) )
} }
@ -772,7 +765,8 @@ fun ChannelScreen(
onNameClick = {}, onNameClick = {},
canReply = false, canReply = false,
onReply = {}, onReply = {},
onAddReaction = {} onAddReaction = {},
mdAst = item.mdAst,
) )
} }
} }
@ -787,7 +781,8 @@ fun ChannelScreen(
onNameClick = {}, onNameClick = {},
canReply = false, canReply = false,
onReply = {}, onReply = {},
onAddReaction = {} onAddReaction = {},
mdAst = item.mdAst,
) )
Row { Row {
UserAvatarWidthPlaceholder() UserAvatarWidthPlaceholder()

View File

@ -49,14 +49,19 @@ import chat.stoat.callbacks.UiCallbacks
import chat.stoat.core.model.schemas.Channel import chat.stoat.core.model.schemas.Channel
import chat.stoat.core.model.schemas.Message import chat.stoat.core.model.schemas.Message
import chat.stoat.internals.text.MessageProcessor import chat.stoat.internals.text.MessageProcessor
import chat.stoat.composables.markdown.prose.easyLineBreaks
import chat.stoat.markdown.StoatMarkdownFlavour
import chat.stoat.persistence.KVStorage import chat.stoat.persistence.KVStorage
import chat.stoat.screens.chat.ChatRouterDestination import chat.stoat.screens.chat.ChatRouterDestination
import chat.stoat.settings.providers.AgeGateUnlockedStorageProvider import chat.stoat.settings.providers.AgeGateUnlockedStorageProvider
import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.parseMarkdownFlow
import io.ktor.http.ContentType import io.ktor.http.ContentType
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -257,6 +262,69 @@ class ChannelScreenViewModel(
} }
} }
private suspend fun parseAst(content: String?): State? =
content?.takeIf { it.isNotBlank() }?.let { c ->
val prepared = injectMassMentionMarkers(easyLineBreaks(c))
parseMarkdownFlow(prepared, flavour = StoatMarkdownFlavour(prepared))
.first { it !is State.Loading }
}
// Replaces @everyone/@online with <@EVERYONE>/<@ONLINE> outside code blocks and inline code spans.
private fun injectMassMentionMarkers(content: String): String {
val sb = StringBuilder(content.length)
var i = 0
var inFence = false
var inCodeSpan = false
while (i < content.length) {
val ch = content[i]
if (!inCodeSpan && (ch == '`' || ch == '~')) {
val lineStart = sb.lastIndexOf('\n') + 1
val atLineStart = sb.length == lineStart || sb.substring(lineStart).isBlank()
if (atLineStart) {
var run = 0
while (i + run < content.length && content[i + run] == ch) run++
if (run >= 3) {
inFence = !inFence
sb.append(content, i, i + run)
i += run
continue
}
}
}
if (!inFence && ch == '`') {
inCodeSpan = !inCodeSpan
sb.append(ch)
i++
continue
}
if (!inFence && !inCodeSpan && ch == '@') {
when {
content.startsWith("everyone", i + 1) &&
(i + 9 >= content.length || !content[i + 9].isLetterOrDigit()) -> {
sb.append("<@EVERYONE>")
i += 9
continue
}
content.startsWith("online", i + 1) &&
(i + 7 >= content.length || !content[i + 7].isLetterOrDigit()) -> {
sb.append("<@ONLINE>")
i += 7
continue
}
}
}
sb.append(ch)
i++
}
return sb.toString()
}
/** /**
* Puts the draft content in the KV storage, the in-memory state of the message content, * Puts the draft content in the KV storage, the in-memory state of the message content,
* and, if [setInitial] is true, updates the text field to say the new [content]. * and, if [setInitial] is true, updates the text field to say the new [content].
@ -395,7 +463,10 @@ class ChannelScreenViewModel(
} ?: false } ?: false
) )
updateItems(listOf(ChannelScreenItem.ProspectiveMessage(prospectiveMessage)) + items) val ast = parseAst(content)
updateItems(
listOf(ChannelScreenItem.ProspectiveMessage(prospectiveMessage, ast)) + items
)
kvStorage.remove("draftContent/${channel?.id}") kvStorage.remove("draftContent/${channel?.id}")
putDraftContent("", true) putDraftContent("", true)
@ -415,7 +486,13 @@ class ChannelScreenViewModel(
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChannelScreenViewModel", "Failed to send message", e) Log.e("ChannelScreenViewModel", "Failed to send message", e)
updateItems(listOf(ChannelScreenItem.FailedMessage(prospectiveMessage)) + items.filter { it !is ChannelScreenItem.ProspectiveMessage }) updateItems(
listOf(
ChannelScreenItem.FailedMessage(
prospectiveMessage,
ast
)
) + items.filter { it !is ChannelScreenItem.ProspectiveMessage })
} finally { } finally {
isSending = false isSending = false
} }
@ -495,11 +572,17 @@ class ChannelScreenViewModel(
} else { } else {
true true
} }
}.map { }.let { filtered ->
when { val result = mutableListOf<ChannelScreenItem>()
it.system != null -> ChannelScreenItem.SystemMessage(it) for (msg in filtered) {
else -> ChannelScreenItem.RegularMessage(it) result.add(
when {
msg.system != null -> ChannelScreenItem.SystemMessage(msg)
else -> ChannelScreenItem.RegularMessage(msg, parseAst(msg.content))
}
)
} }
result
} }
// Place items according to whether above/below/around was specified. // Place items according to whether above/below/around was specified.
@ -553,7 +636,7 @@ class ChannelScreenViewModel(
if (didInitialChannelFetch) { // this check is so that we don't end up with a message that arrives at the same time as the initial fetch in front of the loading indicator if (didInitialChannelFetch) { // this check is so that we don't end up with a message that arrives at the same time as the initial fetch in front of the loading indicator
val newItem = when { val newItem = when {
it.system != null -> ChannelScreenItem.SystemMessage(it) it.system != null -> ChannelScreenItem.SystemMessage(it)
else -> ChannelScreenItem.RegularMessage(it) else -> ChannelScreenItem.RegularMessage(it, parseAst(it.content))
} }
updateItems(listOf(newItem) + items.filter { m -> updateItems(listOf(newItem) + items.filter { m ->
if (m is ChannelScreenItem.ProspectiveMessage) { if (m is ChannelScreenItem.ProspectiveMessage) {
@ -601,9 +684,10 @@ class ChannelScreenViewModel(
updateItems( updateItems(
items.map { m -> items.map { m ->
if (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) { if (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) {
ChannelScreenItem.RegularMessage( val merged = m.message.mergeWithPartial(messageFrame)
m.message.mergeWithPartial(messageFrame) val ast =
) if (messageFrame.content != null) parseAst(merged.content) else m.mdAst
ChannelScreenItem.RegularMessage(merged, ast)
} else { } else {
m m
} }
@ -624,7 +708,7 @@ class ChannelScreenViewModel(
items.map { currentMsg -> items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) { if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
StoatAPI.messageCache[it.id]?.let { m -> StoatAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m) ChannelScreenItem.RegularMessage(m, currentMsg.mdAst)
} ?: return@map currentMsg } ?: return@map currentMsg
} else { } else {
currentMsg currentMsg
@ -648,7 +732,7 @@ class ChannelScreenViewModel(
items.map { currentMsg -> items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) { if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
StoatAPI.messageCache[it.id]?.let { m -> StoatAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m) ChannelScreenItem.RegularMessage(m, currentMsg.mdAst)
} ?: return@map currentMsg } ?: return@map currentMsg
} else { } else {
currentMsg currentMsg
@ -672,7 +756,7 @@ class ChannelScreenViewModel(
items.map { currentMsg -> items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) { if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
StoatAPI.messageCache[it.id]?.let { m -> StoatAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m) ChannelScreenItem.RegularMessage(m, currentMsg.mdAst)
} ?: return@map currentMsg } ?: return@map currentMsg
} else { } else {
currentMsg currentMsg
@ -870,15 +954,15 @@ class ChannelScreenViewModel(
groupedItems.add( groupedItems.add(
when (m) { when (m) {
is ChannelScreenItem.RegularMessage -> ChannelScreenItem.RegularMessage( is ChannelScreenItem.RegularMessage -> ChannelScreenItem.RegularMessage(
m.message.copy( m.message.copy(tail = tail), m.mdAst
tail = tail )
)
is ChannelScreenItem.ProspectiveMessage -> ChannelScreenItem.ProspectiveMessage(
m.message.copy(tail = tail), m.mdAst
) )
is ChannelScreenItem.SystemMessage -> ChannelScreenItem.SystemMessage( is ChannelScreenItem.SystemMessage -> ChannelScreenItem.SystemMessage(
m.message.copy( m.message.copy(tail = tail)
tail = tail
)
) )
else -> m else -> m

View File

@ -166,15 +166,6 @@ fun LabsHomeScreen(navController: NavController, topNav: NavController) {
} }
) )
HorizontalDivider() HorizontalDivider()
ListItem(
headlineContent = {
Text("JB Markdown")
},
modifier = Modifier.clickable {
navController.navigate("sandboxes/jbm")
}
)
HorizontalDivider()
ListItem( ListItem(
headlineContent = { headlineContent = {
Text("Gradient Editor") Text("Gradient Editor")

View File

@ -14,7 +14,6 @@ import androidx.navigation.compose.rememberNavController
import chat.stoat.api.settings.FeatureFlags import chat.stoat.api.settings.FeatureFlags
import chat.stoat.screens.labs.ui.mockups.NewLoginExperienceMockup import chat.stoat.screens.labs.ui.mockups.NewLoginExperienceMockup
import chat.stoat.screens.labs.ui.sandbox.GradientEditorSandbox import chat.stoat.screens.labs.ui.sandbox.GradientEditorSandbox
import chat.stoat.screens.labs.ui.sandbox.JBMSandbox
import chat.stoat.screens.labs.ui.sandbox.NewCardSandboxScreen import chat.stoat.screens.labs.ui.sandbox.NewCardSandboxScreen
import chat.stoat.screens.labs.ui.sandbox.SettingsDslSandbox import chat.stoat.screens.labs.ui.sandbox.SettingsDslSandbox
import chat.stoat.screens.labs.ui.sandbox.TelecomSandbox import chat.stoat.screens.labs.ui.sandbox.TelecomSandbox
@ -71,9 +70,6 @@ fun LabsRootScreen(topNav: NavController) {
composable("sandboxes/settingsdsl") { composable("sandboxes/settingsdsl") {
SettingsDslSandbox(labsNav) SettingsDslSandbox(labsNav)
} }
composable("sandboxes/jbm") {
JBMSandbox(labsNav)
}
composable("sandboxes/gradienteditor") { composable("sandboxes/gradienteditor") {
GradientEditorSandbox(labsNav) GradientEditorSandbox(labsNav)
} }

View File

@ -1,112 +0,0 @@
package chat.stoat.screens.labs.ui.sandbox
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.stoat.markdown.jbm.JBM
import chat.stoat.markdown.jbm.JBMRenderer
import chat.stoat.markdown.jbm.LocalJBMarkdownTreeState
import chat.stoat.settings.dsl.SettingsPage
@OptIn(JBM::class)
@Composable
fun JBMSandbox(navController: NavController) {
var mdSource by remember { mutableStateOf("") }
var submitMdSource by remember { mutableStateOf<String?>(null) }
var isEmbedded by remember { mutableStateOf(false) }
SettingsPage(
navController = navController,
title = {
Text(
text = "JB Markdown Sandbox",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
) {
Subcategory(
title = { Text("Options", maxLines = 1, overflow = TextOverflow.Ellipsis) },
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isEmbedded,
onCheckedChange = { isEmbedded = it }
)
Spacer(Modifier.width(8.dp))
Text("Embedded", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Subcategory(
title = { Text("Source", maxLines = 1, overflow = TextOverflow.Ellipsis) },
) {
TextField(
value = mdSource,
onValueChange = { mdSource = it },
label = { Text("Markdown source") },
modifier = Modifier.fillMaxWidth()
)
TextButton(onClick = {
submitMdSource = mdSource
}) {
Text("Submit")
}
TextButton(onClick = {
submitMdSource = """# Full range of MD now supported!
1. Text with **bold**, *italics*, and ***both***!
2. You ~~can't see me~~.
3. [I'm a link to another website.](<https://revolt.chat>)
4. I'm a spoiler with ||**bold text inside it**||
- I'm a sub-item on this list...
- Let's go even deeper...
`Inline code`
```js
let x = "I'm a multi-line code block!";
```
> > ${'$'}${'$'}E = mc^2${'$'}${'$'}
>
> Albert Einstein
| Timestamp | Mention | Channel Link | Message Link |
|:-:|:-:|:-:|:-:|
| <t:1663846662:f> | <@01EX2NCWQ0CHS3QJF0FEQS1GR4> | <#01H73F4RAHTPBHKJ1XBQDXK3NQ> | https://revolt.chat/server/01F7ZSBSFHQ8TA81725KQCSDDP/channel/01F92C5ZXBQWQ8KY7J8KY917NM/01J25XZM9JXVVJDDKFPB7Q48HZ |"""
}) {
Text("Submit test document")
}
}
Subcategory(
title = { Text("Output", maxLines = 1, overflow = TextOverflow.Ellipsis) },
) {
CompositionLocalProvider(
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
embedded = isEmbedded
)
) {
submitMdSource?.let { JBMRenderer(it, Modifier) }
?: Text("Submit some Markdown and see the output.")
}
}
}
}

View File

@ -2,36 +2,22 @@ package chat.stoat.screens.settings
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.ElevatedButton import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.ToggleButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@ -39,36 +25,18 @@ import androidx.navigation.NavController
import chat.stoat.BuildConfig import chat.stoat.BuildConfig
import chat.stoat.StoatApplication import chat.stoat.StoatApplication
import chat.stoat.api.settings.Experiments import chat.stoat.api.settings.Experiments
import chat.stoat.api.settings.FeatureFlags
import chat.stoat.api.settings.LoadedSettings import chat.stoat.api.settings.LoadedSettings
import chat.stoat.composables.markdown.RichMarkdown import chat.stoat.composables.markdown.prose.UIMarkdown
import chat.stoat.persistence.KVStorage import chat.stoat.persistence.KVStorage
import chat.stoat.settings.dsl.SettingsPage import chat.stoat.settings.dsl.SettingsPage
import chat.stoat.settings.dsl.SubcategoryContentInsets import chat.stoat.settings.dsl.SubcategoryContentInsets
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
enum class MarkdownRenderer {
Stendal, JetBrains, FinalMarkdown
}
class ExperimentsSettingsScreenViewModel : ViewModel() { class ExperimentsSettingsScreenViewModel : ViewModel() {
private val kv = KVStorage(StoatApplication.instance) private val kv = KVStorage(StoatApplication.instance)
fun init() { fun init() {
viewModelScope.launch { viewModelScope.launch {
when {
Experiments.useKotlinBasedMarkdownRenderer.isEnabled -> {
mdRenderer.value = MarkdownRenderer.JetBrains
}
Experiments.useFinalMarkdownRenderer.isEnabled -> {
mdRenderer.value = MarkdownRenderer.FinalMarkdown
}
else -> {
mdRenderer.value = MarkdownRenderer.Stendal
}
}
usePolarChecked.value = Experiments.usePolar.isEnabled usePolarChecked.value = Experiments.usePolar.isEnabled
enableServerIdentityOptionsChecked.value = enableServerIdentityOptionsChecked.value =
Experiments.enableServerIdentityOptions.isEnabled Experiments.enableServerIdentityOptions.isEnabled
@ -100,36 +68,6 @@ class ExperimentsSettingsScreenViewModel : ViewModel() {
} }
} }
val mdRenderer = mutableStateOf(MarkdownRenderer.Stendal)
fun setMdRenderer(value: MarkdownRenderer) {
viewModelScope.launch {
when (value) {
MarkdownRenderer.Stendal -> {
kv.set("exp/useKotlinBasedMarkdownRenderer", false)
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(false)
kv.set("exp/useFinalMarkdownRenderer", false)
Experiments.useFinalMarkdownRenderer.setEnabled(false)
}
MarkdownRenderer.JetBrains -> {
kv.set("exp/useKotlinBasedMarkdownRenderer", true)
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(true)
kv.set("exp/useFinalMarkdownRenderer", false)
Experiments.useFinalMarkdownRenderer.setEnabled(false)
}
MarkdownRenderer.FinalMarkdown -> {
kv.set("exp/useKotlinBasedMarkdownRenderer", false)
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(false)
kv.set("exp/useFinalMarkdownRenderer", true)
Experiments.useFinalMarkdownRenderer.setEnabled(true)
}
}
mdRenderer.value = value
}
}
val usePolarChecked = mutableStateOf(false) val usePolarChecked = mutableStateOf(false)
fun setUsePolarChecked(value: Boolean) { fun setUsePolarChecked(value: Boolean) {
@ -212,64 +150,6 @@ fun ExperimentsSettingsScreen(
Text("Experiments", maxLines = 1, overflow = TextOverflow.Ellipsis) Text("Experiments", maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
) { ) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
ListItem(
headlineContent = {
Text("Markdown Renderer")
},
supportingContent = {
when (viewModel.mdRenderer.value) {
MarkdownRenderer.Stendal -> Text("Use the original C++ Markdown renderer for messages.")
MarkdownRenderer.JetBrains -> Text("Use the Kotlin-based JetBrains Markdown renderer for messages. This renderer is more feature-complete and has better results.")
MarkdownRenderer.FinalMarkdown -> Text("Use a new. blazingly fast markdown renderer for messages. This renderer is experimental and may have missing features.")
}
},
modifier = Modifier
.animateContentSize()
.weight(1f)
)
Column(
modifier = Modifier
.width(IntrinsicSize.Max)
.padding(end = 8.dp),
verticalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
) {
ToggleButton(
checked = viewModel.mdRenderer.value == MarkdownRenderer.Stendal,
onCheckedChange = { viewModel.setMdRenderer(MarkdownRenderer.Stendal) },
modifier = Modifier
.fillMaxWidth()
.semantics { role = Role.RadioButton }
) {
Text("Default")
}
ToggleButton(
checked = viewModel.mdRenderer.value == MarkdownRenderer.JetBrains,
onCheckedChange = { viewModel.setMdRenderer(MarkdownRenderer.JetBrains) },
modifier = Modifier
.fillMaxWidth()
.semantics { role = Role.RadioButton }
) {
Text("Kotlin")
}
if (FeatureFlags.finalMarkdownGranted || viewModel.mdRenderer.value == MarkdownRenderer.FinalMarkdown) {
ToggleButton(
checked = viewModel.mdRenderer.value == MarkdownRenderer.FinalMarkdown,
onCheckedChange = { viewModel.setMdRenderer(MarkdownRenderer.FinalMarkdown) },
enabled = FeatureFlags.finalMarkdownGranted,
modifier = Modifier
.fillMaxWidth()
.semantics { role = Role.RadioButton }
) {
Text("Final")
}
}
}
}
ListItem( ListItem(
headlineContent = { headlineContent = {
Text("Threefold Root User Interface") Text("Threefold Root User Interface")
@ -307,7 +187,7 @@ fun ExperimentsSettingsScreen(
Text("Voice Chats 2.0") Text("Voice Chats 2.0")
}, },
supportingContent = { supportingContent = {
RichMarkdown("Enable voice chats support.\n‼️ **Not available in this build!** ‼️") UIMarkdown("Enable voice chats support.\n‼️ **Not available in this build!** ‼️")
}, },
trailingContent = { trailingContent = {
Switch( Switch(

View File

@ -53,7 +53,7 @@ import chat.stoat.api.routes.auth.logoutAllSessions
import chat.stoat.api.routes.auth.logoutSessionById import chat.stoat.api.routes.auth.logoutSessionById
import chat.stoat.core.model.schemas.Session import chat.stoat.core.model.schemas.Session
import chat.stoat.composables.generic.ListHeader import chat.stoat.composables.generic.ListHeader
import chat.stoat.composables.markdown.RichMarkdown import chat.stoat.composables.markdown.prose.UIMarkdown
import chat.stoat.composables.settings.sessions.SessionItem import chat.stoat.composables.settings.sessions.SessionItem
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -199,7 +199,7 @@ fun SessionSettingsScreen(
} }
} ?: run { } ?: run {
item(key = "noCurrentSession") { item(key = "noCurrentSession") {
RichMarkdown( UIMarkdown(
input = stringResource(id = R.string.settings_sessions_this_device_unavailable), input = stringResource(id = R.string.settings_sessions_this_device_unavailable),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@ -109,6 +109,7 @@ fun ChannelInfoSheet(channelId: String, onHideSheet: suspend () -> Unit) {
channelIcon = channel.icon, channelIcon = channel.icon,
channelType = channel.channelType ?: ChannelType.TextChannel, channelType = channel.channelType ?: ChannelType.TextChannel,
channelDescription = channel.description, channelDescription = channel.description,
serverId = channel.server,
dmPartner = partner dmPartner = partner
) )
HorizontalDivider() HorizontalDivider()

View File

@ -1,103 +0,0 @@
package chat.stoat.sheets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp
import chat.stoat.composables.generic.SheetHeaderPadding
import chat.stoat.markdown.jbm.JBM
import chat.stoat.markdown.jbm.JBMApi
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.getTextInNode
@OptIn(JBM::class)
@Composable
fun JBMDebuggerSheet(content: String, modifier: Modifier = Modifier) {
val rendered = remember(content) { JBMApi.parse(content) }
Column {
SheetHeaderPadding {
Text(
text = "Inspect AST",
style = MaterialTheme.typography.headlineSmall
)
}
Column(modifier.verticalScroll(rememberScrollState())) {
JBMDebugTree(rendered, content)
}
}
}
@Composable
fun JBMDebugTree(node: ASTNode, source: String, modifier: Modifier = Modifier) {
var showChildren by remember { mutableStateOf(false) }
val scheme = MaterialTheme.colorScheme
Column(
modifier
.padding(start = 16.dp)
.clickable { showChildren = !showChildren }) {
JBMDebugNode(node, source, showChildren)
if (showChildren && node.children.isNotEmpty()) {
Column(Modifier.drawBehind {
drawLine(
color = scheme.primary,
start = Offset(0f, 0f),
end = Offset(0f, size.height),
strokeWidth = 2.dp.toPx()
)
}) {
node.children.forEach { JBMDebugTree(it, source) }
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun JBMDebugNode(node: ASTNode, source: String, showingChildren: Boolean) {
Column {
FlowRow {
if (node.children.isNotEmpty()) {
Text(if (showingChildren) "" else "")
}
Text(node.type.toString())
Text(" - ")
if (node.children.isNotEmpty()) {
Text("${node.children.size} " + if (node.children.size == 1) "child" else "children")
} else {
Text("No children")
}
}
val text = try {
node.getTextInNode(source)
} catch (e: Exception) {
"<exception>"
}
Text(
text.take(50).toString() + if (text.length > 50) "" else "",
color = if (text == "<exception>") MaterialTheme.colorScheme.error else LocalContentColor.current,
modifier = Modifier.padding(4.dp)
)
HorizontalDivider(
Modifier.fillMaxWidth()
)
}
}

View File

@ -34,7 +34,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.stoat.BuildConfig
import chat.stoat.R import chat.stoat.R
import chat.stoat.api.StoatAPI import chat.stoat.api.StoatAPI
import chat.stoat.api.internals.PermissionBit import chat.stoat.api.internals.PermissionBit
@ -42,7 +41,6 @@ import chat.stoat.api.internals.Roles
import chat.stoat.api.internals.has import chat.stoat.api.internals.has
import chat.stoat.api.routes.channel.deleteMessage import chat.stoat.api.routes.channel.deleteMessage
import chat.stoat.api.routes.channel.react import chat.stoat.api.routes.channel.react
import chat.stoat.api.settings.Experiments
import chat.stoat.callbacks.UiCallbacks import chat.stoat.callbacks.UiCallbacks
import chat.stoat.composables.chat.Message import chat.stoat.composables.chat.Message
import chat.stoat.composables.generic.SheetButton import chat.stoat.composables.generic.SheetButton
@ -77,9 +75,6 @@ fun MessageContextSheet(
var showShareSheet by remember { mutableStateOf(false) } var showShareSheet by remember { mutableStateOf(false) }
var showReactSheet by remember { mutableStateOf(false) } var showReactSheet by remember { mutableStateOf(false) }
var showDeleteMessageConfirmation by remember { mutableStateOf(false) } var showDeleteMessageConfirmation by remember { mutableStateOf(false) }
var showInspectASTSheet by remember { mutableStateOf(false) }
val showInspectASTSheetButton =
BuildConfig.DEBUG || (message.content != null && Experiments.useKotlinBasedMarkdownRenderer.isEnabled)
if (showShareSheet) { if (showShareSheet) {
val shareSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val shareSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@ -309,19 +304,6 @@ fun MessageContextSheet(
) )
} }
if (showInspectASTSheet) {
val inspectASTSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = inspectASTSheetState,
onDismissRequest = {
showInspectASTSheet = false
}
) {
JBMDebuggerSheet(message.content ?: "")
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
@ -424,26 +406,6 @@ fun MessageContextSheet(
} }
) )
if (showInspectASTSheetButton) {
SheetButton(
leadingContent = {
Icon(
painter = painterResource(id = R.drawable.ic_account_tree_24dp),
contentDescription = null
)
},
headlineContent = {
Text(
text = "Inspect AST"
)
},
onClick = {
showInspectASTSheet = true
},
special = true
)
}
SheetButton( SheetButton(
leadingContent = { leadingContent = {
Icon( Icon(

View File

@ -44,7 +44,7 @@ import chat.stoat.R
import chat.stoat.api.StoatAPI import chat.stoat.api.StoatAPI
import chat.stoat.api.routes.server.leaveOrDeleteServer import chat.stoat.api.routes.server.leaveOrDeleteServer
import chat.stoat.composables.generic.SheetButton import chat.stoat.composables.generic.SheetButton
import chat.stoat.composables.markdown.RichMarkdown import chat.stoat.composables.markdown.prose.ChatMarkdown
import chat.stoat.composables.screens.settings.ServerOverview import chat.stoat.composables.screens.settings.ServerOverview
import chat.stoat.composables.sheets.SheetSelection import chat.stoat.composables.sheets.SheetSelection
import chat.stoat.core.model.data.STOAT_WEB_APP import chat.stoat.core.model.data.STOAT_WEB_APP
@ -159,14 +159,13 @@ fun ServerContextSheet(
ServerOverview(server) ServerOverview(server)
SelectionContainer { SelectionContainer {
RichMarkdown( ChatMarkdown(
input = if (server.description?.isBlank() == false) { content = if (server.description?.isBlank() == false) {
server.description!! server.description!!
} else { } else {
stringResource( stringResource(R.string.server_context_sheet_description_empty)
R.string.server_context_sheet_description_empty },
) serverId = serverId,
}
) )
} }

View File

@ -51,7 +51,7 @@ import chat.stoat.composables.chat.UserBadgeList
import chat.stoat.composables.chat.UserBadgeRow import chat.stoat.composables.chat.UserBadgeRow
import chat.stoat.composables.generic.NonIdealState import chat.stoat.composables.generic.NonIdealState
import chat.stoat.composables.generic.UserAvatar import chat.stoat.composables.generic.UserAvatar
import chat.stoat.composables.markdown.RichMarkdown import chat.stoat.composables.markdown.prose.ChatMarkdown
import chat.stoat.composables.screens.settings.RawUserOverview import chat.stoat.composables.screens.settings.RawUserOverview
import chat.stoat.composables.screens.settings.UserButtons import chat.stoat.composables.screens.settings.UserButtons
import chat.stoat.composables.sheets.SheetTile import chat.stoat.composables.sheets.SheetTile
@ -403,11 +403,11 @@ fun UserInfoSheet(
Text(stringResource(R.string.user_info_sheet_category_bio)) Text(stringResource(R.string.user_info_sheet_category_bio))
}, },
contentPreview = { contentPreview = {
RichMarkdown(input = profile?.content!!) ChatMarkdown(content = profile?.content!!, serverId = serverId)
} }
) { ) {
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) { SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
RichMarkdown(input = profile?.content!!) ChatMarkdown(content = profile?.content!!, serverId = serverId)
} }
} }
} }

View File

@ -4,6 +4,7 @@ import android.os.Build
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.DeviceFontFamilyName
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
@ -55,7 +56,9 @@ private val InterDisplay = FontFamily(
) )
val FragmentMono = FontFamily( val FragmentMono = FontFamily(
Font(R.font.fragmentmono_regular, FontWeight.Normal), Font(R.font.fragmentmono_regular, FontWeight.Normal),
Font(R.font.fragmentmono_italic, FontWeight.Normal, FontStyle.Italic) Font(R.font.fragmentmono_italic, FontWeight.Normal, FontStyle.Italic),
Font(DeviceFontFamilyName("monospace"), FontWeight.Normal, FontStyle.Normal),
Font(DeviceFontFamilyName("monospace"), FontWeight.Normal, FontStyle.Italic)
) )
@OptIn(ExperimentalTextApi::class) @OptIn(ExperimentalTextApi::class)

View File

@ -106,6 +106,7 @@ koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-view
multiplatform-markdown-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "multiplatform-markdown" } multiplatform-markdown-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "multiplatform-markdown" }
multiplatform-markdown-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatform-markdown" } multiplatform-markdown-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatform-markdown" }
multiplatform-markdown-coil3 = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "multiplatform-markdown" } multiplatform-markdown-coil3 = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "multiplatform-markdown" }
multiplatform-markdown-code = { module = "com.mikepenz:multiplatform-markdown-renderer-code", version.ref = "multiplatform-markdown" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }