feat: make markdown good
This commit is contained in:
parent
5e555317e2
commit
39a025fcaa
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,31 +30,7 @@ sealed class UserCardsVariates {
|
|||
data class Restricted(val predicate: () -> Boolean) : UserCardsVariates()
|
||||
}
|
||||
|
||||
@FeatureFlag("MassMentions")
|
||||
sealed class MassMentionsVariates {
|
||||
@Treatment(
|
||||
"Enable mass mentions and role mentions for all users"
|
||||
)
|
||||
object Enabled : MassMentionsVariates()
|
||||
|
||||
@Treatment(
|
||||
"Disable mass mentions and role mentions for all users"
|
||||
)
|
||||
object Disabled : MassMentionsVariates()
|
||||
}
|
||||
|
||||
@FeatureFlag("FinalMarkdown")
|
||||
sealed class FinalMarkdownVariates {
|
||||
@Treatment(
|
||||
"Enable the new FinalMarkdown library for all users"
|
||||
)
|
||||
object Enabled : FinalMarkdownVariates()
|
||||
|
||||
@Treatment(
|
||||
"Disable the new FinalMarkdown library for all users"
|
||||
)
|
||||
object Disabled : FinalMarkdownVariates()
|
||||
}
|
||||
|
||||
object FeatureFlags {
|
||||
@FeatureFlag("LabsAccessControl")
|
||||
|
|
@ -82,23 +58,4 @@ object FeatureFlags {
|
|||
is UserCardsVariates.Restricted -> (userCards as UserCardsVariates.Restricted).predicate()
|
||||
}
|
||||
|
||||
@FeatureFlag("MassMentions")
|
||||
var massMentions by mutableStateOf<MassMentionsVariates>(
|
||||
MassMentionsVariates.Disabled
|
||||
)
|
||||
val massMentionsGranted: Boolean
|
||||
get() = when (massMentions) {
|
||||
is MassMentionsVariates.Enabled -> true
|
||||
is MassMentionsVariates.Disabled -> false
|
||||
}
|
||||
|
||||
@FeatureFlag("FinalMarkdown")
|
||||
var finalMarkdown by mutableStateOf<FinalMarkdownVariates>(
|
||||
FinalMarkdownVariates.Disabled
|
||||
)
|
||||
val finalMarkdownGranted: Boolean
|
||||
get() = when (finalMarkdown) {
|
||||
is FinalMarkdownVariates.Enabled -> true
|
||||
is FinalMarkdownVariates.Disabled -> false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
package chat.stoat.composables.markdown.prose
|
||||
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.core.graphics.toColorInt
|
||||
import androidx.core.net.toUri
|
||||
import chat.stoat.R
|
||||
import chat.stoat.activities.InviteActivity
|
||||
import chat.stoat.api.StoatAPI
|
||||
import chat.stoat.api.internals.isUlid
|
||||
import chat.stoat.api.routes.custom.fetchEmoji
|
||||
import chat.stoat.api.settings.LoadedSettings
|
||||
import chat.stoat.callbacks.Action
|
||||
import chat.stoat.callbacks.ActionChannel
|
||||
import chat.stoat.composables.generic.RemoteImage
|
||||
import chat.stoat.core.model.data.STOAT_FILES
|
||||
import chat.stoat.core.model.schemas.User
|
||||
import chat.stoat.core.model.schemas.isInviteUri
|
||||
import chat.stoat.internals.resolveTimestamp
|
||||
import chat.stoat.markdown.CHANNEL_MENTION_ELEMENT_TYPE
|
||||
import chat.stoat.markdown.CUSTOM_EMOTE_ELEMENT_TYPE
|
||||
import chat.stoat.markdown.MASS_MENTION_ELEMENT_TYPE
|
||||
import chat.stoat.markdown.ROLE_MENTION_ELEMENT_TYPE
|
||||
import chat.stoat.markdown.StoatMarkdownFlavour
|
||||
import chat.stoat.markdown.TIMESTAMP_ELEMENT_TYPE
|
||||
import chat.stoat.markdown.USER_MENTION_ELEMENT_TYPE
|
||||
import chat.stoat.ui.theme.FragmentMono
|
||||
import chat.stoat.ui.theme.isThemeDark
|
||||
import com.mikepenz.markdown.compose.components.markdownComponents
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.model.State
|
||||
import com.mikepenz.markdown.model.markdownAnnotator
|
||||
import com.mikepenz.markdown.model.markdownInlineContent
|
||||
import com.mikepenz.markdown.model.rememberMarkdownState
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
import kotlinx.coroutines.launch
|
||||
import org.intellij.markdown.ast.getTextInNode
|
||||
import org.intellij.markdown.parser.MarkdownParser
|
||||
|
||||
// Converts single \n to hard line breaks so chat messages render line by line, while leaving fenced
|
||||
// code blocks and double-newline paragraph breaks untouched, e.g. GitHub style line breaks
|
||||
internal fun easyLineBreaks(content: String): String {
|
||||
val sb = StringBuilder(content.length + 16)
|
||||
var i = 0
|
||||
var inFence = false
|
||||
while (i < content.length) {
|
||||
val ch = content[i]
|
||||
if (ch == '`' || ch == '~') {
|
||||
val lineStart = sb.lastIndexOf('\n') + 1
|
||||
val atLineStart = sb.length == lineStart || sb.substring(lineStart).isBlank()
|
||||
if (atLineStart) {
|
||||
val fence = ch
|
||||
var run = 0
|
||||
while (i + run < content.length && content[i + run] == fence) run++
|
||||
if (run >= 3) {
|
||||
inFence = !inFence
|
||||
repeat(run) { sb.append(content[i + it]) }
|
||||
i += run
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ch == '\n' && !inFence) {
|
||||
val prev = if (i > 0) content[i - 1] else ' '
|
||||
val next = if (i + 1 < content.length) content[i + 1] else ' '
|
||||
if (prev != '\n' && next != '\n') {
|
||||
sb.append(" ")
|
||||
}
|
||||
}
|
||||
sb.append(ch)
|
||||
i++
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMarkdown(
|
||||
content: String,
|
||||
serverId: String? = null,
|
||||
fontSizeMultiplier: Float = 1f,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val processed = remember(content) { easyLineBreaks(content) }
|
||||
val flavour = remember(processed) { StoatMarkdownFlavour(processed) }
|
||||
val markdownState = rememberMarkdownState(
|
||||
content = processed,
|
||||
flavour = flavour,
|
||||
parser = remember(flavour) { MarkdownParser(flavour) },
|
||||
immediate = true,
|
||||
)
|
||||
val state by markdownState.state.collectAsState()
|
||||
ChatMarkdown(
|
||||
state = state,
|
||||
serverId = serverId,
|
||||
fontSizeMultiplier = fontSizeMultiplier,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMarkdown(
|
||||
state: State,
|
||||
serverId: String? = null,
|
||||
fontSizeMultiplier: Float = 1f,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val fontSize = LocalTextStyle.current.fontSize * fontSizeMultiplier
|
||||
val scope = rememberCoroutineScope()
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
val surfaceVariantColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
val mentionLinkStyle = remember(primaryColor) {
|
||||
TextLinkStyles(
|
||||
SpanStyle(
|
||||
color = primaryColor,
|
||||
background = primaryColor.copy(alpha = 0.2f)
|
||||
)
|
||||
)
|
||||
}
|
||||
val annotator = remember(serverId, mentionLinkStyle, surfaceVariantColor) {
|
||||
markdownAnnotator { content, child ->
|
||||
when (child.type) {
|
||||
CUSTOM_EMOTE_ELEMENT_TYPE -> {
|
||||
val ulid = child.getTextInNode(content).toString().removeSurrounding(":")
|
||||
appendInlineContent("custom_emote", ulid)
|
||||
true
|
||||
}
|
||||
|
||||
USER_MENTION_ELEMENT_TYPE -> {
|
||||
val raw = child.getTextInNode(content).toString()
|
||||
val ulid = raw.substring(2, raw.length - 1)
|
||||
val member = serverId?.let { StoatAPI.members.getMember(it, ulid) }
|
||||
val displayName = member?.nickname
|
||||
?: StoatAPI.userCache[ulid]?.let { User.resolveDefaultName(it) }
|
||||
?: ulid
|
||||
withLink(LinkAnnotation.Url("stoat-user://$ulid", mentionLinkStyle)) {
|
||||
append("@$displayName")
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
CHANNEL_MENTION_ELEMENT_TYPE -> {
|
||||
val raw = child.getTextInNode(content).toString()
|
||||
val channelId = raw.substring(2, raw.length - 1)
|
||||
val channelName = StoatAPI.channelCache[channelId]?.name ?: channelId
|
||||
withLink(LinkAnnotation.Url("stoat-channel://$channelId", mentionLinkStyle)) {
|
||||
append("#$channelName")
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
ROLE_MENTION_ELEMENT_TYPE -> {
|
||||
val raw = child.getTextInNode(content).toString()
|
||||
val roleId = raw.substring(2, raw.length - 1)
|
||||
if (roleId.isUlid()) {
|
||||
val role = serverId?.let { StoatAPI.serverCache[it]?.roles?.get(roleId) }
|
||||
val roleColor = role?.colour
|
||||
?.takeIf { !it.contains("gradient") }
|
||||
?.let { runCatching { Color(it.toColorInt()) }.getOrNull() }
|
||||
?: primaryColor
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = roleColor,
|
||||
background = roleColor.copy(alpha = 0.2f)
|
||||
)
|
||||
) {
|
||||
append("@${role?.name ?: roleId}")
|
||||
}
|
||||
} else {
|
||||
append(raw)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
MASS_MENTION_ELEMENT_TYPE -> {
|
||||
val raw = child.getTextInNode(content).toString()
|
||||
val displayText = if (raw.contains("EVERYONE")) "@everyone" else "@online"
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = primaryColor,
|
||||
background = primaryColor.copy(alpha = 0.2f)
|
||||
)
|
||||
) {
|
||||
append(displayText)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
TIMESTAMP_ELEMENT_TYPE -> {
|
||||
val raw = child.getTextInNode(content).toString()
|
||||
val inner = raw.removePrefix("<t:").removeSuffix(">")
|
||||
val parts = inner.split(":", limit = 2)
|
||||
val epochSeconds = parts[0].toLongOrNull()
|
||||
if (epochSeconds == null) {
|
||||
append(raw)
|
||||
} else {
|
||||
val style = parts.getOrNull(1)
|
||||
withLink(
|
||||
LinkAnnotation.Url(
|
||||
"stoat-timestamp://$epochSeconds",
|
||||
TextLinkStyles(SpanStyle(background = surfaceVariantColor))
|
||||
)
|
||||
) {
|
||||
append(resolveTimestamp(epochSeconds, style))
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
val toolbarColor = MaterialTheme.colorScheme.surfaceContainer.toArgb()
|
||||
val uriHandler = remember(scope, serverId, toolbarColor) {
|
||||
object : UriHandler {
|
||||
override fun openUri(uri: String) {
|
||||
when {
|
||||
uri.startsWith("stoat-user://") -> {
|
||||
val ulid = uri.removePrefix("stoat-user://")
|
||||
scope.launch { ActionChannel.send(Action.OpenUserSheet(ulid, serverId)) }
|
||||
}
|
||||
|
||||
uri.startsWith("stoat-channel://") -> {
|
||||
val channelId = uri.removePrefix("stoat-channel://")
|
||||
scope.launch { ActionChannel.send(Action.SwitchChannel(channelId)) }
|
||||
}
|
||||
|
||||
uri.startsWith("stoat-timestamp://") -> {
|
||||
val epochSeconds = uri.removePrefix("stoat-timestamp://").toLongOrNull()
|
||||
if (epochSeconds != null) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
resolveTimestamp(epochSeconds, "F"),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
try {
|
||||
val parsed = uri.toUri()
|
||||
if (parsed.isInviteUri()) {
|
||||
context.startActivity(
|
||||
Intent(context, InviteActivity::class.java)
|
||||
.apply { data = parsed }
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// no-op
|
||||
}
|
||||
val customTab = CustomTabsIntent.Builder()
|
||||
.setShowTitle(true)
|
||||
.setDefaultColorSchemeParams(
|
||||
CustomTabColorSchemeParams.Builder()
|
||||
.setToolbarColor(toolbarColor)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
try {
|
||||
customTab.launchUrl(context, uri.toUri())
|
||||
} catch (_: Exception) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
resources.getString(R.string.link_type_no_intent),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val systemIsDark = isSystemInDarkTheme()
|
||||
val isDarkTheme =
|
||||
remember(LoadedSettings.theme) { isThemeDark(LoadedSettings.theme, systemIsDark) }
|
||||
val highlightsBuilder = remember(isDarkTheme) {
|
||||
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
|
||||
}
|
||||
CompositionLocalProvider(LocalUriHandler provides uriHandler) {
|
||||
Markdown(
|
||||
state = state,
|
||||
annotator = annotator,
|
||||
typography = markdownTypography(
|
||||
h1 = MaterialTheme.typography.headlineLarge,
|
||||
h2 = MaterialTheme.typography.headlineMedium,
|
||||
h3 = MaterialTheme.typography.headlineSmall,
|
||||
h4 = MaterialTheme.typography.titleLarge,
|
||||
h5 = MaterialTheme.typography.titleMedium,
|
||||
h6 = MaterialTheme.typography.titleSmall,
|
||||
text = MaterialTheme.typography.bodyLarge.copy(fontSize = MaterialTheme.typography.bodyLarge.fontSize * fontSizeMultiplier),
|
||||
paragraph = MaterialTheme.typography.bodyLarge.copy(fontSize = MaterialTheme.typography.bodyLarge.fontSize * fontSizeMultiplier),
|
||||
code = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontFamily = FragmentMono,
|
||||
fontSize = MaterialTheme.typography.bodyLarge.fontSize * fontSizeMultiplier
|
||||
),
|
||||
inlineCode = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontFamily = FragmentMono,
|
||||
fontSize = MaterialTheme.typography.bodyLarge.fontSize * fontSizeMultiplier
|
||||
),
|
||||
textLink = TextLinkStyles(
|
||||
SpanStyle(
|
||||
color = primaryColor,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
),
|
||||
),
|
||||
inlineContent = markdownInlineContent(
|
||||
mapOf(
|
||||
"custom_emote" to InlineTextContent(
|
||||
Placeholder(
|
||||
width = fontSize * 1.5f,
|
||||
height = fontSize * 1.5f,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
)
|
||||
) { ulid ->
|
||||
val emote = StoatAPI.emojiCache[ulid]
|
||||
if (emote == null) {
|
||||
LaunchedEffect(ulid) {
|
||||
try {
|
||||
StoatAPI.emojiCache[ulid] = fetchEmoji(ulid)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
with(LocalDensity.current) {
|
||||
RemoteImage(
|
||||
url = "$STOAT_FILES/emojis/$ulid",
|
||||
description = emote.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.width((fontSize * 1.5f).toDp())
|
||||
.height((fontSize * 1.5f).toDp())
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) {
|
||||
scope.launch { ActionChannel.send(Action.EmoteInfo(ulid)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
components = markdownComponents(
|
||||
image = {},
|
||||
inlineImage = {},
|
||||
codeBlock = {
|
||||
MarkdownHighlightedCodeBlock(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
)
|
||||
},
|
||||
codeFence = {
|
||||
MarkdownHighlightedCodeFence(
|
||||
content = it.content,
|
||||
node = it.node,
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
)
|
||||
},
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -14,11 +14,6 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
|
@ -31,10 +26,8 @@ import chat.stoat.core.model.schemas.ChannelType
|
|||
import chat.stoat.core.model.schemas.User
|
||||
import chat.stoat.composables.generic.RemoteImage
|
||||
import chat.stoat.composables.generic.UserAvatar
|
||||
import chat.stoat.composables.markdown.MarkdownTree
|
||||
import chat.stoat.composables.markdown.prose.ChatMarkdown
|
||||
import chat.stoat.core.model.data.STOAT_FILES
|
||||
import chat.stoat.ndk.AstNode
|
||||
import chat.stoat.ndk.Stendal
|
||||
|
||||
@Composable
|
||||
fun ChannelSheetHeader(
|
||||
|
|
@ -42,16 +35,9 @@ fun ChannelSheetHeader(
|
|||
channelIcon: AutumnResource? = null,
|
||||
channelType: ChannelType,
|
||||
channelDescription: String? = null,
|
||||
serverId: String? = null,
|
||||
dmPartner: User? = null
|
||||
) {
|
||||
var renderedChannelDescription by remember { mutableStateOf<AstNode?>(null) }
|
||||
|
||||
LaunchedEffect(channelDescription) {
|
||||
if (channelDescription != null) {
|
||||
renderedChannelDescription = Stendal.render(channelDescription)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
|
@ -102,9 +88,9 @@ fun ChannelSheetHeader(
|
|||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (renderedChannelDescription != null && channelDescription?.isNotBlank() == true) {
|
||||
if (!channelDescription.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
MarkdownTree(node = renderedChannelDescription!!)
|
||||
ChatMarkdown(channelDescription, serverId = serverId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package chat.stoat.markdown
|
||||
|
||||
import org.intellij.markdown.IElementType
|
||||
|
||||
val CUSTOM_EMOTE_ELEMENT_TYPE: IElementType = IElementType("CUSTOM_EMOTE")
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package chat.stoat.markdown
|
||||
|
||||
import chat.stoat.api.internals.isUlid
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.TokensCache
|
||||
|
||||
class CustomEmoteSequentialParser(private val content: String) : SequentialParser {
|
||||
|
||||
override fun parse(
|
||||
tokens: TokensCache,
|
||||
rangesToGlue: List<IntRange>,
|
||||
): SequentialParser.ParsingResult {
|
||||
val result = SequentialParser.ParsingResultBuilder()
|
||||
val delegateIndices = RangesListBuilder()
|
||||
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
|
||||
|
||||
while (iterator.type != null) {
|
||||
if (iterator.type == MarkdownTokenTypes.COLON) {
|
||||
val openEnd = iterator.end
|
||||
|
||||
var lookahead = iterator.advance()
|
||||
var hops = 0
|
||||
while (lookahead.type != null &&
|
||||
lookahead.type != MarkdownTokenTypes.COLON &&
|
||||
lookahead.type != MarkdownTokenTypes.EOL &&
|
||||
hops < 30
|
||||
) {
|
||||
lookahead = lookahead.advance()
|
||||
hops++
|
||||
}
|
||||
|
||||
if (lookahead.type == MarkdownTokenTypes.COLON) {
|
||||
val innerText = content.substring(openEnd, lookahead.start)
|
||||
if (innerText.isUlid()) {
|
||||
result.withNode(
|
||||
SequentialParser.Node(
|
||||
iterator.index..lookahead.index + 1,
|
||||
CUSTOM_EMOTE_ELEMENT_TYPE
|
||||
)
|
||||
)
|
||||
iterator = lookahead.advance()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
delegateIndices.put(iterator.index)
|
||||
iterator = iterator.advance()
|
||||
}
|
||||
|
||||
return result.withFurtherProcessing(delegateIndices.get())
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package chat.stoat.markdown
|
||||
|
||||
import chat.stoat.api.internals.isUlid
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.TokensCache
|
||||
|
||||
class MentionSequentialParser(private val content: String) : SequentialParser {
|
||||
override fun parse(tokens: TokensCache, rangesToGlue: List<IntRange>): SequentialParser.ParsingResult {
|
||||
val result = SequentialParser.ParsingResultBuilder()
|
||||
val delegateIndices = RangesListBuilder()
|
||||
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
|
||||
|
||||
while (iterator.type != null) {
|
||||
if (iterator.type == MarkdownTokenTypes.LT) {
|
||||
val ltEnd = iterator.end
|
||||
|
||||
var lookahead = iterator.advance()
|
||||
var hops = 0
|
||||
while (lookahead.type != null &&
|
||||
lookahead.type != MarkdownTokenTypes.GT &&
|
||||
lookahead.type != MarkdownTokenTypes.EOL &&
|
||||
hops < 5
|
||||
) {
|
||||
lookahead = lookahead.advance()
|
||||
hops++
|
||||
}
|
||||
|
||||
if (lookahead.type == MarkdownTokenTypes.GT) {
|
||||
val innerText = content.substring(ltEnd, lookahead.start)
|
||||
|
||||
when {
|
||||
innerText.length == 27 && innerText[0] == '@' && innerText.substring(1).isUlid() -> {
|
||||
result.withNode(SequentialParser.Node(iterator.index..lookahead.index + 1, USER_MENTION_ELEMENT_TYPE))
|
||||
iterator = lookahead.advance()
|
||||
continue
|
||||
}
|
||||
innerText.length == 27 && innerText[0] == '#' && innerText.substring(1).isUlid() -> {
|
||||
result.withNode(SequentialParser.Node(iterator.index..lookahead.index + 1, CHANNEL_MENTION_ELEMENT_TYPE))
|
||||
iterator = lookahead.advance()
|
||||
continue
|
||||
}
|
||||
innerText.length == 27 && innerText[0] == '%' && innerText.substring(1).isUlid() -> {
|
||||
result.withNode(SequentialParser.Node(iterator.index..lookahead.index + 1, ROLE_MENTION_ELEMENT_TYPE))
|
||||
iterator = lookahead.advance()
|
||||
continue
|
||||
}
|
||||
innerText == "@EVERYONE" || innerText == "@ONLINE" -> {
|
||||
result.withNode(SequentialParser.Node(iterator.index..lookahead.index + 1, MASS_MENTION_ELEMENT_TYPE))
|
||||
iterator = lookahead.advance()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
delegateIndices.put(iterator.index)
|
||||
iterator = iterator.advance()
|
||||
}
|
||||
|
||||
return result.withFurtherProcessing(delegateIndices.get())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package chat.stoat.markdown
|
||||
|
||||
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParserManager
|
||||
|
||||
class StoatMarkdownFlavour(private val content: String) : GFMFlavourDescriptor() {
|
||||
override val sequentialParserManager = object : SequentialParserManager() {
|
||||
override fun getParserSequence(): List<SequentialParser> {
|
||||
return listOf(
|
||||
MentionSequentialParser(content),
|
||||
TimestampSequentialParser(content),
|
||||
CustomEmoteSequentialParser(content),
|
||||
) + super@StoatMarkdownFlavour.sequentialParserManager.getParserSequence()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package chat.stoat.markdown
|
||||
|
||||
import org.intellij.markdown.IElementType
|
||||
|
||||
val TIMESTAMP_ELEMENT_TYPE: IElementType = IElementType("TIMESTAMP")
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package chat.stoat.markdown
|
||||
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.TokensCache
|
||||
|
||||
class TimestampSequentialParser(private val content: String) : SequentialParser {
|
||||
override fun parse(tokens: TokensCache, rangesToGlue: List<IntRange>): SequentialParser.ParsingResult {
|
||||
val result = SequentialParser.ParsingResultBuilder()
|
||||
val delegateIndices = RangesListBuilder()
|
||||
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
|
||||
|
||||
while (iterator.type != null) {
|
||||
if (iterator.type == MarkdownTokenTypes.LT) {
|
||||
val ltEnd = iterator.end
|
||||
|
||||
var lookahead = iterator.advance()
|
||||
var hops = 0
|
||||
while (lookahead.type != null &&
|
||||
lookahead.type != MarkdownTokenTypes.GT &&
|
||||
lookahead.type != MarkdownTokenTypes.EOL &&
|
||||
hops < 15
|
||||
) {
|
||||
lookahead = lookahead.advance()
|
||||
hops++
|
||||
}
|
||||
|
||||
if (lookahead.type == MarkdownTokenTypes.GT) {
|
||||
val innerText = content.substring(ltEnd, lookahead.start)
|
||||
if (isTimestamp(innerText)) {
|
||||
result.withNode(SequentialParser.Node(iterator.index..lookahead.index + 1, TIMESTAMP_ELEMENT_TYPE))
|
||||
iterator = lookahead.advance()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
delegateIndices.put(iterator.index)
|
||||
iterator = iterator.advance()
|
||||
}
|
||||
|
||||
return result.withFurtherProcessing(delegateIndices.get())
|
||||
}
|
||||
|
||||
private fun isTimestamp(inner: String): Boolean {
|
||||
if (!inner.startsWith("t:")) return false
|
||||
val rest = inner.removePrefix("t:")
|
||||
val colonIdx = rest.indexOf(':')
|
||||
return if (colonIdx == -1) {
|
||||
rest.all { it.isDigit() } && rest.isNotEmpty()
|
||||
} else {
|
||||
val digits = rest.substring(0, colonIdx)
|
||||
val style = rest.substring(colonIdx + 1)
|
||||
digits.all { it.isDigit() } && digits.isNotEmpty() && style.length == 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
package chat.stoat.markdown.jbm
|
||||
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
import org.intellij.markdown.parser.MarkdownParser
|
||||
|
||||
@RequiresOptIn(message = "This API is experimental and has many TODOs.")
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
|
||||
annotation class JBM
|
||||
|
||||
@JBM
|
||||
object JBMApi {
|
||||
fun parse(src: String): ASTNode {
|
||||
return MarkdownParser(RSMFlavourDescriptor()).buildMarkdownTreeFromString(src)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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>"
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
package chat.stoat.markdown.jbm
|
||||
|
||||
import chat.stoat.markdown.jbm.sequentialparsers.ChannelMentionParser
|
||||
import chat.stoat.markdown.jbm.sequentialparsers.CustomEmoteParser
|
||||
import chat.stoat.markdown.jbm.sequentialparsers.MassMentionParser
|
||||
import chat.stoat.markdown.jbm.sequentialparsers.RoleMentionParser
|
||||
import chat.stoat.markdown.jbm.sequentialparsers.UserMentionParser
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
|
||||
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
||||
import org.intellij.markdown.flavours.gfm.StrikeThroughDelimiterParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.EmphasisLikeParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParserManager
|
||||
import org.intellij.markdown.parser.sequentialparsers.impl.AutolinkParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.impl.BacktickParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.impl.EmphStrongDelimiterParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.impl.ImageParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.impl.InlineLinkParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.impl.MathParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.impl.ReferenceLinkParser
|
||||
|
||||
class RSMFlavourDescriptor : GFMFlavourDescriptor() {
|
||||
override val sequentialParserManager = object : SequentialParserManager() {
|
||||
override fun getParserSequence(): List<SequentialParser> {
|
||||
return listOf(
|
||||
UserMentionParser(),
|
||||
ChannelMentionParser(),
|
||||
RoleMentionParser(),
|
||||
MassMentionParser(),
|
||||
CustomEmoteParser(),
|
||||
AutolinkParser(listOf(MarkdownTokenTypes.AUTOLINK, GFMTokenTypes.GFM_AUTOLINK)),
|
||||
BacktickParser(),
|
||||
MathParser(),
|
||||
ImageParser(),
|
||||
InlineLinkParser(),
|
||||
ReferenceLinkParser(),
|
||||
EmphasisLikeParser(EmphStrongDelimiterParser(), StrikeThroughDelimiterParser())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package chat.stoat.markdown.jbm.sequentialparsers
|
||||
|
||||
import chat.stoat.markdown.jbm.RSMElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.TokensCache
|
||||
|
||||
class ChannelMentionParser : SequentialParser {
|
||||
override fun parse(
|
||||
tokens: TokensCache,
|
||||
rangesToGlue: List<IntRange>
|
||||
): SequentialParser.ParsingResult {
|
||||
val result = SequentialParser.ParsingResultBuilder()
|
||||
val delegateIndices = RangesListBuilder()
|
||||
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
|
||||
|
||||
while (iterator.type != null) {
|
||||
if (iterator.type == MarkdownTokenTypes.LT && iterator.charLookup(1) == '#') {
|
||||
val start = iterator.index
|
||||
while (iterator.type != MarkdownTokenTypes.GT && iterator.type != null) {
|
||||
iterator = iterator.advance()
|
||||
}
|
||||
if (iterator.type == MarkdownTokenTypes.GT) {
|
||||
result.withNode(
|
||||
SequentialParser.Node(
|
||||
start..iterator.index + 1,
|
||||
RSMElementTypes.CHANNEL_MENTION
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
delegateIndices.put(iterator.index)
|
||||
}
|
||||
iterator = iterator.advance()
|
||||
}
|
||||
|
||||
return result.withFurtherProcessing(delegateIndices.get())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
package chat.stoat.markdown.jbm.sequentialparsers
|
||||
|
||||
import chat.stoat.markdown.jbm.RSMElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.TokensCache
|
||||
|
||||
class CustomEmoteParser : SequentialParser {
|
||||
override fun parse(
|
||||
tokens: TokensCache,
|
||||
rangesToGlue: List<IntRange>
|
||||
): SequentialParser.ParsingResult {
|
||||
val result = SequentialParser.ParsingResultBuilder()
|
||||
val delegateIndices = RangesListBuilder()
|
||||
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
|
||||
|
||||
while (iterator.type != null) {
|
||||
if (iterator.type == MarkdownTokenTypes.COLON) {
|
||||
|
||||
val endIterator = findNextColon(iterator.advance())
|
||||
|
||||
if (endIterator != null) {
|
||||
result.withNode(
|
||||
SequentialParser.Node(
|
||||
iterator.index..endIterator.index + 1,
|
||||
RSMElementTypes.CUSTOM_EMOTE
|
||||
)
|
||||
)
|
||||
iterator = endIterator.advance()
|
||||
continue
|
||||
}
|
||||
}
|
||||
delegateIndices.put(iterator.index)
|
||||
iterator = iterator.advance()
|
||||
}
|
||||
|
||||
return result.withFurtherProcessing(delegateIndices.get())
|
||||
}
|
||||
|
||||
private fun findNextColon(it: TokensCache.Iterator): TokensCache.Iterator? {
|
||||
var iterator = it
|
||||
while (iterator.type != null) {
|
||||
if (iterator.type == MarkdownTokenTypes.COLON) {
|
||||
return iterator
|
||||
}
|
||||
|
||||
iterator = iterator.advance()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
package chat.stoat.markdown.jbm.sequentialparsers
|
||||
|
||||
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.TokensCache
|
||||
|
||||
class MassMentionParser : SequentialParser {
|
||||
override fun parse(
|
||||
tokens: TokensCache,
|
||||
rangesToGlue: List<IntRange>
|
||||
): SequentialParser.ParsingResult {
|
||||
// TODO - Implement
|
||||
val result = SequentialParser.ParsingResultBuilder()
|
||||
val delegateIndices = RangesListBuilder()
|
||||
return result.withFurtherProcessing(delegateIndices.get())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
package chat.stoat.markdown.jbm.sequentialparsers
|
||||
|
||||
import chat.stoat.markdown.jbm.RSMElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.TokensCache
|
||||
|
||||
class RoleMentionParser : SequentialParser {
|
||||
override fun parse(
|
||||
tokens: TokensCache,
|
||||
rangesToGlue: List<IntRange>
|
||||
): SequentialParser.ParsingResult {
|
||||
val result = SequentialParser.ParsingResultBuilder()
|
||||
val delegateIndices = RangesListBuilder()
|
||||
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
|
||||
|
||||
while (iterator.type != null) {
|
||||
if (iterator.type == MarkdownTokenTypes.LT && iterator.charLookup(1) == '%') {
|
||||
val start = iterator.index
|
||||
while (iterator.type != MarkdownTokenTypes.GT && iterator.type != null) {
|
||||
iterator = iterator.advance()
|
||||
}
|
||||
if (iterator.type == MarkdownTokenTypes.GT) {
|
||||
result.withNode(
|
||||
SequentialParser.Node(
|
||||
start..iterator.index + 1,
|
||||
RSMElementTypes.ROLE_MENTION
|
||||
)
|
||||
)
|
||||
}
|
||||
} else if (iterator.type == MarkdownTokenTypes.LT) {
|
||||
delegateIndices.put(iterator.index)
|
||||
} else {
|
||||
delegateIndices.put(iterator.index)
|
||||
}
|
||||
iterator = iterator.advance()
|
||||
}
|
||||
|
||||
return result.withFurtherProcessing(delegateIndices.get())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package chat.stoat.markdown.jbm.sequentialparsers
|
||||
|
||||
import chat.stoat.markdown.jbm.RSMElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.parser.sequentialparsers.RangesListBuilder
|
||||
import org.intellij.markdown.parser.sequentialparsers.SequentialParser
|
||||
import org.intellij.markdown.parser.sequentialparsers.TokensCache
|
||||
|
||||
class UserMentionParser : SequentialParser {
|
||||
override fun parse(
|
||||
tokens: TokensCache,
|
||||
rangesToGlue: List<IntRange>
|
||||
): SequentialParser.ParsingResult {
|
||||
val result = SequentialParser.ParsingResultBuilder()
|
||||
val delegateIndices = RangesListBuilder()
|
||||
var iterator: TokensCache.Iterator = tokens.RangesListIterator(rangesToGlue)
|
||||
|
||||
while (iterator.type != null) {
|
||||
if (iterator.type == MarkdownTokenTypes.LT && iterator.charLookup(1) == '@') {
|
||||
val start = iterator.index
|
||||
while (iterator.type != MarkdownTokenTypes.GT && iterator.type != null) {
|
||||
iterator = iterator.advance()
|
||||
}
|
||||
if (iterator.type == MarkdownTokenTypes.GT) {
|
||||
result.withNode(
|
||||
SequentialParser.Node(
|
||||
start..iterator.index + 1,
|
||||
RSMElementTypes.USER_MENTION
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
delegateIndices.put(iterator.index)
|
||||
}
|
||||
iterator = iterator.advance()
|
||||
}
|
||||
|
||||
return result.withFurtherProcessing(delegateIndices.get())
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +65,7 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -103,7 +104,6 @@ import chat.stoat.composables.vectorassets.Nametag
|
|||
import chat.stoat.core.model.schemas.AutumnResource
|
||||
import chat.stoat.core.model.schemas.Metadata
|
||||
import chat.stoat.internals.extensions.zero
|
||||
import chat.stoat.markdown.jbm.asHexString
|
||||
import chat.stoat.screens.chat.LocalIsConnected
|
||||
import io.github.g00fy2.quickie.QRResult
|
||||
import io.github.g00fy2.quickie.ScanQRCode
|
||||
|
|
@ -179,8 +179,8 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: ()
|
|||
text = AnnotatedString.fromHtml(
|
||||
stringResource(
|
||||
R.string.friends_add_by_tag_sheet_description,
|
||||
"<font color=\"${Color(HL_USERNAME).asHexString(false)}\">",
|
||||
"<font color=\"${Color(HL_TAG).asHexString(false)}\">",
|
||||
"<font color=\"${Color(HL_USERNAME).toArgb().let { "#%02x%02x%02x".format(it shr 16 and 0xff, it shr 8 and 0xff, it and 0xff) }}\">",
|
||||
"<font color=\"${Color(HL_TAG).toArgb().let { "#%02x%02x%02x".format(it shr 16 and 0xff, it shr 8 and 0xff, it and 0xff) }}\">",
|
||||
"</font>",
|
||||
)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
|||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
|
|
@ -122,7 +123,6 @@ import chat.stoat.composables.chat.DateDivider
|
|||
import chat.stoat.composables.chat.Message
|
||||
import chat.stoat.composables.chat.MessageField
|
||||
import chat.stoat.composables.chat.SystemMessage
|
||||
import chat.stoat.composables.chat.UnsupportedMessage
|
||||
import chat.stoat.composables.emoji.EmojiPicker
|
||||
import chat.stoat.composables.generic.GroupIcon
|
||||
import chat.stoat.composables.generic.PresenceBadge
|
||||
|
|
@ -146,6 +146,7 @@ import chat.stoat.screens.chat.LocalIsConnected
|
|||
import chat.stoat.sheets.ChannelInfoSheet
|
||||
import chat.stoat.sheets.MessageContextSheet
|
||||
import chat.stoat.sheets.ReactSheet
|
||||
import com.mikepenz.markdown.model.State
|
||||
import com.valentinilk.shimmer.ShimmerBounds
|
||||
import com.valentinilk.shimmer.rememberShimmer
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
|
|
@ -157,14 +158,12 @@ import java.io.File
|
|||
import kotlin.math.max
|
||||
|
||||
sealed class ChannelScreenItem {
|
||||
data class RegularMessage(val message: Message) : ChannelScreenItem()
|
||||
data class ProspectiveMessage(val message: Message) : ChannelScreenItem()
|
||||
data class FailedMessage(val message: Message) : ChannelScreenItem()
|
||||
data class RegularMessage(val message: Message, val mdAst: State?) : ChannelScreenItem()
|
||||
data class ProspectiveMessage(val message: Message, val mdAst: State?) : ChannelScreenItem()
|
||||
data class FailedMessage(val message: Message, val mdAst: State?) : ChannelScreenItem()
|
||||
data class SystemMessage(val message: Message) : ChannelScreenItem()
|
||||
data class DateDivider(val instant: Instant) : ChannelScreenItem()
|
||||
data class LoadTrigger(val after: String?, val before: String?) :
|
||||
ChannelScreenItem()
|
||||
|
||||
data class LoadTrigger(val after: String?, val before: String?) : ChannelScreenItem()
|
||||
data object Loading : ChannelScreenItem()
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +202,7 @@ fun ChannelScreen(
|
|||
// <editor-fold desc="State and effects">
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
val config = LocalConfiguration.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -351,7 +351,7 @@ fun ChannelScreen(
|
|||
} catch (e: Exception) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(
|
||||
resources.getString(
|
||||
R.string.file_picker_chip_camera_failed
|
||||
),
|
||||
Toast.LENGTH_SHORT
|
||||
|
|
@ -367,7 +367,7 @@ fun ChannelScreen(
|
|||
} catch (e: Exception) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(
|
||||
resources.getString(
|
||||
R.string.file_picker_chip_camera_none_installed
|
||||
),
|
||||
Toast.LENGTH_SHORT
|
||||
|
|
@ -727,14 +727,6 @@ fun ChannelScreen(
|
|||
}
|
||||
when (val item = viewModel.items[index]) {
|
||||
is ChannelScreenItem.RegularMessage -> {
|
||||
if (item.message.content?.replace("\\s".toRegex(), "")
|
||||
?.contains(">>>>>>>") == true
|
||||
) {
|
||||
// FIXME Dirty hack to prevent a crash caused by malicious messages.
|
||||
UnsupportedMessage()
|
||||
return@items
|
||||
}
|
||||
|
||||
RegularMessage(
|
||||
item.message,
|
||||
viewModel.channel,
|
||||
|
|
@ -757,7 +749,8 @@ fun ChannelScreen(
|
|||
},
|
||||
putTextAtCursorPosition = viewModel::putAtCursorPosition,
|
||||
replyToMessage = viewModel::addReplyTo,
|
||||
scope = scope
|
||||
scope = scope,
|
||||
mdAst = item.mdAst
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -772,7 +765,8 @@ fun ChannelScreen(
|
|||
onNameClick = {},
|
||||
canReply = false,
|
||||
onReply = {},
|
||||
onAddReaction = {}
|
||||
onAddReaction = {},
|
||||
mdAst = item.mdAst,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -787,7 +781,8 @@ fun ChannelScreen(
|
|||
onNameClick = {},
|
||||
canReply = false,
|
||||
onReply = {},
|
||||
onAddReaction = {}
|
||||
onAddReaction = {},
|
||||
mdAst = item.mdAst,
|
||||
)
|
||||
Row {
|
||||
UserAvatarWidthPlaceholder()
|
||||
|
|
|
|||
|
|
@ -49,14 +49,19 @@ import chat.stoat.callbacks.UiCallbacks
|
|||
import chat.stoat.core.model.schemas.Channel
|
||||
import chat.stoat.core.model.schemas.Message
|
||||
import chat.stoat.internals.text.MessageProcessor
|
||||
import chat.stoat.composables.markdown.prose.easyLineBreaks
|
||||
import chat.stoat.markdown.StoatMarkdownFlavour
|
||||
import chat.stoat.persistence.KVStorage
|
||||
import chat.stoat.screens.chat.ChatRouterDestination
|
||||
import chat.stoat.settings.providers.AgeGateUnlockedStorageProvider
|
||||
import com.mikepenz.markdown.model.State
|
||||
import com.mikepenz.markdown.model.parseMarkdownFlow
|
||||
import io.ktor.http.ContentType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -257,6 +262,69 @@ class ChannelScreenViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun parseAst(content: String?): State? =
|
||||
content?.takeIf { it.isNotBlank() }?.let { c ->
|
||||
val prepared = injectMassMentionMarkers(easyLineBreaks(c))
|
||||
parseMarkdownFlow(prepared, flavour = StoatMarkdownFlavour(prepared))
|
||||
.first { it !is State.Loading }
|
||||
}
|
||||
|
||||
// Replaces @everyone/@online with <@EVERYONE>/<@ONLINE> outside code blocks and inline code spans.
|
||||
private fun injectMassMentionMarkers(content: String): String {
|
||||
val sb = StringBuilder(content.length)
|
||||
var i = 0
|
||||
var inFence = false
|
||||
var inCodeSpan = false
|
||||
|
||||
while (i < content.length) {
|
||||
val ch = content[i]
|
||||
|
||||
if (!inCodeSpan && (ch == '`' || ch == '~')) {
|
||||
val lineStart = sb.lastIndexOf('\n') + 1
|
||||
val atLineStart = sb.length == lineStart || sb.substring(lineStart).isBlank()
|
||||
if (atLineStart) {
|
||||
var run = 0
|
||||
while (i + run < content.length && content[i + run] == ch) run++
|
||||
if (run >= 3) {
|
||||
inFence = !inFence
|
||||
sb.append(content, i, i + run)
|
||||
i += run
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!inFence && ch == '`') {
|
||||
inCodeSpan = !inCodeSpan
|
||||
sb.append(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (!inFence && !inCodeSpan && ch == '@') {
|
||||
when {
|
||||
content.startsWith("everyone", i + 1) &&
|
||||
(i + 9 >= content.length || !content[i + 9].isLetterOrDigit()) -> {
|
||||
sb.append("<@EVERYONE>")
|
||||
i += 9
|
||||
continue
|
||||
}
|
||||
content.startsWith("online", i + 1) &&
|
||||
(i + 7 >= content.length || !content[i + 7].isLetterOrDigit()) -> {
|
||||
sb.append("<@ONLINE>")
|
||||
i += 7
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.append(ch)
|
||||
i++
|
||||
}
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the draft content in the KV storage, the in-memory state of the message content,
|
||||
* and, if [setInitial] is true, updates the text field to say the new [content].
|
||||
|
|
@ -395,7 +463,10 @@ class ChannelScreenViewModel(
|
|||
} ?: false
|
||||
)
|
||||
|
||||
updateItems(listOf(ChannelScreenItem.ProspectiveMessage(prospectiveMessage)) + items)
|
||||
val ast = parseAst(content)
|
||||
updateItems(
|
||||
listOf(ChannelScreenItem.ProspectiveMessage(prospectiveMessage, ast)) + items
|
||||
)
|
||||
|
||||
kvStorage.remove("draftContent/${channel?.id}")
|
||||
putDraftContent("", true)
|
||||
|
|
@ -415,7 +486,13 @@ class ChannelScreenViewModel(
|
|||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChannelScreenViewModel", "Failed to send message", e)
|
||||
updateItems(listOf(ChannelScreenItem.FailedMessage(prospectiveMessage)) + items.filter { it !is ChannelScreenItem.ProspectiveMessage })
|
||||
updateItems(
|
||||
listOf(
|
||||
ChannelScreenItem.FailedMessage(
|
||||
prospectiveMessage,
|
||||
ast
|
||||
)
|
||||
) + items.filter { it !is ChannelScreenItem.ProspectiveMessage })
|
||||
} finally {
|
||||
isSending = false
|
||||
}
|
||||
|
|
@ -495,11 +572,17 @@ class ChannelScreenViewModel(
|
|||
} else {
|
||||
true
|
||||
}
|
||||
}.map {
|
||||
when {
|
||||
it.system != null -> ChannelScreenItem.SystemMessage(it)
|
||||
else -> ChannelScreenItem.RegularMessage(it)
|
||||
}.let { filtered ->
|
||||
val result = mutableListOf<ChannelScreenItem>()
|
||||
for (msg in filtered) {
|
||||
result.add(
|
||||
when {
|
||||
msg.system != null -> ChannelScreenItem.SystemMessage(msg)
|
||||
else -> ChannelScreenItem.RegularMessage(msg, parseAst(msg.content))
|
||||
}
|
||||
)
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// Place items according to whether above/below/around was specified.
|
||||
|
|
@ -553,7 +636,7 @@ class ChannelScreenViewModel(
|
|||
if (didInitialChannelFetch) { // this check is so that we don't end up with a message that arrives at the same time as the initial fetch in front of the loading indicator
|
||||
val newItem = when {
|
||||
it.system != null -> ChannelScreenItem.SystemMessage(it)
|
||||
else -> ChannelScreenItem.RegularMessage(it)
|
||||
else -> ChannelScreenItem.RegularMessage(it, parseAst(it.content))
|
||||
}
|
||||
updateItems(listOf(newItem) + items.filter { m ->
|
||||
if (m is ChannelScreenItem.ProspectiveMessage) {
|
||||
|
|
@ -601,9 +684,10 @@ class ChannelScreenViewModel(
|
|||
updateItems(
|
||||
items.map { m ->
|
||||
if (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) {
|
||||
ChannelScreenItem.RegularMessage(
|
||||
m.message.mergeWithPartial(messageFrame)
|
||||
)
|
||||
val merged = m.message.mergeWithPartial(messageFrame)
|
||||
val ast =
|
||||
if (messageFrame.content != null) parseAst(merged.content) else m.mdAst
|
||||
ChannelScreenItem.RegularMessage(merged, ast)
|
||||
} else {
|
||||
m
|
||||
}
|
||||
|
|
@ -624,7 +708,7 @@ class ChannelScreenViewModel(
|
|||
items.map { currentMsg ->
|
||||
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
|
||||
StoatAPI.messageCache[it.id]?.let { m ->
|
||||
ChannelScreenItem.RegularMessage(m)
|
||||
ChannelScreenItem.RegularMessage(m, currentMsg.mdAst)
|
||||
} ?: return@map currentMsg
|
||||
} else {
|
||||
currentMsg
|
||||
|
|
@ -648,7 +732,7 @@ class ChannelScreenViewModel(
|
|||
items.map { currentMsg ->
|
||||
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
|
||||
StoatAPI.messageCache[it.id]?.let { m ->
|
||||
ChannelScreenItem.RegularMessage(m)
|
||||
ChannelScreenItem.RegularMessage(m, currentMsg.mdAst)
|
||||
} ?: return@map currentMsg
|
||||
} else {
|
||||
currentMsg
|
||||
|
|
@ -672,7 +756,7 @@ class ChannelScreenViewModel(
|
|||
items.map { currentMsg ->
|
||||
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
|
||||
StoatAPI.messageCache[it.id]?.let { m ->
|
||||
ChannelScreenItem.RegularMessage(m)
|
||||
ChannelScreenItem.RegularMessage(m, currentMsg.mdAst)
|
||||
} ?: return@map currentMsg
|
||||
} else {
|
||||
currentMsg
|
||||
|
|
@ -870,15 +954,15 @@ class ChannelScreenViewModel(
|
|||
groupedItems.add(
|
||||
when (m) {
|
||||
is ChannelScreenItem.RegularMessage -> ChannelScreenItem.RegularMessage(
|
||||
m.message.copy(
|
||||
tail = tail
|
||||
)
|
||||
m.message.copy(tail = tail), m.mdAst
|
||||
)
|
||||
|
||||
is ChannelScreenItem.ProspectiveMessage -> ChannelScreenItem.ProspectiveMessage(
|
||||
m.message.copy(tail = tail), m.mdAst
|
||||
)
|
||||
|
||||
is ChannelScreenItem.SystemMessage -> ChannelScreenItem.SystemMessage(
|
||||
m.message.copy(
|
||||
tail = tail
|
||||
)
|
||||
m.message.copy(tail = tail)
|
||||
)
|
||||
|
||||
else -> m
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
package chat.stoat.screens.labs.ui.sandbox
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import chat.stoat.markdown.jbm.JBM
|
||||
import chat.stoat.markdown.jbm.JBMRenderer
|
||||
import chat.stoat.markdown.jbm.LocalJBMarkdownTreeState
|
||||
import chat.stoat.settings.dsl.SettingsPage
|
||||
|
||||
@OptIn(JBM::class)
|
||||
@Composable
|
||||
fun JBMSandbox(navController: NavController) {
|
||||
var mdSource by remember { mutableStateOf("") }
|
||||
var submitMdSource by remember { mutableStateOf<String?>(null) }
|
||||
var isEmbedded by remember { mutableStateOf(false) }
|
||||
|
||||
SettingsPage(
|
||||
navController = navController,
|
||||
title = {
|
||||
Text(
|
||||
text = "JB Markdown Sandbox",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
) {
|
||||
Subcategory(
|
||||
title = { Text("Options", maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = isEmbedded,
|
||||
onCheckedChange = { isEmbedded = it }
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Embedded", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Subcategory(
|
||||
title = { Text("Source", maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
) {
|
||||
TextField(
|
||||
value = mdSource,
|
||||
onValueChange = { mdSource = it },
|
||||
label = { Text("Markdown source") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
TextButton(onClick = {
|
||||
submitMdSource = mdSource
|
||||
}) {
|
||||
Text("Submit")
|
||||
}
|
||||
TextButton(onClick = {
|
||||
submitMdSource = """# Full range of MD now supported!
|
||||
1. Text with **bold**, *italics*, and ***both***!
|
||||
2. You ~~can't see me~~.
|
||||
3. [I'm a link to another website.](<https://revolt.chat>)
|
||||
4. I'm a spoiler with ||**bold text inside it**||
|
||||
- I'm a sub-item on this list...
|
||||
- Let's go even deeper...
|
||||
|
||||
`Inline code`
|
||||
|
||||
```js
|
||||
let x = "I'm a multi-line code block!";
|
||||
```
|
||||
|
||||
> > ${'$'}${'$'}E = mc^2${'$'}${'$'}
|
||||
>
|
||||
> — Albert Einstein
|
||||
|
||||
| Timestamp | Mention | Channel Link | Message Link |
|
||||
|:-:|:-:|:-:|:-:|
|
||||
| <t:1663846662:f> | <@01EX2NCWQ0CHS3QJF0FEQS1GR4> | <#01H73F4RAHTPBHKJ1XBQDXK3NQ> | https://revolt.chat/server/01F7ZSBSFHQ8TA81725KQCSDDP/channel/01F92C5ZXBQWQ8KY7J8KY917NM/01J25XZM9JXVVJDDKFPB7Q48HZ |"""
|
||||
}) {
|
||||
Text("Submit test document")
|
||||
}
|
||||
}
|
||||
Subcategory(
|
||||
title = { Text("Output", maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
|
||||
embedded = isEmbedded
|
||||
)
|
||||
) {
|
||||
submitMdSource?.let { JBMRenderer(it, Modifier) }
|
||||
?: Text("Submit some Markdown and see the output.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,103 +0,0 @@
|
|||
package chat.stoat.sheets
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.stoat.composables.generic.SheetHeaderPadding
|
||||
import chat.stoat.markdown.jbm.JBM
|
||||
import chat.stoat.markdown.jbm.JBMApi
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
import org.intellij.markdown.ast.getTextInNode
|
||||
|
||||
@OptIn(JBM::class)
|
||||
@Composable
|
||||
fun JBMDebuggerSheet(content: String, modifier: Modifier = Modifier) {
|
||||
val rendered = remember(content) { JBMApi.parse(content) }
|
||||
Column {
|
||||
SheetHeaderPadding {
|
||||
Text(
|
||||
text = "Inspect AST",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
Column(modifier.verticalScroll(rememberScrollState())) {
|
||||
JBMDebugTree(rendered, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun JBMDebugTree(node: ASTNode, source: String, modifier: Modifier = Modifier) {
|
||||
var showChildren by remember { mutableStateOf(false) }
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(
|
||||
modifier
|
||||
.padding(start = 16.dp)
|
||||
.clickable { showChildren = !showChildren }) {
|
||||
JBMDebugNode(node, source, showChildren)
|
||||
if (showChildren && node.children.isNotEmpty()) {
|
||||
Column(Modifier.drawBehind {
|
||||
drawLine(
|
||||
color = scheme.primary,
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(0f, size.height),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
}) {
|
||||
node.children.forEach { JBMDebugTree(it, source) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun JBMDebugNode(node: ASTNode, source: String, showingChildren: Boolean) {
|
||||
Column {
|
||||
FlowRow {
|
||||
if (node.children.isNotEmpty()) {
|
||||
Text(if (showingChildren) "▼ " else "▶ ")
|
||||
}
|
||||
Text(node.type.toString())
|
||||
Text(" - ")
|
||||
if (node.children.isNotEmpty()) {
|
||||
Text("${node.children.size} " + if (node.children.size == 1) "child" else "children")
|
||||
} else {
|
||||
Text("No children")
|
||||
}
|
||||
}
|
||||
|
||||
val text = try {
|
||||
node.getTextInNode(source)
|
||||
} catch (e: Exception) {
|
||||
"<exception>"
|
||||
}
|
||||
Text(
|
||||
text.take(50).toString() + if (text.length > 50) "…" else "",
|
||||
color = if (text == "<exception>") MaterialTheme.colorScheme.error else LocalContentColor.current,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
)
|
||||
HorizontalDivider(
|
||||
Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue