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.m3)
implementation(libs.multiplatform.markdown.coil3)
implementation(libs.multiplatform.markdown.code)
implementation(libs.coil.compose)
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.
*/
object Experiments {
val useKotlinBasedMarkdownRenderer = ExperimentInstance(false)
val usePolar = ExperimentInstance(false)
val enableServerIdentityOptions = ExperimentInstance(false)
val useFinalMarkdownRenderer = ExperimentInstance(false)
val useVoiceChats2p0 = ExperimentInstance(false)
suspend fun hydrateWithKv() {
@ -41,26 +39,14 @@ object Experiments {
LoadedSettings.experimentsEnabled = kvStorage.getBoolean("experimentsEnabled") == true
}
useKotlinBasedMarkdownRenderer.setEnabled(
kvStorage.getBoolean("exp/useKotlinBasedMarkdownRenderer") == true
)
usePolar.setEnabled(
kvStorage.getBoolean("exp/usePolar") == true
)
enableServerIdentityOptions.setEnabled(
kvStorage.getBoolean("exp/enableServerIdentityOptions") == true
)
useFinalMarkdownRenderer.setEnabled(
kvStorage.getBoolean("exp/useFinalMarkdownRenderer") == true
)
useVoiceChats2p0.setEnabled(
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()
}
@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 {
@FeatureFlag("LabsAccessControl")
@ -82,23 +58,4 @@ object FeatureFlags {
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.composables.chat.specialembeds.SpecialEmbedSwitch
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 as EmbedSchema
@Composable
fun RegularEmbed(
embed: EmbedSchema,
serverId: String? = null,
modifier: Modifier = Modifier,
onLinkClick: (String) -> Unit = {}
) {
@ -98,8 +99,9 @@ fun RegularEmbed(
// Description
embed.description?.let {
RichMarkdown(
input = it,
ChatMarkdown(
content = it,
serverId = serverId,
modifier = Modifier.padding(top = 8.dp)
)
}
@ -144,10 +146,10 @@ fun RegularEmbed(
}
@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 {
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.internals.solidColor
import chat.stoat.api.routes.channel.fetchSingleMessage
import chat.stoat.api.settings.Experiments
import chat.stoat.composables.generic.UserAvatar
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
@OptIn(JBM::class)
@Composable
fun InReplyTo(
channelId: String,
@ -131,28 +126,13 @@ fun InReplyTo(
overflow = TextOverflow.Ellipsis
)
} else {
if (Experiments.useKotlinBasedMarkdownRenderer.isEnabled) {
CompositionLocalProvider(
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
embedded = true,
singleLine = true,
currentServer = serverId,
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
)
}
Text(
text = message.content!!,
fontSize = 12.sp,
color = contentColor.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} else {
Text(

View File

@ -35,7 +35,6 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.unreact
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.LoadedSettings
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.UserAvatar
import chat.stoat.composables.generic.UserAvatarWidthPlaceholder
import chat.stoat.composables.markdown.LocalMarkdownTreeConfig
import chat.stoat.composables.markdown.RichMarkdown
import chat.stoat.composables.markdown.prose.ChatMarkdown
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.GigamojiState
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 com.mikepenz.markdown.model.State
import kotlinx.coroutines.launch
import chat.stoat.core.model.schemas.Message as MessageSchema
@ -198,7 +195,7 @@ fun formatLongAsTime(time: Long): String {
}
@SuppressLint("UnusedBoxWithConstraintsScope")
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class, JBM::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun Message(
message: MessageSchema,
@ -210,7 +207,8 @@ fun Message(
onAddReaction: () -> Unit = {},
fromWebhook: Boolean = false,
webhookName: String? = null,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
mdAst: State? = null
) {
val author = StoatAPI.userCache[message.author] ?: return CircularProgressIndicator()
val context = LocalContext.current
@ -442,36 +440,25 @@ fun Message(
message.content?.let {
if (message.content!!.isBlank()) return@let // if only an attachment is sent
if (Experiments.useKotlinBasedMarkdownRenderer.isEnabled) {
CompositionLocalProvider(
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
currentServer = StoatAPI.channelCache[message.channel]?.server,
fontSizeMultiplier = Gigamoji.useGigamojiForMessage(
message.content!!
)
.let {
if (it) 2f else 1f
}
)
) {
Spacer(modifier = Modifier.height(2.dp))
JBMRenderer(message.content!!)
}
val gigamoji = Gigamoji.useGigamojiForMessage(message.content!!)
val fontSizeMultiplier = when (gigamoji) {
GigamojiState.Single -> 5f
GigamojiState.Multiple -> 2f
GigamojiState.None -> 1f
}
Spacer(modifier = Modifier.height(2.dp))
if (mdAst != null) {
ChatMarkdown(
mdAst,
serverId = StoatAPI.channelCache[message.channel]?.server,
fontSizeMultiplier = fontSizeMultiplier,
)
} else {
CompositionLocalProvider(
LocalMarkdownTreeConfig provides LocalMarkdownTreeConfig.current.copy(
currentServer = StoatAPI.channelCache[message.channel]?.server,
fontSizeMultiplier = Gigamoji.useGigamojiForMessage(
message.content!!
)
.let {
if (it) 2f else 1f
}
)
) {
Spacer(modifier = Modifier.height(2.dp))
RichMarkdown(input = message.content!!)
}
ChatMarkdown(
content = message.content!!,
serverId = StoatAPI.channelCache[message.channel]?.server,
fontSizeMultiplier = fontSizeMultiplier,
)
}
}
}
@ -531,7 +518,7 @@ fun Message(
}
Spacer(modifier = Modifier.height(8.dp))
Embed(embed = embed, onLinkClick = {
Embed(embed = embed, serverId = StoatAPI.channelCache[message.channel]?.server, onLinkClick = {
viewUrlInBrowser(context, it)
})
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 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
enum class SystemMessageType(val type: String) {
@ -54,6 +55,7 @@ fun String?.mention(): String {
@Composable
fun SystemMessage(message: Message) {
if (message.system == null) return
val serverId = StoatAPI.channelCache[message.channel]?.server
val systemMessageType =
SystemMessageType.entries.firstOrNull { it.type == message.system!!.type }
@ -81,119 +83,131 @@ fun SystemMessage(message: Message) {
when (systemMessageType) {
SystemMessageType.CHANNEL_OWNERSHIP_CHANGED -> {
RichMarkdown(
ChatMarkdown(
stringResource(
R.string.system_message_ownership_changed,
message.system!!.from.mention(),
message.system!!.to.mention()
)
),
serverId = serverId
)
}
SystemMessageType.CHANNEL_ICON_CHANGED -> {
RichMarkdown(
ChatMarkdown(
stringResource(
R.string.system_message_channel_icon_changed,
message.system!!.by.mention()
)
),
serverId = serverId
)
}
SystemMessageType.CHANNEL_DESCRIPTION_CHANGED -> {
RichMarkdown(
ChatMarkdown(
stringResource(
R.string.system_message_channel_description_changed,
message.system!!.by.mention()
)
),
serverId = serverId
)
}
SystemMessageType.CHANNEL_RENAMED -> {
RichMarkdown(
ChatMarkdown(
stringResource(
R.string.system_message_channel_renamed,
message.system!!.by.mention(),
"**${message.system!!.name ?: stringResource(R.string.unknown)}**"
)
),
serverId = serverId
)
}
SystemMessageType.USER_REMOVE -> {
RichMarkdown(
ChatMarkdown(
stringResource(
R.string.system_message_user_removed,
message.system!!.by.mention(),
message.system!!.id.mention()
)
),
serverId = serverId
)
}
SystemMessageType.USER_ADDED -> {
RichMarkdown(
ChatMarkdown(
stringResource(
R.string.system_message_user_added,
message.system!!.by.mention(),
message.system!!.id.mention()
)
),
serverId = serverId
)
}
SystemMessageType.USER_BANNED -> {
RichMarkdown(
ChatMarkdown(
stringResource(
R.string.system_message_user_banned,
message.system!!.id.mention()
)
),
serverId = serverId
)
}
SystemMessageType.USER_KICKED -> {
RichMarkdown(
ChatMarkdown(
stringResource(
R.string.system_message_user_kicked,
message.system!!.id.mention()
)
),
serverId = serverId
)
}
SystemMessageType.USER_LEFT -> {
RichMarkdown(
ChatMarkdown(
stringResource(
R.string.system_message_user_left,
message.system!!.id.mention()
)
),
serverId = serverId
)
}
SystemMessageType.USER_JOINED -> {
RichMarkdown(
ChatMarkdown(
stringResource(
R.string.system_message_user_joined,
message.system!!.id.mention()
)
),
serverId = serverId
)
}
SystemMessageType.MESSAGE_PINNED -> {
RichMarkdown(
ChatMarkdown(
stringResource(
R.string.system_message_message_pinned,
message.system!!.by.mention()
)
),
serverId = serverId
)
}
SystemMessageType.MESSAGE_UNPINNED -> {
RichMarkdown(
ChatMarkdown(
stringResource(
R.string.system_message_message_unpinned,
message.system!!.by.mention()
)
),
serverId = serverId
)
}
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.runtime.Composable
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.Modifier
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.composables.generic.RemoteImage
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.ndk.AstNode
import chat.stoat.ndk.Stendal
@Composable
fun ChannelSheetHeader(
@ -42,16 +35,9 @@ fun ChannelSheetHeader(
channelIcon: AutumnResource? = null,
channelType: ChannelType,
channelDescription: String? = null,
serverId: String? = null,
dmPartner: User? = null
) {
var renderedChannelDescription by remember { mutableStateOf<AstNode?>(null) }
LaunchedEffect(channelDescription) {
if (channelDescription != null) {
renderedChannelDescription = Stendal.render(channelDescription)
}
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
@ -102,9 +88,9 @@ fun ChannelSheetHeader(
overflow = TextOverflow.Ellipsis
)
if (renderedChannelDescription != null && channelDescription?.isNotBlank() == true) {
if (!channelDescription.isNullOrBlank()) {
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
@ -43,14 +47,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import chat.stoat.R
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.MessageReplyStyle
import chat.stoat.callbacks.Action
import chat.stoat.callbacks.ActionChannel
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 com.mikepenz.markdown.model.State
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.abs
@ -70,11 +75,13 @@ fun RegularMessage(
showReactBottomSheet: () -> Unit,
putTextAtCursorPosition: (String) -> Unit,
replyToMessage: suspend (String) -> Unit,
scope: CoroutineScope = rememberCoroutineScope()
scope: CoroutineScope = rememberCoroutineScope(),
mdAst: State? = null
) {
val haptic = LocalHapticFeedback.current
var offsetX by remember { mutableFloatStateOf(0f) }
val offsetXState = remember { mutableFloatStateOf(0f) }
var offsetX by offsetXState
val animOffsetX by animateFloatAsState(
when {
drawerIsOpen -> 0f
@ -83,8 +90,28 @@ fun RegularMessage(
},
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) }
// 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) }
val canReleaseToSend = remember(offsetX) { offsetX <= SWIPE_TO_REPLY_THRESHOLD }
@ -182,10 +209,12 @@ fun RegularMessage(
},
fromWebhook = message.webhook != null,
webhookName = message.webhook?.name,
mdAst = mdAst,
modifier = Modifier
.offset(
x = with(LocalDensity.current) { animOffsetX.toDp() }
)
.nestedScroll(nestedScrollConnection)
.then(
if (LoadedSettings.messageReplyStyle == MessageReplyStyle.SwipeFromEnd)
Modifier.supportSwipeReply(

View File

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

View File

@ -51,6 +51,24 @@ fun resolveTimestamp(timestamp: Long, modifier: String? = null): String {
.withZone(ZoneId.systemDefault())
.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
"R" -> DateUtils.getRelativeTimeSpanString(
timestamp * 1000,
@ -58,8 +76,11 @@ fun resolveTimestamp(timestamp: Long, modifier: String? = null): String {
DateUtils.MINUTE_IN_MILLIS
)
// Fallback. Shouldn't happen, regex already checks for this
else -> timestamp.toString()
// f as in fallback
else -> DateTimeFormatter.ofPattern("dd MMMM yyyy HH:mm")
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault())
.format(javaInstant)
}
return outString.toString()

View File

@ -1,36 +1,50 @@
package chat.stoat.internals.text
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 {
private fun stringIsEntirelyCustomEmotes(content: String): Boolean {
return STRING_IS_CUSTOM_EMOTES_REGEX.matches(content)
}
private fun countCustomEmotes(content: String): Int =
CUSTOM_EMOTE_REGEX.findAll(content).count()
private fun stringIsEntirelyUnicodeEmojis(unfilteredContent: String): Boolean {
// Remove all custom emotes
private fun unicodeEmojiCount(unfilteredContent: String): Int? {
val content = unfilteredContent.replace(STRING_IS_CUSTOM_EMOTES_REGEX, "")
if (content.isBlank()) return 0
// the message is solely custom emotes
if (content.isBlank()) return true
if ("[0-9A-Za-z#*]".toRegex()
.containsMatchIn(content)
) { // reject common non-emoji characters
return false
}
var count = 0
var lastWasZwj = false
for (codepoint in content.codePoints()) {
if (MessageProcessor.emoji.codepointIsEmoji(codepoint)) {
return true
when {
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 false
return count
}
fun useGigamojiForMessage(content: String): Boolean {
if (content.isBlank()) return false
return stringIsEntirelyCustomEmotes(content) || stringIsEntirelyUnicodeEmojis(content)
fun useGigamojiForMessage(content: String): GigamojiState {
if (content.isBlank()) return GigamojiState.None
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalClipboard
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.Metadata
import chat.stoat.internals.extensions.zero
import chat.stoat.markdown.jbm.asHexString
import chat.stoat.screens.chat.LocalIsConnected
import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanQRCode
@ -179,8 +179,8 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: ()
text = AnnotatedString.fromHtml(
stringResource(
R.string.friends_add_by_tag_sheet_description,
"<font color=\"${Color(HL_USERNAME).asHexString(false)}\">",
"<font color=\"${Color(HL_TAG).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).toArgb().let { "#%02x%02x%02x".format(it shr 16 and 0xff, it shr 8 and 0xff, it and 0xff) }}\">",
"</font>",
)
),

View File

@ -90,6 +90,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.MessageField
import chat.stoat.composables.chat.SystemMessage
import chat.stoat.composables.chat.UnsupportedMessage
import chat.stoat.composables.emoji.EmojiPicker
import chat.stoat.composables.generic.GroupIcon
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.MessageContextSheet
import chat.stoat.sheets.ReactSheet
import com.mikepenz.markdown.model.State
import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer
import com.valentinilk.shimmer.shimmer
@ -157,14 +158,12 @@ import java.io.File
import kotlin.math.max
sealed class ChannelScreenItem {
data class RegularMessage(val message: Message) : ChannelScreenItem()
data class ProspectiveMessage(val message: Message) : ChannelScreenItem()
data class FailedMessage(val message: Message) : ChannelScreenItem()
data class RegularMessage(val message: Message, val mdAst: State?) : ChannelScreenItem()
data class ProspectiveMessage(val message: Message, val mdAst: State?) : ChannelScreenItem()
data class FailedMessage(val message: Message, val mdAst: State?) : ChannelScreenItem()
data class SystemMessage(val message: Message) : ChannelScreenItem()
data class DateDivider(val instant: Instant) : ChannelScreenItem()
data class LoadTrigger(val after: String?, val before: String?) :
ChannelScreenItem()
data class LoadTrigger(val after: String?, val before: String?) : ChannelScreenItem()
data object Loading : ChannelScreenItem()
}
@ -203,6 +202,7 @@ fun ChannelScreen(
// <editor-fold desc="State and effects">
val scope = rememberCoroutineScope()
val context = LocalContext.current
val resources = LocalResources.current
val config = LocalConfiguration.current
LaunchedEffect(Unit) {
@ -351,7 +351,7 @@ fun ChannelScreen(
} catch (e: Exception) {
Toast.makeText(
context,
context.getString(
resources.getString(
R.string.file_picker_chip_camera_failed
),
Toast.LENGTH_SHORT
@ -367,7 +367,7 @@ fun ChannelScreen(
} catch (e: Exception) {
Toast.makeText(
context,
context.getString(
resources.getString(
R.string.file_picker_chip_camera_none_installed
),
Toast.LENGTH_SHORT
@ -727,14 +727,6 @@ fun ChannelScreen(
}
when (val item = viewModel.items[index]) {
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(
item.message,
viewModel.channel,
@ -757,7 +749,8 @@ fun ChannelScreen(
},
putTextAtCursorPosition = viewModel::putAtCursorPosition,
replyToMessage = viewModel::addReplyTo,
scope = scope
scope = scope,
mdAst = item.mdAst
)
}
@ -772,7 +765,8 @@ fun ChannelScreen(
onNameClick = {},
canReply = false,
onReply = {},
onAddReaction = {}
onAddReaction = {},
mdAst = item.mdAst,
)
}
}
@ -787,7 +781,8 @@ fun ChannelScreen(
onNameClick = {},
canReply = false,
onReply = {},
onAddReaction = {}
onAddReaction = {},
mdAst = item.mdAst,
)
Row {
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.Message
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.screens.chat.ChatRouterDestination
import chat.stoat.settings.providers.AgeGateUnlockedStorageProvider
import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.parseMarkdownFlow
import io.ktor.http.ContentType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
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,
* and, if [setInitial] is true, updates the text field to say the new [content].
@ -395,7 +463,10 @@ class ChannelScreenViewModel(
} ?: false
)
updateItems(listOf(ChannelScreenItem.ProspectiveMessage(prospectiveMessage)) + items)
val ast = parseAst(content)
updateItems(
listOf(ChannelScreenItem.ProspectiveMessage(prospectiveMessage, ast)) + items
)
kvStorage.remove("draftContent/${channel?.id}")
putDraftContent("", true)
@ -415,7 +486,13 @@ class ChannelScreenViewModel(
)
} catch (e: Exception) {
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 {
isSending = false
}
@ -495,11 +572,17 @@ class ChannelScreenViewModel(
} else {
true
}
}.map {
when {
it.system != null -> ChannelScreenItem.SystemMessage(it)
else -> ChannelScreenItem.RegularMessage(it)
}.let { filtered ->
val result = mutableListOf<ChannelScreenItem>()
for (msg in filtered) {
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.
@ -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
val newItem = when {
it.system != null -> ChannelScreenItem.SystemMessage(it)
else -> ChannelScreenItem.RegularMessage(it)
else -> ChannelScreenItem.RegularMessage(it, parseAst(it.content))
}
updateItems(listOf(newItem) + items.filter { m ->
if (m is ChannelScreenItem.ProspectiveMessage) {
@ -601,9 +684,10 @@ class ChannelScreenViewModel(
updateItems(
items.map { m ->
if (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) {
ChannelScreenItem.RegularMessage(
m.message.mergeWithPartial(messageFrame)
)
val merged = m.message.mergeWithPartial(messageFrame)
val ast =
if (messageFrame.content != null) parseAst(merged.content) else m.mdAst
ChannelScreenItem.RegularMessage(merged, ast)
} else {
m
}
@ -624,7 +708,7 @@ class ChannelScreenViewModel(
items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
StoatAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m)
ChannelScreenItem.RegularMessage(m, currentMsg.mdAst)
} ?: return@map currentMsg
} else {
currentMsg
@ -648,7 +732,7 @@ class ChannelScreenViewModel(
items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
StoatAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m)
ChannelScreenItem.RegularMessage(m, currentMsg.mdAst)
} ?: return@map currentMsg
} else {
currentMsg
@ -672,7 +756,7 @@ class ChannelScreenViewModel(
items.map { currentMsg ->
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
StoatAPI.messageCache[it.id]?.let { m ->
ChannelScreenItem.RegularMessage(m)
ChannelScreenItem.RegularMessage(m, currentMsg.mdAst)
} ?: return@map currentMsg
} else {
currentMsg
@ -870,15 +954,15 @@ class ChannelScreenViewModel(
groupedItems.add(
when (m) {
is ChannelScreenItem.RegularMessage -> ChannelScreenItem.RegularMessage(
m.message.copy(
tail = tail
)
m.message.copy(tail = tail), m.mdAst
)
is ChannelScreenItem.ProspectiveMessage -> ChannelScreenItem.ProspectiveMessage(
m.message.copy(tail = tail), m.mdAst
)
is ChannelScreenItem.SystemMessage -> ChannelScreenItem.SystemMessage(
m.message.copy(
tail = tail
)
m.message.copy(tail = tail)
)
else -> m

View File

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

View File

@ -14,7 +14,6 @@ import androidx.navigation.compose.rememberNavController
import chat.stoat.api.settings.FeatureFlags
import chat.stoat.screens.labs.ui.mockups.NewLoginExperienceMockup
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.SettingsDslSandbox
import chat.stoat.screens.labs.ui.sandbox.TelecomSandbox
@ -71,9 +70,6 @@ fun LabsRootScreen(topNav: NavController) {
composable("sandboxes/settingsdsl") {
SettingsDslSandbox(labsNav)
}
composable("sandboxes/jbm") {
JBMSandbox(labsNav)
}
composable("sandboxes/gradienteditor") {
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.Intent
import androidx.compose.animation.animateContentSize
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ListItem
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.ToggleButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
@ -39,36 +25,18 @@ import androidx.navigation.NavController
import chat.stoat.BuildConfig
import chat.stoat.StoatApplication
import chat.stoat.api.settings.Experiments
import chat.stoat.api.settings.FeatureFlags
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.settings.dsl.SettingsPage
import chat.stoat.settings.dsl.SubcategoryContentInsets
import kotlinx.coroutines.launch
enum class MarkdownRenderer {
Stendal, JetBrains, FinalMarkdown
}
class ExperimentsSettingsScreenViewModel : ViewModel() {
private val kv = KVStorage(StoatApplication.instance)
fun init() {
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
enableServerIdentityOptionsChecked.value =
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)
fun setUsePolarChecked(value: Boolean) {
@ -212,64 +150,6 @@ fun ExperimentsSettingsScreen(
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(
headlineContent = {
Text("Threefold Root User Interface")
@ -307,7 +187,7 @@ fun ExperimentsSettingsScreen(
Text("Voice Chats 2.0")
},
supportingContent = {
RichMarkdown("Enable voice chats support.\n‼️ **Not available in this build!** ‼️")
UIMarkdown("Enable voice chats support.\n‼️ **Not available in this build!** ‼️")
},
trailingContent = {
Switch(

View File

@ -53,7 +53,7 @@ import chat.stoat.api.routes.auth.logoutAllSessions
import chat.stoat.api.routes.auth.logoutSessionById
import chat.stoat.core.model.schemas.Session
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 kotlinx.coroutines.launch
@ -199,7 +199,7 @@ fun SessionSettingsScreen(
}
} ?: run {
item(key = "noCurrentSession") {
RichMarkdown(
UIMarkdown(
input = stringResource(id = R.string.settings_sessions_this_device_unavailable),
modifier = Modifier
.fillMaxWidth()

View File

@ -109,6 +109,7 @@ fun ChannelInfoSheet(channelId: String, onHideSheet: suspend () -> Unit) {
channelIcon = channel.icon,
channelType = channel.channelType ?: ChannelType.TextChannel,
channelDescription = channel.description,
serverId = channel.server,
dmPartner = partner
)
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.text.AnnotatedString
import androidx.compose.ui.unit.dp
import chat.stoat.BuildConfig
import chat.stoat.R
import chat.stoat.api.StoatAPI
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.routes.channel.deleteMessage
import chat.stoat.api.routes.channel.react
import chat.stoat.api.settings.Experiments
import chat.stoat.callbacks.UiCallbacks
import chat.stoat.composables.chat.Message
import chat.stoat.composables.generic.SheetButton
@ -77,9 +75,6 @@ fun MessageContextSheet(
var showShareSheet by remember { mutableStateOf(false) }
var showReactSheet 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) {
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(
modifier = Modifier
.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(
leadingContent = {
Icon(

View File

@ -44,7 +44,7 @@ import chat.stoat.R
import chat.stoat.api.StoatAPI
import chat.stoat.api.routes.server.leaveOrDeleteServer
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.sheets.SheetSelection
import chat.stoat.core.model.data.STOAT_WEB_APP
@ -159,14 +159,13 @@ fun ServerContextSheet(
ServerOverview(server)
SelectionContainer {
RichMarkdown(
input = if (server.description?.isBlank() == false) {
ChatMarkdown(
content = if (server.description?.isBlank() == false) {
server.description!!
} else {
stringResource(
R.string.server_context_sheet_description_empty
)
}
stringResource(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.generic.NonIdealState
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.UserButtons
import chat.stoat.composables.sheets.SheetTile
@ -403,11 +403,11 @@ fun UserInfoSheet(
Text(stringResource(R.string.user_info_sheet_category_bio))
},
contentPreview = {
RichMarkdown(input = profile?.content!!)
ChatMarkdown(content = profile?.content!!, serverId = serverId)
}
) {
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.ui.text.ExperimentalTextApi
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.FontFamily
import androidx.compose.ui.text.font.FontStyle
@ -55,7 +56,9 @@ private val InterDisplay = FontFamily(
)
val FragmentMono = FontFamily(
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)

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-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-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-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }