From 39a025fcaa3022a83534d46f02e37e856e31d427 Mon Sep 17 00:00:00 2001 From: infi Date: Fri, 5 Jun 2026 08:29:37 +0200 Subject: [PATCH] feat: make markdown good --- app/build.gradle.kts | 1 + .../chat/stoat/api/settings/Experiments.kt | 14 - .../chat/stoat/api/settings/FeatureFlags.kt | 43 - .../java/chat/stoat/composables/chat/Embed.kt | 12 +- .../chat/stoat/composables/chat/InReplyTo.kt | 34 +- .../chat/stoat/composables/chat/Message.kt | 67 +- .../stoat/composables/chat/SystemMessage.kt | 66 +- .../composables/markdown/RichMarkdown.kt | 21 - .../markdown/prose/ChatMarkdown.kt | 408 ++++++ .../composables/markdown/prose/UIMarkdown.kt | 16 + .../screens/chat/ChannelSheetHeader.kt | 22 +- .../screens/chat/atoms/RegularMessage.kt | 39 +- .../java/chat/stoat/internals/Autocomplete.kt | 6 +- .../java/chat/stoat/internals/Timestamp.kt | 25 +- .../chat/stoat/internals/text/Gigamoji.kt | 58 +- .../stoat/markdown/CustomEmoteElementType.kt | 5 + .../markdown/CustomEmoteSequentialParser.kt | 54 + .../stoat/markdown/MentionElementTypes.kt | 8 + .../stoat/markdown/MentionSequentialParser.kt | 63 + .../stoat/markdown/StoatMarkdownFlavour.kt | 17 + .../stoat/markdown/TimestampElementType.kt | 5 + .../markdown/TimestampSequentialParser.kt | 57 + .../stoat/markdown/jbm/FallbackRenderer.kt | 154 --- .../java/chat/stoat/markdown/jbm/JBMApi.kt | 16 - .../chat/stoat/markdown/jbm/JBMRenderer.kt | 1208 ----------------- .../chat/stoat/markdown/jbm/KatexRenderer.kt | 134 -- .../stoat/markdown/jbm/MentionResolver.kt | 32 - .../stoat/markdown/jbm/RSMElementTypes.kt | 24 - .../markdown/jbm/RSMFlavourDescriptor.kt | 42 - .../sequentialparsers/ChannelMentionParser.kt | 40 - .../sequentialparsers/CustomEmoteParser.kt | 52 - .../sequentialparsers/MassMentionParser.kt | 17 - .../sequentialparsers/RoleMentionParser.kt | 42 - .../sequentialparsers/UserMentionParser.kt | 40 - .../stoat/screens/chat/views/FriendsScreen.kt | 6 +- .../chat/views/channel/ChannelScreen.kt | 35 +- .../views/channel/ChannelScreenViewModel.kt | 122 +- .../chat/stoat/screens/labs/LabsHomeScreen.kt | 9 - .../chat/stoat/screens/labs/LabsRootScreen.kt | 4 - .../screens/labs/ui/sandbox/JBMSandbox.kt | 112 -- .../settings/ExperimentsSettingsScreen.kt | 124 +- .../screens/settings/SessionSettingsScreen.kt | 4 +- .../chat/stoat/sheets/ChannelInfoSheet.kt | 1 + .../chat/stoat/sheets/JBMDebuggerSheet.kt | 103 -- .../chat/stoat/sheets/MessageContextSheet.kt | 38 - .../chat/stoat/sheets/ServerContextSheet.kt | 13 +- .../java/chat/stoat/sheets/UserInfoSheet.kt | 6 +- .../java/chat/stoat/ui/theme/Typography.kt | 5 +- gradle/libs.versions.toml | 1 + 49 files changed, 955 insertions(+), 2470 deletions(-) delete mode 100644 app/src/main/java/chat/stoat/composables/markdown/RichMarkdown.kt create mode 100644 app/src/main/java/chat/stoat/composables/markdown/prose/ChatMarkdown.kt create mode 100644 app/src/main/java/chat/stoat/composables/markdown/prose/UIMarkdown.kt create mode 100644 app/src/main/java/chat/stoat/markdown/CustomEmoteElementType.kt create mode 100644 app/src/main/java/chat/stoat/markdown/CustomEmoteSequentialParser.kt create mode 100644 app/src/main/java/chat/stoat/markdown/MentionElementTypes.kt create mode 100644 app/src/main/java/chat/stoat/markdown/MentionSequentialParser.kt create mode 100644 app/src/main/java/chat/stoat/markdown/StoatMarkdownFlavour.kt create mode 100644 app/src/main/java/chat/stoat/markdown/TimestampElementType.kt create mode 100644 app/src/main/java/chat/stoat/markdown/TimestampSequentialParser.kt delete mode 100644 app/src/main/java/chat/stoat/markdown/jbm/FallbackRenderer.kt delete mode 100644 app/src/main/java/chat/stoat/markdown/jbm/JBMApi.kt delete mode 100644 app/src/main/java/chat/stoat/markdown/jbm/JBMRenderer.kt delete mode 100644 app/src/main/java/chat/stoat/markdown/jbm/KatexRenderer.kt delete mode 100644 app/src/main/java/chat/stoat/markdown/jbm/MentionResolver.kt delete mode 100644 app/src/main/java/chat/stoat/markdown/jbm/RSMElementTypes.kt delete mode 100644 app/src/main/java/chat/stoat/markdown/jbm/RSMFlavourDescriptor.kt delete mode 100644 app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/ChannelMentionParser.kt delete mode 100644 app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/CustomEmoteParser.kt delete mode 100644 app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/MassMentionParser.kt delete mode 100644 app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/RoleMentionParser.kt delete mode 100644 app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/UserMentionParser.kt delete mode 100644 app/src/main/java/chat/stoat/screens/labs/ui/sandbox/JBMSandbox.kt delete mode 100644 app/src/main/java/chat/stoat/sheets/JBMDebuggerSheet.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dddec205..7f4d9375 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/chat/stoat/api/settings/Experiments.kt b/app/src/main/java/chat/stoat/api/settings/Experiments.kt index ea72c4e7..30d01a0b 100644 --- a/app/src/main/java/chat/stoat/api/settings/Experiments.kt +++ b/app/src/main/java/chat/stoat/api/settings/Experiments.kt @@ -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) - } } } \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/api/settings/FeatureFlags.kt b/app/src/main/java/chat/stoat/api/settings/FeatureFlags.kt index c7829d09..c3cfe2f3 100644 --- a/app/src/main/java/chat/stoat/api/settings/FeatureFlags.kt +++ b/app/src/main/java/chat/stoat/api/settings/FeatureFlags.kt @@ -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.Disabled - ) - val massMentionsGranted: Boolean - get() = when (massMentions) { - is MassMentionsVariates.Enabled -> true - is MassMentionsVariates.Disabled -> false - } - - @FeatureFlag("FinalMarkdown") - var finalMarkdown by mutableStateOf( - FinalMarkdownVariates.Disabled - ) - val finalMarkdownGranted: Boolean - get() = when (finalMarkdown) { - is FinalMarkdownVariates.Enabled -> true - is FinalMarkdownVariates.Disabled -> false - } } diff --git a/app/src/main/java/chat/stoat/composables/chat/Embed.kt b/app/src/main/java/chat/stoat/composables/chat/Embed.kt index 8b690a1c..68396263 100644 --- a/app/src/main/java/chat/stoat/composables/chat/Embed.kt +++ b/app/src/main/java/chat/stoat/composables/chat/Embed.kt @@ -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) } } } diff --git a/app/src/main/java/chat/stoat/composables/chat/InReplyTo.kt b/app/src/main/java/chat/stoat/composables/chat/InReplyTo.kt index 27073b82..d77c99a7 100644 --- a/app/src/main/java/chat/stoat/composables/chat/InReplyTo.kt +++ b/app/src/main/java/chat/stoat/composables/chat/InReplyTo.kt @@ -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( diff --git a/app/src/main/java/chat/stoat/composables/chat/Message.kt b/app/src/main/java/chat/stoat/composables/chat/Message.kt index c2b3cc3a..c89bb144 100644 --- a/app/src/main/java/chat/stoat/composables/chat/Message.kt +++ b/app/src/main/java/chat/stoat/composables/chat/Message.kt @@ -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)) diff --git a/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt b/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt index 73721650..c5fbc1c6 100644 --- a/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt +++ b/app/src/main/java/chat/stoat/composables/chat/SystemMessage.kt @@ -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) } } } } diff --git a/app/src/main/java/chat/stoat/composables/markdown/RichMarkdown.kt b/app/src/main/java/chat/stoat/composables/markdown/RichMarkdown.kt deleted file mode 100644 index d3d94744..00000000 --- a/app/src/main/java/chat/stoat/composables/markdown/RichMarkdown.kt +++ /dev/null @@ -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)) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/composables/markdown/prose/ChatMarkdown.kt b/app/src/main/java/chat/stoat/composables/markdown/prose/ChatMarkdown.kt new file mode 100644 index 00000000..2e6a6846 --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/markdown/prose/ChatMarkdown.kt @@ -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("") + 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, + ) + } +} diff --git a/app/src/main/java/chat/stoat/composables/markdown/prose/UIMarkdown.kt b/app/src/main/java/chat/stoat/composables/markdown/prose/UIMarkdown.kt new file mode 100644 index 00000000..b0142266 --- /dev/null +++ b/app/src/main/java/chat/stoat/composables/markdown/prose/UIMarkdown.kt @@ -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, + ) +} diff --git a/app/src/main/java/chat/stoat/composables/screens/chat/ChannelSheetHeader.kt b/app/src/main/java/chat/stoat/composables/screens/chat/ChannelSheetHeader.kt index 46b19573..b92fb8c5 100644 --- a/app/src/main/java/chat/stoat/composables/screens/chat/ChannelSheetHeader.kt +++ b/app/src/main/java/chat/stoat/composables/screens/chat/ChannelSheetHeader.kt @@ -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(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) } } } diff --git a/app/src/main/java/chat/stoat/composables/screens/chat/atoms/RegularMessage.kt b/app/src/main/java/chat/stoat/composables/screens/chat/atoms/RegularMessage.kt index 6a6e15ae..88afc3d9 100644 --- a/app/src/main/java/chat/stoat/composables/screens/chat/atoms/RegularMessage.kt +++ b/app/src/main/java/chat/stoat/composables/screens/chat/atoms/RegularMessage.kt @@ -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( diff --git a/app/src/main/java/chat/stoat/internals/Autocomplete.kt b/app/src/main/java/chat/stoat/internals/Autocomplete.kt index 273dca5d..b87d9b0c 100644 --- a/app/src/main/java/chat/stoat/internals/Autocomplete.kt +++ b/app/src/main/java/chat/stoat/internals/Autocomplete.kt @@ -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() } diff --git a/app/src/main/java/chat/stoat/internals/Timestamp.kt b/app/src/main/java/chat/stoat/internals/Timestamp.kt index 41fb9db0..d08318fc 100644 --- a/app/src/main/java/chat/stoat/internals/Timestamp.kt +++ b/app/src/main/java/chat/stoat/internals/Timestamp.kt @@ -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() diff --git a/app/src/main/java/chat/stoat/internals/text/Gigamoji.kt b/app/src/main/java/chat/stoat/internals/text/Gigamoji.kt index 227e90c2..a025f770 100644 --- a/app/src/main/java/chat/stoat/internals/text/Gigamoji.kt +++ b/app/src/main/java/chat/stoat/internals/text/Gigamoji.kt @@ -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 + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/chat/stoat/markdown/CustomEmoteElementType.kt b/app/src/main/java/chat/stoat/markdown/CustomEmoteElementType.kt new file mode 100644 index 00000000..876906b7 --- /dev/null +++ b/app/src/main/java/chat/stoat/markdown/CustomEmoteElementType.kt @@ -0,0 +1,5 @@ +package chat.stoat.markdown + +import org.intellij.markdown.IElementType + +val CUSTOM_EMOTE_ELEMENT_TYPE: IElementType = IElementType("CUSTOM_EMOTE") diff --git a/app/src/main/java/chat/stoat/markdown/CustomEmoteSequentialParser.kt b/app/src/main/java/chat/stoat/markdown/CustomEmoteSequentialParser.kt new file mode 100644 index 00000000..998add94 --- /dev/null +++ b/app/src/main/java/chat/stoat/markdown/CustomEmoteSequentialParser.kt @@ -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, + ): 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()) + } +} diff --git a/app/src/main/java/chat/stoat/markdown/MentionElementTypes.kt b/app/src/main/java/chat/stoat/markdown/MentionElementTypes.kt new file mode 100644 index 00000000..4b5ead8f --- /dev/null +++ b/app/src/main/java/chat/stoat/markdown/MentionElementTypes.kt @@ -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") diff --git a/app/src/main/java/chat/stoat/markdown/MentionSequentialParser.kt b/app/src/main/java/chat/stoat/markdown/MentionSequentialParser.kt new file mode 100644 index 00000000..89dffdf7 --- /dev/null +++ b/app/src/main/java/chat/stoat/markdown/MentionSequentialParser.kt @@ -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): 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()) + } +} diff --git a/app/src/main/java/chat/stoat/markdown/StoatMarkdownFlavour.kt b/app/src/main/java/chat/stoat/markdown/StoatMarkdownFlavour.kt new file mode 100644 index 00000000..a9e5ccb7 --- /dev/null +++ b/app/src/main/java/chat/stoat/markdown/StoatMarkdownFlavour.kt @@ -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 { + return listOf( + MentionSequentialParser(content), + TimestampSequentialParser(content), + CustomEmoteSequentialParser(content), + ) + super@StoatMarkdownFlavour.sequentialParserManager.getParserSequence() + } + } +} diff --git a/app/src/main/java/chat/stoat/markdown/TimestampElementType.kt b/app/src/main/java/chat/stoat/markdown/TimestampElementType.kt new file mode 100644 index 00000000..498bf858 --- /dev/null +++ b/app/src/main/java/chat/stoat/markdown/TimestampElementType.kt @@ -0,0 +1,5 @@ +package chat.stoat.markdown + +import org.intellij.markdown.IElementType + +val TIMESTAMP_ELEMENT_TYPE: IElementType = IElementType("TIMESTAMP") diff --git a/app/src/main/java/chat/stoat/markdown/TimestampSequentialParser.kt b/app/src/main/java/chat/stoat/markdown/TimestampSequentialParser.kt new file mode 100644 index 00000000..35452ccd --- /dev/null +++ b/app/src/main/java/chat/stoat/markdown/TimestampSequentialParser.kt @@ -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): 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 + } + } +} diff --git a/app/src/main/java/chat/stoat/markdown/jbm/FallbackRenderer.kt b/app/src/main/java/chat/stoat/markdown/jbm/FallbackRenderer.kt deleted file mode 100644 index 7bb5a3b3..00000000 --- a/app/src/main/java/chat/stoat/markdown/jbm/FallbackRenderer.kt +++ /dev/null @@ -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" - ) - } - } - ) -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/markdown/jbm/JBMApi.kt b/app/src/main/java/chat/stoat/markdown/jbm/JBMApi.kt deleted file mode 100644 index 3aa04eb0..00000000 --- a/app/src/main/java/chat/stoat/markdown/jbm/JBMApi.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/markdown/jbm/JBMRenderer.kt b/app/src/main/java/chat/stoat/markdown/jbm/JBMRenderer.kt deleted file mode 100644 index e3dc3e65..00000000 --- a/app/src/main/java/chat/stoat/markdown/jbm/JBMRenderer.kt +++ /dev/null @@ -1,1208 +0,0 @@ -package chat.stoat.markdown.jbm - -import android.content.Intent -import android.content.res.Configuration -import android.util.Log -import android.widget.Toast -import androidx.browser.customtabs.CustomTabColorSchemeParams -import androidx.browser.customtabs.CustomTabsIntent -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.LocalContentColor -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.compositionLocalOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.structuralEqualityPolicy -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.RoundRect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri -import chat.stoat.R -import chat.stoat.activities.InviteActivity -import chat.stoat.api.StoatAPI -import chat.stoat.api.internals.BrushCompat -import chat.stoat.api.internals.InstancedBrushCompat -import chat.stoat.api.internals.isUlid -import chat.stoat.api.routes.custom.fetchEmoji -import chat.stoat.core.model.schemas.isInviteUri -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.composables.generic.UserAvatar -import chat.stoat.composables.markdown.Annotations -import chat.stoat.composables.utils.detectTapGesturesConditionalConsume -import chat.stoat.core.model.data.STOAT_FILES -import chat.stoat.ui.theme.FragmentMono -import chat.stoat.ui.theme.isThemeDark -import dev.snipme.highlights.Highlights -import dev.snipme.highlights.model.BoldHighlight -import dev.snipme.highlights.model.CodeHighlight -import dev.snipme.highlights.model.ColorHighlight -import dev.snipme.highlights.model.SyntaxLanguage -import dev.snipme.highlights.model.SyntaxThemes -import kotlinx.coroutines.launch -import org.intellij.markdown.MarkdownElementTypes -import org.intellij.markdown.MarkdownTokenTypes -import org.intellij.markdown.ast.ASTNode -import org.intellij.markdown.ast.getTextInNode -import org.intellij.markdown.flavours.gfm.GFMElementTypes -import org.intellij.markdown.flavours.gfm.GFMTokenTypes - -enum class JBMAnnotations(val tag: String, val clickable: Boolean) { - URL("URL", true), - UserMention("UserMention", true), - ChannelMention("ChannelMention", true), - RoleMention("RoleMention", false), - CustomEmote("CustomEmote", true), - Timestamp("Timestamp", false), - Checkbox("Checkbox", false), - UserAvatar("UserAvatar", true), - RoleChip("RoleChip", false), - JBMBackgroundRoundingStart("JBMBackgroundRoundingStart", false), - JBMBackgroundRoundingEnd("JBMBackgroundRoundingEnd", false), -} - -object JBMRegularExpressions { - val Timestamp = Regex("") -} - -data class JBMColors( - val clickable: Color, - val clickableBackground: Color, -) - -data class JBMarkdownTreeState( - val sourceText: String = "", - val listDepth: Int = 0, - val fontSizeMultiplier: Float = 1f, - val linksClickable: Boolean = true, - val currentServer: String? = null, - val embedded: Boolean = false, - val singleLine: Boolean = false, - val colors: JBMColors = JBMColors( - clickable = Color(0xFFFF00FF), - clickableBackground = Color(0x2000FF00) - ), - val brushCompat: InstancedBrushCompat? = null -) - -val LocalJBMarkdownTreeState = - compositionLocalOf(structuralEqualityPolicy()) { JBMarkdownTreeState() } - -val avatarPadding = 2.dp - -@Composable -@JBM -fun JBMRenderer(content: String, modifier: Modifier = Modifier) { - var tree by remember { mutableStateOf(JBMApi.parse(content)) } - - LaunchedEffect(content) { - tree = JBMApi.parse(content) - } - - CompositionLocalProvider( - LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy( - sourceText = content, - colors = JBMColors( - clickable = MaterialTheme.colorScheme.primary, - clickableBackground = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) - ), - brushCompat = InstancedBrushCompat( - defaultColour = LocalContentColor.current, - primaryColour = MaterialTheme.colorScheme.primary, - onBackgroundColour = MaterialTheme.colorScheme.onBackground, - backgroundColour = MaterialTheme.colorScheme.background, - errorColour = MaterialTheme.colorScheme.error, - ) - ) - ) { - if (LocalJBMarkdownTreeState.current.embedded) { - tree.children.getOrNull(0)?.let { - JBMBlock(it, modifier) - } - } else { - tree.children.map { - JBMBlock(it, modifier) - } - } - } -} - -private fun annotateText( - state: JBMarkdownTreeState, - node: ASTNode -): AnnotatedString { - val sourceText = state.sourceText - - return try { - buildAnnotatedString { - when (node.type) { - MarkdownTokenTypes.TEXT -> { - val source = if (state.embedded) { - node.getTextInNode(sourceText).toString().replace("\n", " ") - } else { - node.getTextInNode(sourceText) - } - append(source) - } - - RSMElementTypes.USER_MENTION -> { - val contents = node.getTextInNode(sourceText).toString() - val userId = contents.removeSurrounding("<@", ">") - if (userId == contents || !userId.isUlid()) { - // Invalid user mention. Append as if it were regular text. - for (child in node.children) { - append(annotateText(state, child)) - } - } else { - pushStringAnnotation( - tag = JBMAnnotations.UserMention.tag, - annotation = userId - ) - pushStyle( - SpanStyle( - color = state.colors.clickable, - background = state.colors.clickableBackground - ) - ) - - append(" ") - appendInlineContent(JBMAnnotations.UserAvatar.tag, userId) - append(" ") - append(MentionResolver.resolveUser(userId, state.currentServer)) - append(" ") - - pop() - pop() - } - } - - RSMElementTypes.CHANNEL_MENTION -> { - val contents = node.getTextInNode(sourceText).toString() - val channelId = contents.removeSurrounding("<#", ">") - if (channelId == contents || !channelId.isUlid()) { - // Invalid channel mention. Append as if it were regular text. - for (child in node.children) { - append(annotateText(state, child)) - } - } else { - pushStringAnnotation( - tag = JBMAnnotations.ChannelMention.tag, - annotation = channelId - ) - pushStyle( - SpanStyle( - color = state.colors.clickable, - background = state.colors.clickableBackground - ) - ) - - append(MentionResolver.resolveChannel(channelId)) - - pop() - pop() - } - } - - RSMElementTypes.ROLE_MENTION -> { - val contents = node.getTextInNode(sourceText).toString() - val roleId = contents.removeSurrounding("<%", ">") - if (roleId == contents || !roleId.isUlid() || state.currentServer == null) { - // Invalid role mention. Append as if it were regular text. - for (child in node.children) { - append(annotateText(state, child)) - } - } else { - val server = StoatAPI.serverCache[state.currentServer] - val role = server?.roles?.get(roleId) - val isGradient = role?.colour?.contains("gradient") == true - - pushStyle( - SpanStyle( - background = state.colors.clickableBackground - ) - ) - pushStyle( - SpanStyle( - brush = (if (!isGradient) role?.colour?.let { - state.brushCompat?.parseColour( - it - ) - } else null) - ?: SolidColor(state.colors.clickable), - ) - ) - pushStyle( - SpanStyle( - background = state.colors.clickableBackground - ) - ) - append(" ") - appendInlineContent(JBMAnnotations.RoleChip.tag, roleId) - append(" ") - append(role?.name ?: "invalid-role") - append(" ") - pop() - pop() - pop() - } - } - - RSMElementTypes.CUSTOM_EMOTE -> { - val contents = node.getTextInNode(sourceText).toString() - val emoteId = contents.removeSurrounding(":", ":") - if (emoteId == contents || !emoteId.isUlid()) { - // Invalid custom emote. Append as if it were regular text. - for (child in node.children) { - append(annotateText(state, child)) - } - } else { - pushStringAnnotation( - tag = JBMAnnotations.CustomEmote.tag, - annotation = emoteId - ) - appendInlineContent(JBMAnnotations.CustomEmote.tag, emoteId) - pop() - } - } - - MarkdownTokenTypes.ATX_HEADER -> { - // Do not need to do anything - } - - MarkdownElementTypes.ATX_1, - MarkdownElementTypes.ATX_2, - MarkdownElementTypes.ATX_3, - MarkdownElementTypes.ATX_4, - MarkdownElementTypes.ATX_5, - MarkdownElementTypes.ATX_6 -> { - for (child in node.children) { - append(annotateText(state, child)) - } - } - - MarkdownElementTypes.EMPH -> { - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - // Skip the first child and the last child - // because they are the asterisk characters - for (child in node.children.subList(1, node.children.size - 1)) { - append(annotateText(state, child)) - } - } - } - - MarkdownElementTypes.STRONG -> { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - // Skip the first two children and the last two children - // because they are the asterisk characters - for (child in node.children.subList(2, node.children.size - 2)) { - append(annotateText(state, child)) - } - } - } - - GFMElementTypes.STRIKETHROUGH -> { - withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { - // Skip the first two children and the last two children - // because they are the tilde characters - for (child in node.children.subList(2, node.children.size - 2)) { - append(annotateText(state, child)) - } - } - } - - MarkdownElementTypes.CODE_SPAN -> { - withStyle(SpanStyle(fontFamily = FragmentMono)) { - val startsWithTwoBackticks = - node.children.getOrNull(1)?.type == MarkdownTokenTypes.BACKTICK - val removeItemCount = if (startsWithTwoBackticks) 2 else 1 - // Skip the first and last 1 or 2 children - // because they are the backtick characters - for (child in node.children.subList( - removeItemCount, - node.children.size - removeItemCount - )) { - append(annotateText(state, child)) - } - } - } - - MarkdownTokenTypes.LIST_BULLET -> { - append(" ".repeat(state.listDepth) + " • ") - } - - MarkdownTokenTypes.LIST_NUMBER -> { - withStyle(SpanStyle(fontFeatureSettings = "'tnum'")) { - append(" ".repeat(state.listDepth) + "${node.getTextInNode(sourceText)} ") - } - } - - MarkdownElementTypes.UNORDERED_LIST, - MarkdownElementTypes.ORDERED_LIST, - MarkdownElementTypes.LIST_ITEM -> { - for (child in node.children) { - append(annotateText(state, child)) - } - } - - GFMTokenTypes.CHECK_BOX -> { - if (node.getTextInNode(sourceText).trim() == "[ ]") { - appendInlineContent(JBMAnnotations.Checkbox.tag, "❌") - } else { - appendInlineContent(JBMAnnotations.Checkbox.tag, "✅") - } - append(" ") - } - - MarkdownElementTypes.PARAGRAPH, - MarkdownElementTypes.HTML_BLOCK, - MarkdownTokenTypes.HTML_TAG -> { - for (child in node.children) { - append(annotateText(state, child)) - } - } - - MarkdownTokenTypes.ATX_CONTENT -> { - // Drop WHITE_SPACE children at the start - for (child in node.children.dropWhile { it.type == MarkdownTokenTypes.WHITE_SPACE }) { - append(annotateText(state, child)) - } - } - - MarkdownElementTypes.INLINE_LINK -> { - val linkTextChild = - node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_TEXT } - val linkDestinationChild = - node.children.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION } - ?: node.children.firstOrNull { it.type == MarkdownElementTypes.AUTOLINK } - - pushStringAnnotation( - tag = JBMAnnotations.URL.tag, - annotation = linkDestinationChild?.getTextInNode(sourceText).toString() - .removeSurrounding("<", ">") - ) - pushStyle(SpanStyle(color = state.colors.clickable)) - linkTextChild?.children - ?.drop(1) // l-bracket - ?.dropLast(1) // r-bracket - ?.forEach { - append(annotateText(state, it)) - } - pop() - pop() - } - - GFMTokenTypes.GFM_AUTOLINK, - MarkdownTokenTypes.AUTOLINK -> { - pushStringAnnotation( - tag = JBMAnnotations.URL.tag, - annotation = node.getTextInNode(sourceText).toString() - .removeSurrounding("<", ">") - ) - pushStyle(SpanStyle(color = state.colors.clickable)) - append(node.getTextInNode(sourceText)) - pop() - pop() - } - - // re-render types - // for example, various syntactic elements like exclamation marks, brackets, etc. - // we simply append the text as is - MarkdownTokenTypes.EXCLAMATION_MARK, - MarkdownTokenTypes.LBRACKET, - MarkdownTokenTypes.RBRACKET, - MarkdownTokenTypes.LPAREN, - MarkdownTokenTypes.RPAREN, - MarkdownTokenTypes.LT, - MarkdownTokenTypes.GT, - MarkdownTokenTypes.BACKTICK, - MarkdownTokenTypes.DOUBLE_QUOTE, - MarkdownTokenTypes.SINGLE_QUOTE, - MarkdownTokenTypes.EOL, - MarkdownTokenTypes.WHITE_SPACE, - MarkdownTokenTypes.COLON, - MarkdownTokenTypes.EMPH, - GFMTokenTypes.TILDE, - GFMTokenTypes.DOLLAR -> { - append(node.getTextInNode(sourceText)) - } - - MarkdownElementTypes.SHORT_REFERENCE_LINK, - MarkdownElementTypes.LINK_DEFINITION, - MarkdownElementTypes.FULL_REFERENCE_LINK -> { - append(node.getTextInNode(sourceText)) - } - - else -> { - withStyle(SpanStyle(color = Color.Cyan)) { - append("[${node.type.name}]{\n") - } - for (child in node.children) { - append(annotateText(state, child)) - } - withStyle(SpanStyle(color = Color.Cyan)) { - append("\n}") - } - } - } - } - } catch (e: Exception) { - buildAnnotatedString { - withStyle(SpanStyle(color = Color(0xFFFF0000), background = Color(0xFF000000))) { - append("[${node.type.name}] Error: ${e.message}") - } - - Log.e("JBMRenderer", "Error rendering node: ${node.type.name}", e) - } - } -} - -@Composable -private fun JBMText(node: ASTNode, modifier: Modifier) { - var layoutResult by remember { mutableStateOf(null) } - val mdState = LocalJBMarkdownTreeState.current - val annotatedText = remember(node) { annotateText(mdState, node) } - val colours = MaterialTheme.colorScheme - val scope = rememberCoroutineScope() - val context = LocalContext.current - - val shouldConsumeTap = handler@{ offset: Int -> - if (!mdState.linksClickable) { - return@handler false - } - - JBMAnnotations.entries.filter { it.clickable }.map { it.tag }.forEach { tag -> - if (annotatedText.getStringAnnotations( - tag = tag, - start = offset, - end = offset - ).isNotEmpty() - ) { - return@handler true - } - } - - return@handler false - } - - val onClick = handler@{ offset: Int -> - if (mdState.linksClickable) { - JBMAnnotations.entries.filter { it.clickable }.map { it.tag }.forEach { tag -> - val annotations = annotatedText.getStringAnnotations( - tag = tag, - start = offset, - end = offset - ) - annotations.forEach { annotation -> - val item = annotation.item - when (tag) { - JBMAnnotations.URL.tag -> { - try { - val uri = item.toUri() - if (uri.isInviteUri()) { - scope.launch { - Intent(context, InviteActivity::class.java).apply { - data = uri - context.startActivity(this) - } - } - return@handler true - } - } catch (e: Exception) { - // no-op - } - - val customTab = CustomTabsIntent.Builder() - .setShowTitle(true) - .setDefaultColorSchemeParams( - CustomTabColorSchemeParams.Builder() - .setToolbarColor(colours.surfaceContainer.toArgb()) - .build() - ) - .build() - - try { - customTab.launchUrl(context, item.toUri()) - } catch (e: Exception) { - Toast.makeText( - context, - context.getString(R.string.link_type_no_intent), - Toast.LENGTH_SHORT - ).show() - } - - return@handler true - } - - JBMAnnotations.UserMention.tag -> { - scope.launch { - ActionChannel.send( - Action.OpenUserSheet( - item, - mdState.currentServer - ) - ) - } - return@handler true - } - - JBMAnnotations.ChannelMention.tag -> { - scope.launch { - ActionChannel.send( - Action.SwitchChannel(item) - ) - } - return@handler true - } - - JBMAnnotations.CustomEmote.tag -> { - scope.launch { - ActionChannel.send( - Action.EmoteInfo(item) - ) - } - return@handler true - } - } - } - } - } - - return@handler false - } - - val onLongClick = handler@{ offset: Int -> - if (mdState.linksClickable) { - annotatedText.getStringAnnotations( - tag = Annotations.URL.tag, - start = offset, - end = offset - ).firstOrNull()?.let { annotation -> - scope.launch { - ActionChannel.send(Action.LinkInfo(annotation.item)) - } - - return@handler true - } - } - - return@handler false - } - - Text( - text = annotatedText, - onTextLayout = { layoutResult = it }, - maxLines = if (mdState.singleLine) 1 else Int.MAX_VALUE, - overflow = if (mdState.singleLine) TextOverflow.Ellipsis else TextOverflow.Clip, - modifier = modifier.pointerInput(onClick, onLongClick) { - detectTapGesturesConditionalConsume( - onTap = { pos -> - val index = - layoutResult?.getOffsetForPosition(pos) - ?: return@detectTapGesturesConditionalConsume - onClick(index) - }, - onLongPress = { pos -> - val index = - layoutResult?.getOffsetForPosition(pos) - ?: return@detectTapGesturesConditionalConsume - onLongClick(index) - }, - shouldConsumeTap = { pos -> - val index = - layoutResult?.getOffsetForPosition(pos) - ?: return@detectTapGesturesConditionalConsume false - shouldConsumeTap(index) - } - ) - }, - inlineContent = mapOf( - JBMAnnotations.Checkbox.tag to InlineTextContent( - placeholder = Placeholder( - width = LocalTextStyle.current.fontSize * 1.5, - height = LocalTextStyle.current.fontSize * 1.5, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ), - children = { alternateText -> - val isCheck = alternateText == "✅" - - with(LocalDensity.current) { - Canvas(modifier = Modifier.size((LocalTextStyle.current.fontSize * 1.5).toDp())) { - drawRoundRect( - color = if (isCheck) colours.primaryContainer else colours.surfaceContainer, - cornerRadius = CornerRadius(size.width * 0.1f), - topLeft = Offset(size.width * 0.1f, size.height * 0.1f), - size = size.copy( - width = size.width * 0.8f, - height = size.height * 0.8f - ) - ) - - if (isCheck) { - drawPath( - path = Path().apply { - moveTo(size.width * 0.8f, size.height * 0.3f) - lineTo(size.width * 0.4f, size.height * 0.7f) - lineTo(size.width * 0.2f, size.height * 0.5f) - }, - color = colours.onPrimaryContainer, - style = Stroke(width = size.width * 0.1f) - ) - } - } - } - } - ), - JBMAnnotations.RoleChip.tag to with(LocalDensity.current) { - val placeholderBaseWidth = - (LocalTextStyle.current.fontSize * 1.5).toPx() - (avatarPadding * 2).toPx() - val widthTolerancePx = - 2 // Else we get a gap of about 1-2 pixels due to rounding errors - val placeholderBaseHeight = (LocalTextStyle.current.fontSize * 1.5).toPx() - val heightTolerancePx = 2 // Dito - - InlineTextContent( - placeholder = Placeholder( - width = (placeholderBaseWidth - widthTolerancePx).toSp(), - height = (placeholderBaseHeight - heightTolerancePx).toSp(), - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ), - ) { id -> - val role = StoatAPI.serverCache[mdState.currentServer]?.roles?.get(id) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .background(LocalJBMarkdownTreeState.current.colors.clickableBackground) - .padding(vertical = avatarPadding) - ) { - Box( - modifier = Modifier - .clip(CircleShape) - .background( - role?.colour?.let { BrushCompat.parseColour(it) } - ?: SolidColor(MaterialTheme.colorScheme.primaryContainer) - ) - .size((LocalTextStyle.current.fontSize * 1.5).toDp() - (avatarPadding * 2)) - ) - } - } - }, - JBMAnnotations.CustomEmote.tag to InlineTextContent( - placeholder = Placeholder( - width = LocalTextStyle.current.fontSize * 1.5, - height = LocalTextStyle.current.fontSize * 1.5, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ), - ) { id -> - val emote = StoatAPI.emojiCache[id] - if (emote == null) { - scope.launch { - try { - StoatAPI.emojiCache[id] = fetchEmoji(id) - } catch (e: Exception) { - // no-op - } - } - return@InlineTextContent - } else { - with(LocalDensity.current) { - RemoteImage( - url = "$STOAT_FILES/emojis/${id}", - description = emote.name, - contentScale = ContentScale.Fit, - modifier = Modifier - .width((LocalTextStyle.current.fontSize * 1.5).toDp()) - .height((LocalTextStyle.current.fontSize * 1.5).toDp()) - ) - } - } - }, - JBMAnnotations.UserAvatar.tag to with(LocalDensity.current) { - val placeholderBaseWidth = - (LocalTextStyle.current.fontSize * 1.5).toPx() - (avatarPadding * 2).toPx() - val widthTolerancePx = - 2 // Else we get a gap of about 1-2 pixels due to rounding errors - val placeholderBaseHeight = (LocalTextStyle.current.fontSize * 1.5).toPx() - val heightTolerancePx = 2 // Dito - - InlineTextContent( - placeholder = Placeholder( - width = (placeholderBaseWidth - widthTolerancePx).toSp(), - height = (placeholderBaseHeight - heightTolerancePx).toSp(), - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ), - ) { id -> - val user = StoatAPI.userCache[id] - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .background(LocalJBMarkdownTreeState.current.colors.clickableBackground) - .padding(vertical = avatarPadding) - ) { - if (user == null) { - UserAvatar( - username = stringResource(R.string.unknown), - userId = id, - size = (LocalTextStyle.current.fontSize * 1.5).toDp() - (avatarPadding * 2), - modifier = Modifier.aspectRatio(1f, true) - ) - } else { - UserAvatar( - username = user.username ?: "", - avatar = user.avatar, - userId = user.id ?: "", - size = (LocalTextStyle.current.fontSize * 1.5).toDp() - (avatarPadding * 2), - modifier = Modifier.aspectRatio(1f, true) - ) - } - } - } - }, - JBMAnnotations.JBMBackgroundRoundingStart.tag to with(LocalDensity.current) { - InlineTextContent( - placeholder = Placeholder( - width = LocalTextStyle.current.fontSize * 0.25, - height = LocalTextStyle.current.fontSize * 1.5, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ), - ) { - val colour = LocalJBMarkdownTreeState.current.colors.clickableBackground - Canvas( - modifier = Modifier - .width((LocalTextStyle.current.fontSize * 0.25).toDp()) - .height((LocalTextStyle.current.fontSize * 1.5).toDp()) - ) { - drawPath( - Path().apply { - addRoundRect( - RoundRect( - 0f, - 0f, - size.width, - size.height, - topLeftCornerRadius = CornerRadius(size.width), - topRightCornerRadius = CornerRadius(0f), - bottomLeftCornerRadius = CornerRadius(size.width), - bottomRightCornerRadius = CornerRadius(0f) - ) - ) - }, - color = colour - ) - } - } - }, - JBMAnnotations.JBMBackgroundRoundingEnd.tag to with(LocalDensity.current) { - InlineTextContent( - placeholder = Placeholder( - width = LocalTextStyle.current.fontSize * 0.25, - height = LocalTextStyle.current.fontSize * 1.5, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ), - ) { - val colour = LocalJBMarkdownTreeState.current.colors.clickableBackground - Canvas( - modifier = Modifier - .width((LocalTextStyle.current.fontSize * 0.25).toDp()) - .height((LocalTextStyle.current.fontSize * 1.5).toDp()) - ) { - drawPath( - Path().apply { - addRoundRect( - RoundRect( - 0f, - 0f, - size.width, - size.height, - topLeftCornerRadius = CornerRadius(0f), - topRightCornerRadius = CornerRadius(size.width), - bottomLeftCornerRadius = CornerRadius(0f), - bottomRightCornerRadius = CornerRadius(size.width) - ) - ) - }, - color = colour - ) - } - } - } - ) - ) -} - -private fun annotateHighlights( - source: String, - highlights: List -): AnnotatedString { - val highlightStyles = highlights.map { - when (it) { - is BoldHighlight -> AnnotatedString.Range( - SpanStyle(fontWeight = FontWeight.Bold), - it.location.start, - it.location.end - ) - - is ColorHighlight -> { - AnnotatedString.Range( - SpanStyle(color = Color(0xFF000000 or it.rgb.toLong())), - it.location.start, - it.location.end - ) - } - } - } - - return AnnotatedString(source, spanStyles = highlightStyles) -} - -val languageAliases = mapOf( - "js" to SyntaxLanguage.JAVASCRIPT, - "ts" to SyntaxLanguage.TYPESCRIPT, - "rs" to SyntaxLanguage.RUST, - "cs" to SyntaxLanguage.CSHARP, - "py" to SyntaxLanguage.PYTHON, - "pl" to SyntaxLanguage.PERL, - "bash" to SyntaxLanguage.SHELL, - "sh" to SyntaxLanguage.SHELL, - "py" to SyntaxLanguage.PYTHON, - "coffee" to SyntaxLanguage.COFFEESCRIPT, - "bad" to SyntaxLanguage.PHP // had to -) - -val languageDisplayNamedResource = mapOf( - SyntaxLanguage.DEFAULT to R.string.programming_language_default, - SyntaxLanguage.C to R.string.programming_language_c, - SyntaxLanguage.CPP to R.string.programming_language_cpp, - SyntaxLanguage.DART to R.string.programming_language_dart, - SyntaxLanguage.JAVA to R.string.programming_language_java, - SyntaxLanguage.KOTLIN to R.string.programming_language_kotlin, - SyntaxLanguage.RUST to R.string.programming_language_rust, - SyntaxLanguage.CSHARP to R.string.programming_language_csharp, - SyntaxLanguage.COFFEESCRIPT to R.string.programming_language_coffeescript, - SyntaxLanguage.JAVASCRIPT to R.string.programming_language_javascript, - SyntaxLanguage.PERL to R.string.programming_language_perl, - SyntaxLanguage.PYTHON to R.string.programming_language_python, - SyntaxLanguage.RUBY to R.string.programming_language_ruby, - SyntaxLanguage.SHELL to R.string.programming_language_shell, - SyntaxLanguage.SWIFT to R.string.programming_language_swift, - SyntaxLanguage.TYPESCRIPT to R.string.programming_language_typescript, - SyntaxLanguage.GO to R.string.programming_language_go, - SyntaxLanguage.PHP to R.string.programming_language_php, -) - -@Composable -private fun JBMCodeBlockContent(node: ASTNode, modifier: Modifier) { - val state = LocalJBMarkdownTreeState.current - - val uiMode = LocalConfiguration.current.uiMode - val systemIsDark = - (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - val themeIsDark = - remember(LoadedSettings.theme) { isThemeDark(LoadedSettings.theme, systemIsDark) } - - val codeFenceLanguage = remember(node) { - node.children.firstOrNull { it.type == MarkdownTokenTypes.FENCE_LANG } - ?.getTextInNode(state.sourceText)?.toString() - } - val codeFenceContent = remember(node) { - node.children - .filter { it.type == MarkdownTokenTypes.CODE_FENCE_CONTENT || it.type == MarkdownTokenTypes.EOL } - .joinToString("") { - it.getTextInNode(state.sourceText).toString() - } - .trim() - } - val annotatedContent = remember(codeFenceLanguage, codeFenceContent) { - val canAnnotate = codeFenceLanguage != null - val language = codeFenceLanguage?.let { - languageAliases[it] ?: SyntaxLanguage.getByName(it) - } - val shouldAnnotate = language != null - - if (canAnnotate && shouldAnnotate) { - buildAnnotatedString { - val highlights = Highlights.Builder().apply { - code(codeFenceContent) - language(language ?: SyntaxLanguage.DEFAULT) - theme(SyntaxThemes.atom(themeIsDark)) - }.build() - append(annotateHighlights(codeFenceContent, highlights.getHighlights())) - } - } else { - buildAnnotatedString { - append(codeFenceContent) - } - } - } - - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(8.dp) - ) { - Text( - text = languageDisplayNamedResource[ - languageAliases[codeFenceLanguage] - ?: SyntaxLanguage.getByName(codeFenceLanguage ?: "") - ]?.let { - stringResource(it) - } ?: stringResource(R.string.programming_language_default), - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(vertical = 4.dp) - ) - Box( - modifier = Modifier.horizontalScroll(rememberScrollState()) - ) { - Text( - text = annotatedContent, - fontFamily = FragmentMono, - modifier = Modifier - ) - } - } -} - -@Composable -private fun JBMBlock(node: ASTNode, modifier: Modifier, nestingCounter: Int = 0) { - val state = LocalJBMarkdownTreeState.current - val colorScheme = MaterialTheme.colorScheme - - when (node.type) { - MarkdownElementTypes.PARAGRAPH, - MarkdownElementTypes.HTML_BLOCK, - MarkdownElementTypes.LINK_DEFINITION, - MarkdownTokenTypes.WHITE_SPACE -> { - // If the only child is a BLOCK_MATH we render it instead - if (node.children.size == 1 && node.children[0].type == GFMElementTypes.BLOCK_MATH) { - JBMBlock(node.children[0], modifier) - } else { - CompositionLocalProvider( - LocalTextStyle provides LocalTextStyle.current.copy( - fontSize = LocalTextStyle.current.fontSize * state.fontSizeMultiplier - ) - ) { - JBMText(node, modifier) - } - } - } - - MarkdownElementTypes.ATX_1, - MarkdownElementTypes.ATX_2, - MarkdownElementTypes.ATX_3, - MarkdownElementTypes.ATX_4, - MarkdownElementTypes.ATX_5, - MarkdownElementTypes.ATX_6 -> { - CompositionLocalProvider( - LocalTextStyle provides LocalTextStyle.current.copy( - fontWeight = FontWeight.Bold, - fontSize = if (state.embedded) LocalTextStyle.current.fontSize - else when (node.type) { - MarkdownElementTypes.ATX_1 -> 32.sp * state.fontSizeMultiplier - MarkdownElementTypes.ATX_2 -> 24.sp * state.fontSizeMultiplier - MarkdownElementTypes.ATX_3 -> 20.sp * state.fontSizeMultiplier - MarkdownElementTypes.ATX_4 -> 16.sp * state.fontSizeMultiplier - MarkdownElementTypes.ATX_5 -> 14.sp * state.fontSizeMultiplier - else -> 12.sp * state.fontSizeMultiplier - } - ) - ) { - if (node.startOffset != 0) { - Spacer(Modifier.height(8.dp)) - } - JBMText(node, modifier) - Spacer(Modifier.height(4.dp)) - } - } - - MarkdownElementTypes.ORDERED_LIST, - MarkdownElementTypes.UNORDERED_LIST -> { - CompositionLocalProvider( - LocalJBMarkdownTreeState provides state.copy( - listDepth = state.listDepth + 1 - ) - ) { - JBMText(node, modifier) - } - } - - MarkdownTokenTypes.EOL -> { - Spacer(Modifier.height(4.dp)) - } - - MarkdownElementTypes.CODE_FENCE -> { - if (LocalJBMarkdownTreeState.current.singleLine) { - Text( - text = buildAnnotatedString { - withStyle(SpanStyle(fontFamily = FragmentMono)) { - val codeFenceLanguage = - node.children.firstOrNull { it.type == MarkdownTokenTypes.FENCE_LANG } - ?.getTextInNode(state.sourceText)?.toString() - val languageName = languageDisplayNamedResource[ - languageAliases[codeFenceLanguage] - ?: SyntaxLanguage.getByName(codeFenceLanguage ?: "") - ]?.let { - stringResource(it) - } - - append(languageName?.let { - stringResource(R.string.programming_language_snippet, it) - } ?: stringResource(R.string.programming_language_snippet_default)) - } - }, - maxLines = if (state.singleLine) 1 else Int.MAX_VALUE, - overflow = if (state.singleLine) TextOverflow.Ellipsis else TextOverflow.Clip, - modifier = modifier - ) - } else { - JBMCodeBlockContent(node, modifier) - } - } - - MarkdownElementTypes.BLOCK_QUOTE -> { - if (LocalJBMarkdownTreeState.current.embedded) { - node.children.getOrNull(0)?.let { - JBMBlock(it, modifier) - } - } else { - Column( - Modifier - .clip(MaterialTheme.shapes.medium) - .fillMaxWidth() - .drawBehind { - drawRect(colorScheme.surfaceContainer.copy(alpha = 0.5f)) - drawLine( - colorScheme.primary, - Offset.Zero, - Offset(0f, size.height), - strokeWidth = 16f - ) - } - .padding(8.dp) - .padding(start = 4.dp) - ) { - if (nestingCounter < 5) { - node.children.map { - JBMBlock(it, modifier, nestingCounter = nestingCounter + 1) - } - } - } - } - } - - MarkdownTokenTypes.BLOCK_QUOTE -> { - if (LocalJBMarkdownTreeState.current.embedded) { - node.children.getOrNull(0)?.let { - JBMBlock(it, modifier) - } - } else { - node.children.map { - JBMBlock(it, modifier) - } - } - } - - GFMElementTypes.BLOCK_MATH -> { - // No use using Katex in embedded because we don't want to - // create WebViews when embedded - if (!LocalJBMarkdownTreeState.current.embedded) { - val mathContent = - try { - node.getTextInNode(state.sourceText).toString().removeSurrounding("$$") - } catch (e: Exception) { - "" - } - KatexRenderer(mathContent, modifier) - } - } - - GFMElementTypes.TABLE -> { - // Dito BLOCK_MATH - if (!LocalJBMarkdownTreeState.current.embedded) { - val tableContent = try { - node.getTextInNode(state.sourceText).toString() - } catch (e: Exception) { - "" - } - - FallbackRenderer(tableContent, modifier) - } - } - - MarkdownTokenTypes.HORIZONTAL_RULE -> { - HorizontalDivider( - color = colorScheme.onSurface, - thickness = 1.dp, - modifier = modifier.padding(vertical = 8.dp) - ) - } - - else -> { - Text( - text = buildAnnotatedString { - withStyle(SpanStyle(color = Color(0xFFFF7F50))) { - append("[Unknown block type ${node.type.name}]") - } - }, - maxLines = if (state.singleLine) 1 else Int.MAX_VALUE, - overflow = if (state.singleLine) TextOverflow.Ellipsis else TextOverflow.Clip, - modifier = modifier - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/markdown/jbm/KatexRenderer.kt b/app/src/main/java/chat/stoat/markdown/jbm/KatexRenderer.kt deleted file mode 100644 index 4c3ea689..00000000 --- a/app/src/main/java/chat/stoat/markdown/jbm/KatexRenderer.kt +++ /dev/null @@ -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" - ) - } - } - ) -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/markdown/jbm/MentionResolver.kt b/app/src/main/java/chat/stoat/markdown/jbm/MentionResolver.kt deleted file mode 100644 index f5de5bb6..00000000 --- a/app/src/main/java/chat/stoat/markdown/jbm/MentionResolver.kt +++ /dev/null @@ -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>" - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/markdown/jbm/RSMElementTypes.kt b/app/src/main/java/chat/stoat/markdown/jbm/RSMElementTypes.kt deleted file mode 100644 index e8c8be39..00000000 --- a/app/src/main/java/chat/stoat/markdown/jbm/RSMElementTypes.kt +++ /dev/null @@ -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") -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/markdown/jbm/RSMFlavourDescriptor.kt b/app/src/main/java/chat/stoat/markdown/jbm/RSMFlavourDescriptor.kt deleted file mode 100644 index e59f9ac2..00000000 --- a/app/src/main/java/chat/stoat/markdown/jbm/RSMFlavourDescriptor.kt +++ /dev/null @@ -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 { - return listOf( - UserMentionParser(), - ChannelMentionParser(), - RoleMentionParser(), - MassMentionParser(), - CustomEmoteParser(), - AutolinkParser(listOf(MarkdownTokenTypes.AUTOLINK, GFMTokenTypes.GFM_AUTOLINK)), - BacktickParser(), - MathParser(), - ImageParser(), - InlineLinkParser(), - ReferenceLinkParser(), - EmphasisLikeParser(EmphStrongDelimiterParser(), StrikeThroughDelimiterParser()) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/ChannelMentionParser.kt b/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/ChannelMentionParser.kt deleted file mode 100644 index 6475a6f4..00000000 --- a/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/ChannelMentionParser.kt +++ /dev/null @@ -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 - ): 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()) - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/CustomEmoteParser.kt b/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/CustomEmoteParser.kt deleted file mode 100644 index c89f6b08..00000000 --- a/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/CustomEmoteParser.kt +++ /dev/null @@ -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 - ): 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 - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/MassMentionParser.kt b/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/MassMentionParser.kt deleted file mode 100644 index afb7a485..00000000 --- a/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/MassMentionParser.kt +++ /dev/null @@ -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 - ): SequentialParser.ParsingResult { - // TODO - Implement - val result = SequentialParser.ParsingResultBuilder() - val delegateIndices = RangesListBuilder() - return result.withFurtherProcessing(delegateIndices.get()) - } -} diff --git a/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/RoleMentionParser.kt b/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/RoleMentionParser.kt deleted file mode 100644 index c79000b4..00000000 --- a/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/RoleMentionParser.kt +++ /dev/null @@ -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 - ): 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()) - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/UserMentionParser.kt b/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/UserMentionParser.kt deleted file mode 100644 index 3b1136b9..00000000 --- a/app/src/main/java/chat/stoat/markdown/jbm/sequentialparsers/UserMentionParser.kt +++ /dev/null @@ -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 - ): 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()) - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/screens/chat/views/FriendsScreen.kt b/app/src/main/java/chat/stoat/screens/chat/views/FriendsScreen.kt index 4d8f5ec5..76ce0cc1 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/FriendsScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/FriendsScreen.kt @@ -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, - "", - "", + "", + "", "", ) ), diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt index f5d4feb7..a4cae6d1 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt @@ -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( // 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() diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt index f2638446..200e35e4 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreenViewModel.kt @@ -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() + 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 diff --git a/app/src/main/java/chat/stoat/screens/labs/LabsHomeScreen.kt b/app/src/main/java/chat/stoat/screens/labs/LabsHomeScreen.kt index 26ab07e8..51c26e1a 100644 --- a/app/src/main/java/chat/stoat/screens/labs/LabsHomeScreen.kt +++ b/app/src/main/java/chat/stoat/screens/labs/LabsHomeScreen.kt @@ -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") diff --git a/app/src/main/java/chat/stoat/screens/labs/LabsRootScreen.kt b/app/src/main/java/chat/stoat/screens/labs/LabsRootScreen.kt index db0f21a9..9cbc7ffa 100644 --- a/app/src/main/java/chat/stoat/screens/labs/LabsRootScreen.kt +++ b/app/src/main/java/chat/stoat/screens/labs/LabsRootScreen.kt @@ -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) } diff --git a/app/src/main/java/chat/stoat/screens/labs/ui/sandbox/JBMSandbox.kt b/app/src/main/java/chat/stoat/screens/labs/ui/sandbox/JBMSandbox.kt deleted file mode 100644 index 00e12d61..00000000 --- a/app/src/main/java/chat/stoat/screens/labs/ui/sandbox/JBMSandbox.kt +++ /dev/null @@ -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(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.]() -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 | -|:-:|:-:|:-:|:-:| -| | <@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.") - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/screens/settings/ExperimentsSettingsScreen.kt b/app/src/main/java/chat/stoat/screens/settings/ExperimentsSettingsScreen.kt index 0a20ed52..c588c6cf 100644 --- a/app/src/main/java/chat/stoat/screens/settings/ExperimentsSettingsScreen.kt +++ b/app/src/main/java/chat/stoat/screens/settings/ExperimentsSettingsScreen.kt @@ -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( diff --git a/app/src/main/java/chat/stoat/screens/settings/SessionSettingsScreen.kt b/app/src/main/java/chat/stoat/screens/settings/SessionSettingsScreen.kt index f49f5e44..3f12aee6 100644 --- a/app/src/main/java/chat/stoat/screens/settings/SessionSettingsScreen.kt +++ b/app/src/main/java/chat/stoat/screens/settings/SessionSettingsScreen.kt @@ -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() diff --git a/app/src/main/java/chat/stoat/sheets/ChannelInfoSheet.kt b/app/src/main/java/chat/stoat/sheets/ChannelInfoSheet.kt index 60cda1e9..d2d6a0ad 100644 --- a/app/src/main/java/chat/stoat/sheets/ChannelInfoSheet.kt +++ b/app/src/main/java/chat/stoat/sheets/ChannelInfoSheet.kt @@ -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() diff --git a/app/src/main/java/chat/stoat/sheets/JBMDebuggerSheet.kt b/app/src/main/java/chat/stoat/sheets/JBMDebuggerSheet.kt deleted file mode 100644 index d84aee49..00000000 --- a/app/src/main/java/chat/stoat/sheets/JBMDebuggerSheet.kt +++ /dev/null @@ -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) { - "" - } - Text( - text.take(50).toString() + if (text.length > 50) "…" else "", - color = if (text == "") MaterialTheme.colorScheme.error else LocalContentColor.current, - modifier = Modifier.padding(4.dp) - ) - HorizontalDivider( - Modifier.fillMaxWidth() - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/sheets/MessageContextSheet.kt b/app/src/main/java/chat/stoat/sheets/MessageContextSheet.kt index 40669d58..e726cb6c 100644 --- a/app/src/main/java/chat/stoat/sheets/MessageContextSheet.kt +++ b/app/src/main/java/chat/stoat/sheets/MessageContextSheet.kt @@ -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( diff --git a/app/src/main/java/chat/stoat/sheets/ServerContextSheet.kt b/app/src/main/java/chat/stoat/sheets/ServerContextSheet.kt index 01679764..55d92af6 100644 --- a/app/src/main/java/chat/stoat/sheets/ServerContextSheet.kt +++ b/app/src/main/java/chat/stoat/sheets/ServerContextSheet.kt @@ -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, ) } diff --git a/app/src/main/java/chat/stoat/sheets/UserInfoSheet.kt b/app/src/main/java/chat/stoat/sheets/UserInfoSheet.kt index 9eed05b4..76e924de 100644 --- a/app/src/main/java/chat/stoat/sheets/UserInfoSheet.kt +++ b/app/src/main/java/chat/stoat/sheets/UserInfoSheet.kt @@ -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) } } } diff --git a/app/src/main/java/chat/stoat/ui/theme/Typography.kt b/app/src/main/java/chat/stoat/ui/theme/Typography.kt index 05fc2d64..14fdae77 100644 --- a/app/src/main/java/chat/stoat/ui/theme/Typography.kt +++ b/app/src/main/java/chat/stoat/ui/theme/Typography.kt @@ -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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dccf8d06..2faa37cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }