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.android)
|
||||||
implementation(libs.multiplatform.markdown.m3)
|
implementation(libs.multiplatform.markdown.m3)
|
||||||
implementation(libs.multiplatform.markdown.coil3)
|
implementation(libs.multiplatform.markdown.coil3)
|
||||||
|
implementation(libs.multiplatform.markdown.code)
|
||||||
|
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,8 @@ class ExperimentInstance(default: Boolean) {
|
||||||
* - All experiments can be disabled at once with a single toggle.
|
* - All experiments can be disabled at once with a single toggle.
|
||||||
*/
|
*/
|
||||||
object Experiments {
|
object Experiments {
|
||||||
val useKotlinBasedMarkdownRenderer = ExperimentInstance(false)
|
|
||||||
val usePolar = ExperimentInstance(false)
|
val usePolar = ExperimentInstance(false)
|
||||||
val enableServerIdentityOptions = ExperimentInstance(false)
|
val enableServerIdentityOptions = ExperimentInstance(false)
|
||||||
val useFinalMarkdownRenderer = ExperimentInstance(false)
|
|
||||||
val useVoiceChats2p0 = ExperimentInstance(false)
|
val useVoiceChats2p0 = ExperimentInstance(false)
|
||||||
|
|
||||||
suspend fun hydrateWithKv() {
|
suspend fun hydrateWithKv() {
|
||||||
|
|
@ -41,26 +39,14 @@ object Experiments {
|
||||||
LoadedSettings.experimentsEnabled = kvStorage.getBoolean("experimentsEnabled") == true
|
LoadedSettings.experimentsEnabled = kvStorage.getBoolean("experimentsEnabled") == true
|
||||||
}
|
}
|
||||||
|
|
||||||
useKotlinBasedMarkdownRenderer.setEnabled(
|
|
||||||
kvStorage.getBoolean("exp/useKotlinBasedMarkdownRenderer") == true
|
|
||||||
)
|
|
||||||
usePolar.setEnabled(
|
usePolar.setEnabled(
|
||||||
kvStorage.getBoolean("exp/usePolar") == true
|
kvStorage.getBoolean("exp/usePolar") == true
|
||||||
)
|
)
|
||||||
enableServerIdentityOptions.setEnabled(
|
enableServerIdentityOptions.setEnabled(
|
||||||
kvStorage.getBoolean("exp/enableServerIdentityOptions") == true
|
kvStorage.getBoolean("exp/enableServerIdentityOptions") == true
|
||||||
)
|
)
|
||||||
useFinalMarkdownRenderer.setEnabled(
|
|
||||||
kvStorage.getBoolean("exp/useFinalMarkdownRenderer") == true
|
|
||||||
)
|
|
||||||
useVoiceChats2p0.setEnabled(
|
useVoiceChats2p0.setEnabled(
|
||||||
kvStorage.getBoolean("exp/useVoiceChats2p0") == true
|
kvStorage.getBoolean("exp/useVoiceChats2p0") == true
|
||||||
)
|
)
|
||||||
|
|
||||||
if (useFinalMarkdownRenderer.isEnabled && useKotlinBasedMarkdownRenderer.isEnabled) {
|
|
||||||
// if jbm and fm are enabled, fm takes precedence. this should not be possible in practice
|
|
||||||
useKotlinBasedMarkdownRenderer.setEnabled(false)
|
|
||||||
kvStorage.set("exp/useKotlinBasedMarkdownRenderer", false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -30,31 +30,7 @@ sealed class UserCardsVariates {
|
||||||
data class Restricted(val predicate: () -> Boolean) : UserCardsVariates()
|
data class Restricted(val predicate: () -> Boolean) : UserCardsVariates()
|
||||||
}
|
}
|
||||||
|
|
||||||
@FeatureFlag("MassMentions")
|
|
||||||
sealed class MassMentionsVariates {
|
|
||||||
@Treatment(
|
|
||||||
"Enable mass mentions and role mentions for all users"
|
|
||||||
)
|
|
||||||
object Enabled : MassMentionsVariates()
|
|
||||||
|
|
||||||
@Treatment(
|
|
||||||
"Disable mass mentions and role mentions for all users"
|
|
||||||
)
|
|
||||||
object Disabled : MassMentionsVariates()
|
|
||||||
}
|
|
||||||
|
|
||||||
@FeatureFlag("FinalMarkdown")
|
|
||||||
sealed class FinalMarkdownVariates {
|
|
||||||
@Treatment(
|
|
||||||
"Enable the new FinalMarkdown library for all users"
|
|
||||||
)
|
|
||||||
object Enabled : FinalMarkdownVariates()
|
|
||||||
|
|
||||||
@Treatment(
|
|
||||||
"Disable the new FinalMarkdown library for all users"
|
|
||||||
)
|
|
||||||
object Disabled : FinalMarkdownVariates()
|
|
||||||
}
|
|
||||||
|
|
||||||
object FeatureFlags {
|
object FeatureFlags {
|
||||||
@FeatureFlag("LabsAccessControl")
|
@FeatureFlag("LabsAccessControl")
|
||||||
|
|
@ -82,23 +58,4 @@ object FeatureFlags {
|
||||||
is UserCardsVariates.Restricted -> (userCards as UserCardsVariates.Restricted).predicate()
|
is UserCardsVariates.Restricted -> (userCards as UserCardsVariates.Restricted).predicate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@FeatureFlag("MassMentions")
|
|
||||||
var massMentions by mutableStateOf<MassMentionsVariates>(
|
|
||||||
MassMentionsVariates.Disabled
|
|
||||||
)
|
|
||||||
val massMentionsGranted: Boolean
|
|
||||||
get() = when (massMentions) {
|
|
||||||
is MassMentionsVariates.Enabled -> true
|
|
||||||
is MassMentionsVariates.Disabled -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
@FeatureFlag("FinalMarkdown")
|
|
||||||
var finalMarkdown by mutableStateOf<FinalMarkdownVariates>(
|
|
||||||
FinalMarkdownVariates.Disabled
|
|
||||||
)
|
|
||||||
val finalMarkdownGranted: Boolean
|
|
||||||
get() = when (finalMarkdown) {
|
|
||||||
is FinalMarkdownVariates.Enabled -> true
|
|
||||||
is FinalMarkdownVariates.Disabled -> false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,14 @@ import chat.stoat.api.internals.solidColor
|
||||||
import chat.stoat.api.routes.microservices.january.asJanuaryProxyUrl
|
import chat.stoat.api.routes.microservices.january.asJanuaryProxyUrl
|
||||||
import chat.stoat.composables.chat.specialembeds.SpecialEmbedSwitch
|
import chat.stoat.composables.chat.specialembeds.SpecialEmbedSwitch
|
||||||
import chat.stoat.composables.generic.RemoteImage
|
import chat.stoat.composables.generic.RemoteImage
|
||||||
import chat.stoat.composables.markdown.RichMarkdown
|
import chat.stoat.composables.markdown.prose.ChatMarkdown
|
||||||
import chat.stoat.core.model.schemas.Embed
|
import chat.stoat.core.model.schemas.Embed
|
||||||
import chat.stoat.core.model.schemas.Embed as EmbedSchema
|
import chat.stoat.core.model.schemas.Embed as EmbedSchema
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RegularEmbed(
|
fun RegularEmbed(
|
||||||
embed: EmbedSchema,
|
embed: EmbedSchema,
|
||||||
|
serverId: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onLinkClick: (String) -> Unit = {}
|
onLinkClick: (String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
|
@ -98,8 +99,9 @@ fun RegularEmbed(
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
embed.description?.let {
|
embed.description?.let {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
input = it,
|
content = it,
|
||||||
|
serverId = serverId,
|
||||||
modifier = Modifier.padding(top = 8.dp)
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -144,10 +146,10 @@ fun RegularEmbed(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Embed(embed: Embed, modifier: Modifier = Modifier, onLinkClick: (String) -> Unit) {
|
fun Embed(embed: Embed, serverId: String? = null, modifier: Modifier = Modifier, onLinkClick: (String) -> Unit) {
|
||||||
Column {
|
Column {
|
||||||
when (embed.type) {
|
when (embed.type) {
|
||||||
else -> RegularEmbed(embed = embed, modifier = modifier, onLinkClick = onLinkClick)
|
else -> RegularEmbed(embed = embed, serverId = serverId, modifier = modifier, onLinkClick = onLinkClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,15 +30,10 @@ import chat.stoat.R
|
||||||
import chat.stoat.api.StoatAPI
|
import chat.stoat.api.StoatAPI
|
||||||
import chat.stoat.api.internals.solidColor
|
import chat.stoat.api.internals.solidColor
|
||||||
import chat.stoat.api.routes.channel.fetchSingleMessage
|
import chat.stoat.api.routes.channel.fetchSingleMessage
|
||||||
import chat.stoat.api.settings.Experiments
|
|
||||||
import chat.stoat.composables.generic.UserAvatar
|
import chat.stoat.composables.generic.UserAvatar
|
||||||
import chat.stoat.core.model.schemas.User
|
import chat.stoat.core.model.schemas.User
|
||||||
import chat.stoat.markdown.jbm.JBM
|
|
||||||
import chat.stoat.markdown.jbm.JBMRenderer
|
|
||||||
import chat.stoat.markdown.jbm.LocalJBMarkdownTreeState
|
|
||||||
import java.util.concurrent.CancellationException
|
import java.util.concurrent.CancellationException
|
||||||
|
|
||||||
@OptIn(JBM::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InReplyTo(
|
fun InReplyTo(
|
||||||
channelId: String,
|
channelId: String,
|
||||||
|
|
@ -131,28 +126,13 @@ fun InReplyTo(
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
if (Experiments.useKotlinBasedMarkdownRenderer.isEnabled) {
|
Text(
|
||||||
CompositionLocalProvider(
|
text = message.content!!,
|
||||||
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
|
fontSize = 12.sp,
|
||||||
embedded = true,
|
color = contentColor.copy(alpha = 0.7f),
|
||||||
singleLine = true,
|
maxLines = 1,
|
||||||
currentServer = serverId,
|
overflow = TextOverflow.Ellipsis
|
||||||
linksClickable = false
|
)
|
||||||
),
|
|
||||||
LocalContentColor provides contentColor.copy(alpha = 0.7f),
|
|
||||||
LocalTextStyle provides LocalTextStyle.current.copy(fontSize = 12.sp)
|
|
||||||
) {
|
|
||||||
JBMRenderer(message.content!!)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = message.content!!,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = contentColor.copy(alpha = 0.7f),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
|
|
@ -73,8 +72,6 @@ import chat.stoat.api.internals.solidColor
|
||||||
import chat.stoat.api.routes.channel.react
|
import chat.stoat.api.routes.channel.react
|
||||||
import chat.stoat.api.routes.channel.unreact
|
import chat.stoat.api.routes.channel.unreact
|
||||||
import chat.stoat.api.routes.microservices.january.asJanuaryProxyUrl
|
import chat.stoat.api.routes.microservices.january.asJanuaryProxyUrl
|
||||||
import chat.stoat.core.model.schemas.AutumnResource
|
|
||||||
import chat.stoat.core.model.schemas.User
|
|
||||||
import chat.stoat.api.settings.Experiments
|
import chat.stoat.api.settings.Experiments
|
||||||
import chat.stoat.api.settings.LoadedSettings
|
import chat.stoat.api.settings.LoadedSettings
|
||||||
import chat.stoat.api.settings.MessageReplyStyle
|
import chat.stoat.api.settings.MessageReplyStyle
|
||||||
|
|
@ -83,15 +80,15 @@ import chat.stoat.callbacks.ActionChannel
|
||||||
import chat.stoat.composables.generic.RemoteImage
|
import chat.stoat.composables.generic.RemoteImage
|
||||||
import chat.stoat.composables.generic.UserAvatar
|
import chat.stoat.composables.generic.UserAvatar
|
||||||
import chat.stoat.composables.generic.UserAvatarWidthPlaceholder
|
import chat.stoat.composables.generic.UserAvatarWidthPlaceholder
|
||||||
import chat.stoat.composables.markdown.LocalMarkdownTreeConfig
|
import chat.stoat.composables.markdown.prose.ChatMarkdown
|
||||||
import chat.stoat.composables.markdown.RichMarkdown
|
|
||||||
import chat.stoat.core.model.data.STOAT_FILES
|
import chat.stoat.core.model.data.STOAT_FILES
|
||||||
|
import chat.stoat.core.model.schemas.AutumnResource
|
||||||
|
import chat.stoat.core.model.schemas.User
|
||||||
import chat.stoat.internals.text.Gigamoji
|
import chat.stoat.internals.text.Gigamoji
|
||||||
|
import chat.stoat.internals.text.GigamojiState
|
||||||
import chat.stoat.internals.text.MessageProcessor
|
import chat.stoat.internals.text.MessageProcessor
|
||||||
import chat.stoat.markdown.jbm.JBM
|
|
||||||
import chat.stoat.markdown.jbm.JBMRenderer
|
|
||||||
import chat.stoat.markdown.jbm.LocalJBMarkdownTreeState
|
|
||||||
import chat.stoat.persistence.KVStorage
|
import chat.stoat.persistence.KVStorage
|
||||||
|
import com.mikepenz.markdown.model.State
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import chat.stoat.core.model.schemas.Message as MessageSchema
|
import chat.stoat.core.model.schemas.Message as MessageSchema
|
||||||
|
|
||||||
|
|
@ -198,7 +195,7 @@ fun formatLongAsTime(time: Long): String {
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnusedBoxWithConstraintsScope")
|
@SuppressLint("UnusedBoxWithConstraintsScope")
|
||||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class, JBM::class)
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun Message(
|
fun Message(
|
||||||
message: MessageSchema,
|
message: MessageSchema,
|
||||||
|
|
@ -210,7 +207,8 @@ fun Message(
|
||||||
onAddReaction: () -> Unit = {},
|
onAddReaction: () -> Unit = {},
|
||||||
fromWebhook: Boolean = false,
|
fromWebhook: Boolean = false,
|
||||||
webhookName: String? = null,
|
webhookName: String? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
mdAst: State? = null
|
||||||
) {
|
) {
|
||||||
val author = StoatAPI.userCache[message.author] ?: return CircularProgressIndicator()
|
val author = StoatAPI.userCache[message.author] ?: return CircularProgressIndicator()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -442,36 +440,25 @@ fun Message(
|
||||||
message.content?.let {
|
message.content?.let {
|
||||||
if (message.content!!.isBlank()) return@let // if only an attachment is sent
|
if (message.content!!.isBlank()) return@let // if only an attachment is sent
|
||||||
|
|
||||||
if (Experiments.useKotlinBasedMarkdownRenderer.isEnabled) {
|
val gigamoji = Gigamoji.useGigamojiForMessage(message.content!!)
|
||||||
CompositionLocalProvider(
|
val fontSizeMultiplier = when (gigamoji) {
|
||||||
LocalJBMarkdownTreeState provides LocalJBMarkdownTreeState.current.copy(
|
GigamojiState.Single -> 5f
|
||||||
currentServer = StoatAPI.channelCache[message.channel]?.server,
|
GigamojiState.Multiple -> 2f
|
||||||
fontSizeMultiplier = Gigamoji.useGigamojiForMessage(
|
GigamojiState.None -> 1f
|
||||||
message.content!!
|
}
|
||||||
)
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
.let {
|
if (mdAst != null) {
|
||||||
if (it) 2f else 1f
|
ChatMarkdown(
|
||||||
}
|
mdAst,
|
||||||
)
|
serverId = StoatAPI.channelCache[message.channel]?.server,
|
||||||
) {
|
fontSizeMultiplier = fontSizeMultiplier,
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
)
|
||||||
JBMRenderer(message.content!!)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
CompositionLocalProvider(
|
ChatMarkdown(
|
||||||
LocalMarkdownTreeConfig provides LocalMarkdownTreeConfig.current.copy(
|
content = message.content!!,
|
||||||
currentServer = StoatAPI.channelCache[message.channel]?.server,
|
serverId = StoatAPI.channelCache[message.channel]?.server,
|
||||||
fontSizeMultiplier = Gigamoji.useGigamojiForMessage(
|
fontSizeMultiplier = fontSizeMultiplier,
|
||||||
message.content!!
|
)
|
||||||
)
|
|
||||||
.let {
|
|
||||||
if (it) 2f else 1f
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
RichMarkdown(input = message.content!!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -531,7 +518,7 @@ fun Message(
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Embed(embed = embed, onLinkClick = {
|
Embed(embed = embed, serverId = StoatAPI.channelCache[message.channel]?.server, onLinkClick = {
|
||||||
viewUrlInBrowser(context, it)
|
viewUrlInBrowser(context, it)
|
||||||
})
|
})
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.stoat.R
|
import chat.stoat.R
|
||||||
import chat.stoat.composables.markdown.RichMarkdown
|
import chat.stoat.api.StoatAPI
|
||||||
|
import chat.stoat.composables.markdown.prose.ChatMarkdown
|
||||||
import chat.stoat.core.model.schemas.Message
|
import chat.stoat.core.model.schemas.Message
|
||||||
|
|
||||||
enum class SystemMessageType(val type: String) {
|
enum class SystemMessageType(val type: String) {
|
||||||
|
|
@ -54,6 +55,7 @@ fun String?.mention(): String {
|
||||||
@Composable
|
@Composable
|
||||||
fun SystemMessage(message: Message) {
|
fun SystemMessage(message: Message) {
|
||||||
if (message.system == null) return
|
if (message.system == null) return
|
||||||
|
val serverId = StoatAPI.channelCache[message.channel]?.server
|
||||||
|
|
||||||
val systemMessageType =
|
val systemMessageType =
|
||||||
SystemMessageType.entries.firstOrNull { it.type == message.system!!.type }
|
SystemMessageType.entries.firstOrNull { it.type == message.system!!.type }
|
||||||
|
|
@ -81,119 +83,131 @@ fun SystemMessage(message: Message) {
|
||||||
|
|
||||||
when (systemMessageType) {
|
when (systemMessageType) {
|
||||||
SystemMessageType.CHANNEL_OWNERSHIP_CHANGED -> {
|
SystemMessageType.CHANNEL_OWNERSHIP_CHANGED -> {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_ownership_changed,
|
R.string.system_message_ownership_changed,
|
||||||
message.system!!.from.mention(),
|
message.system!!.from.mention(),
|
||||||
message.system!!.to.mention()
|
message.system!!.to.mention()
|
||||||
)
|
),
|
||||||
|
serverId = serverId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.CHANNEL_ICON_CHANGED -> {
|
SystemMessageType.CHANNEL_ICON_CHANGED -> {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_channel_icon_changed,
|
R.string.system_message_channel_icon_changed,
|
||||||
message.system!!.by.mention()
|
message.system!!.by.mention()
|
||||||
)
|
),
|
||||||
|
serverId = serverId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.CHANNEL_DESCRIPTION_CHANGED -> {
|
SystemMessageType.CHANNEL_DESCRIPTION_CHANGED -> {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_channel_description_changed,
|
R.string.system_message_channel_description_changed,
|
||||||
message.system!!.by.mention()
|
message.system!!.by.mention()
|
||||||
)
|
),
|
||||||
|
serverId = serverId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.CHANNEL_RENAMED -> {
|
SystemMessageType.CHANNEL_RENAMED -> {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_channel_renamed,
|
R.string.system_message_channel_renamed,
|
||||||
message.system!!.by.mention(),
|
message.system!!.by.mention(),
|
||||||
"**${message.system!!.name ?: stringResource(R.string.unknown)}**"
|
"**${message.system!!.name ?: stringResource(R.string.unknown)}**"
|
||||||
)
|
),
|
||||||
|
serverId = serverId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.USER_REMOVE -> {
|
SystemMessageType.USER_REMOVE -> {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_user_removed,
|
R.string.system_message_user_removed,
|
||||||
message.system!!.by.mention(),
|
message.system!!.by.mention(),
|
||||||
message.system!!.id.mention()
|
message.system!!.id.mention()
|
||||||
)
|
),
|
||||||
|
serverId = serverId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.USER_ADDED -> {
|
SystemMessageType.USER_ADDED -> {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_user_added,
|
R.string.system_message_user_added,
|
||||||
message.system!!.by.mention(),
|
message.system!!.by.mention(),
|
||||||
message.system!!.id.mention()
|
message.system!!.id.mention()
|
||||||
)
|
),
|
||||||
|
serverId = serverId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.USER_BANNED -> {
|
SystemMessageType.USER_BANNED -> {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_user_banned,
|
R.string.system_message_user_banned,
|
||||||
message.system!!.id.mention()
|
message.system!!.id.mention()
|
||||||
)
|
),
|
||||||
|
serverId = serverId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.USER_KICKED -> {
|
SystemMessageType.USER_KICKED -> {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_user_kicked,
|
R.string.system_message_user_kicked,
|
||||||
message.system!!.id.mention()
|
message.system!!.id.mention()
|
||||||
)
|
),
|
||||||
|
serverId = serverId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.USER_LEFT -> {
|
SystemMessageType.USER_LEFT -> {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_user_left,
|
R.string.system_message_user_left,
|
||||||
message.system!!.id.mention()
|
message.system!!.id.mention()
|
||||||
)
|
),
|
||||||
|
serverId = serverId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.USER_JOINED -> {
|
SystemMessageType.USER_JOINED -> {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_user_joined,
|
R.string.system_message_user_joined,
|
||||||
message.system!!.id.mention()
|
message.system!!.id.mention()
|
||||||
)
|
),
|
||||||
|
serverId = serverId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.MESSAGE_PINNED -> {
|
SystemMessageType.MESSAGE_PINNED -> {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_message_pinned,
|
R.string.system_message_message_pinned,
|
||||||
message.system!!.by.mention()
|
message.system!!.by.mention()
|
||||||
)
|
),
|
||||||
|
serverId = serverId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.MESSAGE_UNPINNED -> {
|
SystemMessageType.MESSAGE_UNPINNED -> {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.system_message_message_unpinned,
|
R.string.system_message_message_unpinned,
|
||||||
message.system!!.by.mention()
|
message.system!!.by.mention()
|
||||||
)
|
),
|
||||||
|
serverId = serverId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemMessageType.TEXT -> {
|
SystemMessageType.TEXT -> {
|
||||||
message.system!!.content?.let { RichMarkdown(it) }
|
message.system!!.content?.let { ChatMarkdown(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -31,10 +26,8 @@ import chat.stoat.core.model.schemas.ChannelType
|
||||||
import chat.stoat.core.model.schemas.User
|
import chat.stoat.core.model.schemas.User
|
||||||
import chat.stoat.composables.generic.RemoteImage
|
import chat.stoat.composables.generic.RemoteImage
|
||||||
import chat.stoat.composables.generic.UserAvatar
|
import chat.stoat.composables.generic.UserAvatar
|
||||||
import chat.stoat.composables.markdown.MarkdownTree
|
import chat.stoat.composables.markdown.prose.ChatMarkdown
|
||||||
import chat.stoat.core.model.data.STOAT_FILES
|
import chat.stoat.core.model.data.STOAT_FILES
|
||||||
import chat.stoat.ndk.AstNode
|
|
||||||
import chat.stoat.ndk.Stendal
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChannelSheetHeader(
|
fun ChannelSheetHeader(
|
||||||
|
|
@ -42,16 +35,9 @@ fun ChannelSheetHeader(
|
||||||
channelIcon: AutumnResource? = null,
|
channelIcon: AutumnResource? = null,
|
||||||
channelType: ChannelType,
|
channelType: ChannelType,
|
||||||
channelDescription: String? = null,
|
channelDescription: String? = null,
|
||||||
|
serverId: String? = null,
|
||||||
dmPartner: User? = null
|
dmPartner: User? = null
|
||||||
) {
|
) {
|
||||||
var renderedChannelDescription by remember { mutableStateOf<AstNode?>(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(channelDescription) {
|
|
||||||
if (channelDescription != null) {
|
|
||||||
renderedChannelDescription = Stendal.render(channelDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|
@ -102,9 +88,9 @@ fun ChannelSheetHeader(
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|
||||||
if (renderedChannelDescription != null && channelDescription?.isNotBlank() == true) {
|
if (!channelDescription.isNullOrBlank()) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
MarkdownTree(node = renderedChannelDescription!!)
|
ChatMarkdown(channelDescription, serverId = serverId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
|
@ -43,14 +47,15 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.min
|
import androidx.compose.ui.unit.min
|
||||||
import chat.stoat.R
|
import chat.stoat.R
|
||||||
import chat.stoat.api.StoatAPI
|
import chat.stoat.api.StoatAPI
|
||||||
import chat.stoat.core.model.schemas.Channel
|
|
||||||
import chat.stoat.core.model.schemas.Message
|
|
||||||
import chat.stoat.api.settings.LoadedSettings
|
import chat.stoat.api.settings.LoadedSettings
|
||||||
import chat.stoat.api.settings.MessageReplyStyle
|
import chat.stoat.api.settings.MessageReplyStyle
|
||||||
import chat.stoat.callbacks.Action
|
import chat.stoat.callbacks.Action
|
||||||
import chat.stoat.callbacks.ActionChannel
|
import chat.stoat.callbacks.ActionChannel
|
||||||
import chat.stoat.composables.chat.Message
|
import chat.stoat.composables.chat.Message
|
||||||
|
import chat.stoat.core.model.schemas.Channel
|
||||||
|
import chat.stoat.core.model.schemas.Message
|
||||||
import chat.stoat.internals.extensions.supportSwipeReply
|
import chat.stoat.internals.extensions.supportSwipeReply
|
||||||
|
import com.mikepenz.markdown.model.State
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
@ -70,11 +75,13 @@ fun RegularMessage(
|
||||||
showReactBottomSheet: () -> Unit,
|
showReactBottomSheet: () -> Unit,
|
||||||
putTextAtCursorPosition: (String) -> Unit,
|
putTextAtCursorPosition: (String) -> Unit,
|
||||||
replyToMessage: suspend (String) -> Unit,
|
replyToMessage: suspend (String) -> Unit,
|
||||||
scope: CoroutineScope = rememberCoroutineScope()
|
scope: CoroutineScope = rememberCoroutineScope(),
|
||||||
|
mdAst: State? = null
|
||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
|
|
||||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
val offsetXState = remember { mutableFloatStateOf(0f) }
|
||||||
|
var offsetX by offsetXState
|
||||||
val animOffsetX by animateFloatAsState(
|
val animOffsetX by animateFloatAsState(
|
||||||
when {
|
when {
|
||||||
drawerIsOpen -> 0f
|
drawerIsOpen -> 0f
|
||||||
|
|
@ -83,8 +90,28 @@ fun RegularMessage(
|
||||||
},
|
},
|
||||||
label = "X offset of message for Swipe to Reply"
|
label = "X offset of message for Swipe to Reply"
|
||||||
)
|
)
|
||||||
var markGestureInvalid by remember { mutableStateOf(false) }
|
val markGestureInvalidState = remember { mutableStateOf(false) }
|
||||||
|
var markGestureInvalid by markGestureInvalidState
|
||||||
var hapticFeedbackPerformed by remember { mutableStateOf(false) }
|
var hapticFeedbackPerformed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Invalidate swipe-to-reply only when a child (e.g. a code block) actually consumed a
|
||||||
|
// horizontal scroll, i.e. it had content to scroll. Non-scrollable code blocks produce
|
||||||
|
// consumed.x == 0 and will not block the swipe gesture.
|
||||||
|
val nestedScrollConnection = remember(offsetXState, markGestureInvalidState) {
|
||||||
|
object : NestedScrollConnection {
|
||||||
|
override fun onPostScroll(
|
||||||
|
consumed: Offset,
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource
|
||||||
|
): Offset {
|
||||||
|
if (consumed.x != 0f) {
|
||||||
|
offsetXState.floatValue = 0f
|
||||||
|
markGestureInvalidState.value = true
|
||||||
|
}
|
||||||
|
return Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
var messageHeight by remember { mutableIntStateOf(0) }
|
var messageHeight by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
val canReleaseToSend = remember(offsetX) { offsetX <= SWIPE_TO_REPLY_THRESHOLD }
|
val canReleaseToSend = remember(offsetX) { offsetX <= SWIPE_TO_REPLY_THRESHOLD }
|
||||||
|
|
@ -182,10 +209,12 @@ fun RegularMessage(
|
||||||
},
|
},
|
||||||
fromWebhook = message.webhook != null,
|
fromWebhook = message.webhook != null,
|
||||||
webhookName = message.webhook?.name,
|
webhookName = message.webhook?.name,
|
||||||
|
mdAst = mdAst,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = with(LocalDensity.current) { animOffsetX.toDp() }
|
x = with(LocalDensity.current) { animOffsetX.toDp() }
|
||||||
)
|
)
|
||||||
|
.nestedScroll(nestedScrollConnection)
|
||||||
.then(
|
.then(
|
||||||
if (LoadedSettings.messageReplyStyle == MessageReplyStyle.SwipeFromEnd)
|
if (LoadedSettings.messageReplyStyle == MessageReplyStyle.SwipeFromEnd)
|
||||||
Modifier.supportSwipeReply(
|
Modifier.supportSwipeReply(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import chat.stoat.api.StoatAPI
|
||||||
import chat.stoat.api.internals.PermissionBit
|
import chat.stoat.api.internals.PermissionBit
|
||||||
import chat.stoat.api.internals.Roles
|
import chat.stoat.api.internals.Roles
|
||||||
import chat.stoat.api.internals.has
|
import chat.stoat.api.internals.has
|
||||||
import chat.stoat.api.settings.FeatureFlags
|
|
||||||
import chat.stoat.composables.chat.AutocompleteSuggestion
|
import chat.stoat.composables.chat.AutocompleteSuggestion
|
||||||
import chat.stoat.core.model.schemas.ChannelType
|
import chat.stoat.core.model.schemas.ChannelType
|
||||||
|
|
||||||
|
|
@ -51,6 +50,7 @@ object Autocomplete {
|
||||||
val member = serverId?.let { StoatAPI.members.getMember(serverId, StoatAPI.selfId ?: "") }
|
val member = serverId?.let { StoatAPI.members.getMember(serverId, StoatAPI.selfId ?: "") }
|
||||||
val massMentionSuggestions = listOf("everyone", "online")
|
val massMentionSuggestions = listOf("everyone", "online")
|
||||||
.filter { it.startsWith(query, ignoreCase = true) }
|
.filter { it.startsWith(query, ignoreCase = true) }
|
||||||
|
val hereIsOnlineMnemonic = if (query == "here") listOf("online") else emptyList()
|
||||||
|
|
||||||
val selfPermissions = StoatAPI.channelCache[channelId]?.let { ch ->
|
val selfPermissions = StoatAPI.channelCache[channelId]?.let { ch ->
|
||||||
Roles.permissionFor(
|
Roles.permissionFor(
|
||||||
|
|
@ -119,7 +119,7 @@ object Autocomplete {
|
||||||
if (query.length < 2) return emptyList()
|
if (query.length < 2) return emptyList()
|
||||||
|
|
||||||
val roles =
|
val roles =
|
||||||
if (selfPermissions has PermissionBit.MentionRoles && FeatureFlags.massMentionsGranted) StoatAPI.serverCache[serverId]?.roles
|
if (selfPermissions has PermissionBit.MentionRoles) StoatAPI.serverCache[serverId]?.roles
|
||||||
?: emptyMap() else emptyMap()
|
?: emptyMap() else emptyMap()
|
||||||
val byNickname = StoatAPI.members.filterNamesFor(serverId, query)
|
val byNickname = StoatAPI.members.filterNamesFor(serverId, query)
|
||||||
.map { m -> m to StoatAPI.userCache[m.id?.user] }.filter { (_, u) ->
|
.map { m -> m to StoatAPI.userCache[m.id?.user] }.filter { (_, u) ->
|
||||||
|
|
@ -174,7 +174,7 @@ object Autocomplete {
|
||||||
}
|
}
|
||||||
|
|
||||||
null -> emptyList()
|
null -> emptyList()
|
||||||
} + if (selfPermissions has PermissionBit.MentionEveryone && FeatureFlags.massMentionsGranted) massMentionSuggestions.map { mention ->
|
} + if (selfPermissions has PermissionBit.MentionEveryone) (massMentionSuggestions + hereIsOnlineMnemonic).map { mention ->
|
||||||
AutocompleteSuggestion.MassMention(mention)
|
AutocompleteSuggestion.MassMention(mention)
|
||||||
} else listOf()
|
} else listOf()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,24 @@ fun resolveTimestamp(timestamp: Long, modifier: String? = null): String {
|
||||||
.withZone(ZoneId.systemDefault())
|
.withZone(ZoneId.systemDefault())
|
||||||
.format(javaInstant)
|
.format(javaInstant)
|
||||||
|
|
||||||
|
// 20/04/2021
|
||||||
|
"d" -> DateTimeFormatter.ofPattern("dd/MM/yyyy")
|
||||||
|
.withLocale(Locale.getDefault())
|
||||||
|
.withZone(ZoneId.systemDefault())
|
||||||
|
.format(javaInstant)
|
||||||
|
|
||||||
|
// 20/04/2021, 16:20
|
||||||
|
"s" -> DateTimeFormatter.ofPattern("dd/MM/yyyy, HH:mm")
|
||||||
|
.withLocale(Locale.getDefault())
|
||||||
|
.withZone(ZoneId.systemDefault())
|
||||||
|
.format(javaInstant)
|
||||||
|
|
||||||
|
// 20/04/2021, 16:20:30
|
||||||
|
"S" -> DateTimeFormatter.ofPattern("dd/MM/yyyy, HH:mm:ss")
|
||||||
|
.withLocale(Locale.getDefault())
|
||||||
|
.withZone(ZoneId.systemDefault())
|
||||||
|
.format(javaInstant)
|
||||||
|
|
||||||
// 9 months ago
|
// 9 months ago
|
||||||
"R" -> DateUtils.getRelativeTimeSpanString(
|
"R" -> DateUtils.getRelativeTimeSpanString(
|
||||||
timestamp * 1000,
|
timestamp * 1000,
|
||||||
|
|
@ -58,8 +76,11 @@ fun resolveTimestamp(timestamp: Long, modifier: String? = null): String {
|
||||||
DateUtils.MINUTE_IN_MILLIS
|
DateUtils.MINUTE_IN_MILLIS
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fallback. Shouldn't happen, regex already checks for this
|
// f as in fallback
|
||||||
else -> timestamp.toString()
|
else -> DateTimeFormatter.ofPattern("dd MMMM yyyy HH:mm")
|
||||||
|
.withLocale(Locale.getDefault())
|
||||||
|
.withZone(ZoneId.systemDefault())
|
||||||
|
.format(javaInstant)
|
||||||
}
|
}
|
||||||
|
|
||||||
return outString.toString()
|
return outString.toString()
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,50 @@
|
||||||
package chat.stoat.internals.text
|
package chat.stoat.internals.text
|
||||||
|
|
||||||
private val STRING_IS_CUSTOM_EMOTES_REGEX = Regex("(?::[0-9A-HJKMNP-TV-Z]{26}:|\\s)+")
|
private val STRING_IS_CUSTOM_EMOTES_REGEX = Regex("(?::[0-9A-HJKMNP-TV-Z]{26}:|\\s)+")
|
||||||
|
private val CUSTOM_EMOTE_REGEX = Regex(":[0-9A-HJKMNP-TV-Z]{26}:")
|
||||||
|
|
||||||
|
sealed class GigamojiState {
|
||||||
|
object None : GigamojiState()
|
||||||
|
object Single : GigamojiState()
|
||||||
|
object Multiple : GigamojiState()
|
||||||
|
|
||||||
|
val isGigamoji: Boolean get() = this != None
|
||||||
|
}
|
||||||
|
|
||||||
object Gigamoji {
|
object Gigamoji {
|
||||||
private fun stringIsEntirelyCustomEmotes(content: String): Boolean {
|
private fun countCustomEmotes(content: String): Int =
|
||||||
return STRING_IS_CUSTOM_EMOTES_REGEX.matches(content)
|
CUSTOM_EMOTE_REGEX.findAll(content).count()
|
||||||
}
|
|
||||||
|
|
||||||
private fun stringIsEntirelyUnicodeEmojis(unfilteredContent: String): Boolean {
|
private fun unicodeEmojiCount(unfilteredContent: String): Int? {
|
||||||
// Remove all custom emotes
|
|
||||||
val content = unfilteredContent.replace(STRING_IS_CUSTOM_EMOTES_REGEX, "")
|
val content = unfilteredContent.replace(STRING_IS_CUSTOM_EMOTES_REGEX, "")
|
||||||
|
if (content.isBlank()) return 0
|
||||||
|
|
||||||
// the message is solely custom emotes
|
var count = 0
|
||||||
if (content.isBlank()) return true
|
var lastWasZwj = false
|
||||||
|
|
||||||
if ("[0-9A-Za-z#*]".toRegex()
|
|
||||||
.containsMatchIn(content)
|
|
||||||
) { // reject common non-emoji characters
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for (codepoint in content.codePoints()) {
|
for (codepoint in content.codePoints()) {
|
||||||
if (MessageProcessor.emoji.codepointIsEmoji(codepoint)) {
|
when {
|
||||||
return true
|
Character.isWhitespace(codepoint) -> lastWasZwj = false
|
||||||
|
codepoint == 0x200D -> lastWasZwj = true
|
||||||
|
codepoint == 0xFE0F || codepoint == 0xFE0E || codepoint == 0x20E3 -> Unit
|
||||||
|
codepoint in 0x1F3FB..0x1F3FF -> lastWasZwj = false
|
||||||
|
MessageProcessor.emoji.codepointIsEmoji(codepoint) -> {
|
||||||
|
if (!lastWasZwj) count++
|
||||||
|
lastWasZwj = false
|
||||||
|
}
|
||||||
|
else -> return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return count
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun useGigamojiForMessage(content: String): Boolean {
|
fun useGigamojiForMessage(content: String): GigamojiState {
|
||||||
if (content.isBlank()) return false
|
if (content.isBlank()) return GigamojiState.None
|
||||||
return stringIsEntirelyCustomEmotes(content) || stringIsEntirelyUnicodeEmojis(content)
|
val unicodeCount = unicodeEmojiCount(content) ?: return GigamojiState.None
|
||||||
|
val total = unicodeCount + countCustomEmotes(content)
|
||||||
|
return when {
|
||||||
|
total == 0 -> GigamojiState.None
|
||||||
|
total == 1 -> GigamojiState.Single
|
||||||
|
else -> GigamojiState.Multiple
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
@ -103,7 +104,6 @@ import chat.stoat.composables.vectorassets.Nametag
|
||||||
import chat.stoat.core.model.schemas.AutumnResource
|
import chat.stoat.core.model.schemas.AutumnResource
|
||||||
import chat.stoat.core.model.schemas.Metadata
|
import chat.stoat.core.model.schemas.Metadata
|
||||||
import chat.stoat.internals.extensions.zero
|
import chat.stoat.internals.extensions.zero
|
||||||
import chat.stoat.markdown.jbm.asHexString
|
|
||||||
import chat.stoat.screens.chat.LocalIsConnected
|
import chat.stoat.screens.chat.LocalIsConnected
|
||||||
import io.github.g00fy2.quickie.QRResult
|
import io.github.g00fy2.quickie.QRResult
|
||||||
import io.github.g00fy2.quickie.ScanQRCode
|
import io.github.g00fy2.quickie.ScanQRCode
|
||||||
|
|
@ -179,8 +179,8 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: ()
|
||||||
text = AnnotatedString.fromHtml(
|
text = AnnotatedString.fromHtml(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.friends_add_by_tag_sheet_description,
|
R.string.friends_add_by_tag_sheet_description,
|
||||||
"<font color=\"${Color(HL_USERNAME).asHexString(false)}\">",
|
"<font color=\"${Color(HL_USERNAME).toArgb().let { "#%02x%02x%02x".format(it shr 16 and 0xff, it shr 8 and 0xff, it and 0xff) }}\">",
|
||||||
"<font color=\"${Color(HL_TAG).asHexString(false)}\">",
|
"<font color=\"${Color(HL_TAG).toArgb().let { "#%02x%02x%02x".format(it shr 16 and 0xff, it shr 8 and 0xff, it and 0xff) }}\">",
|
||||||
"</font>",
|
"</font>",
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.Placeholder
|
import androidx.compose.ui.text.Placeholder
|
||||||
|
|
@ -122,7 +123,6 @@ import chat.stoat.composables.chat.DateDivider
|
||||||
import chat.stoat.composables.chat.Message
|
import chat.stoat.composables.chat.Message
|
||||||
import chat.stoat.composables.chat.MessageField
|
import chat.stoat.composables.chat.MessageField
|
||||||
import chat.stoat.composables.chat.SystemMessage
|
import chat.stoat.composables.chat.SystemMessage
|
||||||
import chat.stoat.composables.chat.UnsupportedMessage
|
|
||||||
import chat.stoat.composables.emoji.EmojiPicker
|
import chat.stoat.composables.emoji.EmojiPicker
|
||||||
import chat.stoat.composables.generic.GroupIcon
|
import chat.stoat.composables.generic.GroupIcon
|
||||||
import chat.stoat.composables.generic.PresenceBadge
|
import chat.stoat.composables.generic.PresenceBadge
|
||||||
|
|
@ -146,6 +146,7 @@ import chat.stoat.screens.chat.LocalIsConnected
|
||||||
import chat.stoat.sheets.ChannelInfoSheet
|
import chat.stoat.sheets.ChannelInfoSheet
|
||||||
import chat.stoat.sheets.MessageContextSheet
|
import chat.stoat.sheets.MessageContextSheet
|
||||||
import chat.stoat.sheets.ReactSheet
|
import chat.stoat.sheets.ReactSheet
|
||||||
|
import com.mikepenz.markdown.model.State
|
||||||
import com.valentinilk.shimmer.ShimmerBounds
|
import com.valentinilk.shimmer.ShimmerBounds
|
||||||
import com.valentinilk.shimmer.rememberShimmer
|
import com.valentinilk.shimmer.rememberShimmer
|
||||||
import com.valentinilk.shimmer.shimmer
|
import com.valentinilk.shimmer.shimmer
|
||||||
|
|
@ -157,14 +158,12 @@ import java.io.File
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
sealed class ChannelScreenItem {
|
sealed class ChannelScreenItem {
|
||||||
data class RegularMessage(val message: Message) : ChannelScreenItem()
|
data class RegularMessage(val message: Message, val mdAst: State?) : ChannelScreenItem()
|
||||||
data class ProspectiveMessage(val message: Message) : ChannelScreenItem()
|
data class ProspectiveMessage(val message: Message, val mdAst: State?) : ChannelScreenItem()
|
||||||
data class FailedMessage(val message: Message) : ChannelScreenItem()
|
data class FailedMessage(val message: Message, val mdAst: State?) : ChannelScreenItem()
|
||||||
data class SystemMessage(val message: Message) : ChannelScreenItem()
|
data class SystemMessage(val message: Message) : ChannelScreenItem()
|
||||||
data class DateDivider(val instant: Instant) : ChannelScreenItem()
|
data class DateDivider(val instant: Instant) : ChannelScreenItem()
|
||||||
data class LoadTrigger(val after: String?, val before: String?) :
|
data class LoadTrigger(val after: String?, val before: String?) : ChannelScreenItem()
|
||||||
ChannelScreenItem()
|
|
||||||
|
|
||||||
data object Loading : ChannelScreenItem()
|
data object Loading : ChannelScreenItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,6 +202,7 @@ fun ChannelScreen(
|
||||||
// <editor-fold desc="State and effects">
|
// <editor-fold desc="State and effects">
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val resources = LocalResources.current
|
||||||
val config = LocalConfiguration.current
|
val config = LocalConfiguration.current
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
@ -351,7 +351,7 @@ fun ChannelScreen(
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
context,
|
context,
|
||||||
context.getString(
|
resources.getString(
|
||||||
R.string.file_picker_chip_camera_failed
|
R.string.file_picker_chip_camera_failed
|
||||||
),
|
),
|
||||||
Toast.LENGTH_SHORT
|
Toast.LENGTH_SHORT
|
||||||
|
|
@ -367,7 +367,7 @@ fun ChannelScreen(
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
context,
|
context,
|
||||||
context.getString(
|
resources.getString(
|
||||||
R.string.file_picker_chip_camera_none_installed
|
R.string.file_picker_chip_camera_none_installed
|
||||||
),
|
),
|
||||||
Toast.LENGTH_SHORT
|
Toast.LENGTH_SHORT
|
||||||
|
|
@ -727,14 +727,6 @@ fun ChannelScreen(
|
||||||
}
|
}
|
||||||
when (val item = viewModel.items[index]) {
|
when (val item = viewModel.items[index]) {
|
||||||
is ChannelScreenItem.RegularMessage -> {
|
is ChannelScreenItem.RegularMessage -> {
|
||||||
if (item.message.content?.replace("\\s".toRegex(), "")
|
|
||||||
?.contains(">>>>>>>") == true
|
|
||||||
) {
|
|
||||||
// FIXME Dirty hack to prevent a crash caused by malicious messages.
|
|
||||||
UnsupportedMessage()
|
|
||||||
return@items
|
|
||||||
}
|
|
||||||
|
|
||||||
RegularMessage(
|
RegularMessage(
|
||||||
item.message,
|
item.message,
|
||||||
viewModel.channel,
|
viewModel.channel,
|
||||||
|
|
@ -757,7 +749,8 @@ fun ChannelScreen(
|
||||||
},
|
},
|
||||||
putTextAtCursorPosition = viewModel::putAtCursorPosition,
|
putTextAtCursorPosition = viewModel::putAtCursorPosition,
|
||||||
replyToMessage = viewModel::addReplyTo,
|
replyToMessage = viewModel::addReplyTo,
|
||||||
scope = scope
|
scope = scope,
|
||||||
|
mdAst = item.mdAst
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -772,7 +765,8 @@ fun ChannelScreen(
|
||||||
onNameClick = {},
|
onNameClick = {},
|
||||||
canReply = false,
|
canReply = false,
|
||||||
onReply = {},
|
onReply = {},
|
||||||
onAddReaction = {}
|
onAddReaction = {},
|
||||||
|
mdAst = item.mdAst,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -787,7 +781,8 @@ fun ChannelScreen(
|
||||||
onNameClick = {},
|
onNameClick = {},
|
||||||
canReply = false,
|
canReply = false,
|
||||||
onReply = {},
|
onReply = {},
|
||||||
onAddReaction = {}
|
onAddReaction = {},
|
||||||
|
mdAst = item.mdAst,
|
||||||
)
|
)
|
||||||
Row {
|
Row {
|
||||||
UserAvatarWidthPlaceholder()
|
UserAvatarWidthPlaceholder()
|
||||||
|
|
|
||||||
|
|
@ -49,14 +49,19 @@ import chat.stoat.callbacks.UiCallbacks
|
||||||
import chat.stoat.core.model.schemas.Channel
|
import chat.stoat.core.model.schemas.Channel
|
||||||
import chat.stoat.core.model.schemas.Message
|
import chat.stoat.core.model.schemas.Message
|
||||||
import chat.stoat.internals.text.MessageProcessor
|
import chat.stoat.internals.text.MessageProcessor
|
||||||
|
import chat.stoat.composables.markdown.prose.easyLineBreaks
|
||||||
|
import chat.stoat.markdown.StoatMarkdownFlavour
|
||||||
import chat.stoat.persistence.KVStorage
|
import chat.stoat.persistence.KVStorage
|
||||||
import chat.stoat.screens.chat.ChatRouterDestination
|
import chat.stoat.screens.chat.ChatRouterDestination
|
||||||
import chat.stoat.settings.providers.AgeGateUnlockedStorageProvider
|
import chat.stoat.settings.providers.AgeGateUnlockedStorageProvider
|
||||||
|
import com.mikepenz.markdown.model.State
|
||||||
|
import com.mikepenz.markdown.model.parseMarkdownFlow
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -257,6 +262,69 @@ class ChannelScreenViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun parseAst(content: String?): State? =
|
||||||
|
content?.takeIf { it.isNotBlank() }?.let { c ->
|
||||||
|
val prepared = injectMassMentionMarkers(easyLineBreaks(c))
|
||||||
|
parseMarkdownFlow(prepared, flavour = StoatMarkdownFlavour(prepared))
|
||||||
|
.first { it !is State.Loading }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replaces @everyone/@online with <@EVERYONE>/<@ONLINE> outside code blocks and inline code spans.
|
||||||
|
private fun injectMassMentionMarkers(content: String): String {
|
||||||
|
val sb = StringBuilder(content.length)
|
||||||
|
var i = 0
|
||||||
|
var inFence = false
|
||||||
|
var inCodeSpan = false
|
||||||
|
|
||||||
|
while (i < content.length) {
|
||||||
|
val ch = content[i]
|
||||||
|
|
||||||
|
if (!inCodeSpan && (ch == '`' || ch == '~')) {
|
||||||
|
val lineStart = sb.lastIndexOf('\n') + 1
|
||||||
|
val atLineStart = sb.length == lineStart || sb.substring(lineStart).isBlank()
|
||||||
|
if (atLineStart) {
|
||||||
|
var run = 0
|
||||||
|
while (i + run < content.length && content[i + run] == ch) run++
|
||||||
|
if (run >= 3) {
|
||||||
|
inFence = !inFence
|
||||||
|
sb.append(content, i, i + run)
|
||||||
|
i += run
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inFence && ch == '`') {
|
||||||
|
inCodeSpan = !inCodeSpan
|
||||||
|
sb.append(ch)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inFence && !inCodeSpan && ch == '@') {
|
||||||
|
when {
|
||||||
|
content.startsWith("everyone", i + 1) &&
|
||||||
|
(i + 9 >= content.length || !content[i + 9].isLetterOrDigit()) -> {
|
||||||
|
sb.append("<@EVERYONE>")
|
||||||
|
i += 9
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content.startsWith("online", i + 1) &&
|
||||||
|
(i + 7 >= content.length || !content[i + 7].isLetterOrDigit()) -> {
|
||||||
|
sb.append("<@ONLINE>")
|
||||||
|
i += 7
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append(ch)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts the draft content in the KV storage, the in-memory state of the message content,
|
* Puts the draft content in the KV storage, the in-memory state of the message content,
|
||||||
* and, if [setInitial] is true, updates the text field to say the new [content].
|
* and, if [setInitial] is true, updates the text field to say the new [content].
|
||||||
|
|
@ -395,7 +463,10 @@ class ChannelScreenViewModel(
|
||||||
} ?: false
|
} ?: false
|
||||||
)
|
)
|
||||||
|
|
||||||
updateItems(listOf(ChannelScreenItem.ProspectiveMessage(prospectiveMessage)) + items)
|
val ast = parseAst(content)
|
||||||
|
updateItems(
|
||||||
|
listOf(ChannelScreenItem.ProspectiveMessage(prospectiveMessage, ast)) + items
|
||||||
|
)
|
||||||
|
|
||||||
kvStorage.remove("draftContent/${channel?.id}")
|
kvStorage.remove("draftContent/${channel?.id}")
|
||||||
putDraftContent("", true)
|
putDraftContent("", true)
|
||||||
|
|
@ -415,7 +486,13 @@ class ChannelScreenViewModel(
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("ChannelScreenViewModel", "Failed to send message", e)
|
Log.e("ChannelScreenViewModel", "Failed to send message", e)
|
||||||
updateItems(listOf(ChannelScreenItem.FailedMessage(prospectiveMessage)) + items.filter { it !is ChannelScreenItem.ProspectiveMessage })
|
updateItems(
|
||||||
|
listOf(
|
||||||
|
ChannelScreenItem.FailedMessage(
|
||||||
|
prospectiveMessage,
|
||||||
|
ast
|
||||||
|
)
|
||||||
|
) + items.filter { it !is ChannelScreenItem.ProspectiveMessage })
|
||||||
} finally {
|
} finally {
|
||||||
isSending = false
|
isSending = false
|
||||||
}
|
}
|
||||||
|
|
@ -495,11 +572,17 @@ class ChannelScreenViewModel(
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}.map {
|
}.let { filtered ->
|
||||||
when {
|
val result = mutableListOf<ChannelScreenItem>()
|
||||||
it.system != null -> ChannelScreenItem.SystemMessage(it)
|
for (msg in filtered) {
|
||||||
else -> ChannelScreenItem.RegularMessage(it)
|
result.add(
|
||||||
|
when {
|
||||||
|
msg.system != null -> ChannelScreenItem.SystemMessage(msg)
|
||||||
|
else -> ChannelScreenItem.RegularMessage(msg, parseAst(msg.content))
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place items according to whether above/below/around was specified.
|
// Place items according to whether above/below/around was specified.
|
||||||
|
|
@ -553,7 +636,7 @@ class ChannelScreenViewModel(
|
||||||
if (didInitialChannelFetch) { // this check is so that we don't end up with a message that arrives at the same time as the initial fetch in front of the loading indicator
|
if (didInitialChannelFetch) { // this check is so that we don't end up with a message that arrives at the same time as the initial fetch in front of the loading indicator
|
||||||
val newItem = when {
|
val newItem = when {
|
||||||
it.system != null -> ChannelScreenItem.SystemMessage(it)
|
it.system != null -> ChannelScreenItem.SystemMessage(it)
|
||||||
else -> ChannelScreenItem.RegularMessage(it)
|
else -> ChannelScreenItem.RegularMessage(it, parseAst(it.content))
|
||||||
}
|
}
|
||||||
updateItems(listOf(newItem) + items.filter { m ->
|
updateItems(listOf(newItem) + items.filter { m ->
|
||||||
if (m is ChannelScreenItem.ProspectiveMessage) {
|
if (m is ChannelScreenItem.ProspectiveMessage) {
|
||||||
|
|
@ -601,9 +684,10 @@ class ChannelScreenViewModel(
|
||||||
updateItems(
|
updateItems(
|
||||||
items.map { m ->
|
items.map { m ->
|
||||||
if (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) {
|
if (m is ChannelScreenItem.RegularMessage && m.message.id == it.id) {
|
||||||
ChannelScreenItem.RegularMessage(
|
val merged = m.message.mergeWithPartial(messageFrame)
|
||||||
m.message.mergeWithPartial(messageFrame)
|
val ast =
|
||||||
)
|
if (messageFrame.content != null) parseAst(merged.content) else m.mdAst
|
||||||
|
ChannelScreenItem.RegularMessage(merged, ast)
|
||||||
} else {
|
} else {
|
||||||
m
|
m
|
||||||
}
|
}
|
||||||
|
|
@ -624,7 +708,7 @@ class ChannelScreenViewModel(
|
||||||
items.map { currentMsg ->
|
items.map { currentMsg ->
|
||||||
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
|
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
|
||||||
StoatAPI.messageCache[it.id]?.let { m ->
|
StoatAPI.messageCache[it.id]?.let { m ->
|
||||||
ChannelScreenItem.RegularMessage(m)
|
ChannelScreenItem.RegularMessage(m, currentMsg.mdAst)
|
||||||
} ?: return@map currentMsg
|
} ?: return@map currentMsg
|
||||||
} else {
|
} else {
|
||||||
currentMsg
|
currentMsg
|
||||||
|
|
@ -648,7 +732,7 @@ class ChannelScreenViewModel(
|
||||||
items.map { currentMsg ->
|
items.map { currentMsg ->
|
||||||
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
|
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
|
||||||
StoatAPI.messageCache[it.id]?.let { m ->
|
StoatAPI.messageCache[it.id]?.let { m ->
|
||||||
ChannelScreenItem.RegularMessage(m)
|
ChannelScreenItem.RegularMessage(m, currentMsg.mdAst)
|
||||||
} ?: return@map currentMsg
|
} ?: return@map currentMsg
|
||||||
} else {
|
} else {
|
||||||
currentMsg
|
currentMsg
|
||||||
|
|
@ -672,7 +756,7 @@ class ChannelScreenViewModel(
|
||||||
items.map { currentMsg ->
|
items.map { currentMsg ->
|
||||||
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
|
if (currentMsg is ChannelScreenItem.RegularMessage && currentMsg.message.id == it.id) {
|
||||||
StoatAPI.messageCache[it.id]?.let { m ->
|
StoatAPI.messageCache[it.id]?.let { m ->
|
||||||
ChannelScreenItem.RegularMessage(m)
|
ChannelScreenItem.RegularMessage(m, currentMsg.mdAst)
|
||||||
} ?: return@map currentMsg
|
} ?: return@map currentMsg
|
||||||
} else {
|
} else {
|
||||||
currentMsg
|
currentMsg
|
||||||
|
|
@ -870,15 +954,15 @@ class ChannelScreenViewModel(
|
||||||
groupedItems.add(
|
groupedItems.add(
|
||||||
when (m) {
|
when (m) {
|
||||||
is ChannelScreenItem.RegularMessage -> ChannelScreenItem.RegularMessage(
|
is ChannelScreenItem.RegularMessage -> ChannelScreenItem.RegularMessage(
|
||||||
m.message.copy(
|
m.message.copy(tail = tail), m.mdAst
|
||||||
tail = tail
|
)
|
||||||
)
|
|
||||||
|
is ChannelScreenItem.ProspectiveMessage -> ChannelScreenItem.ProspectiveMessage(
|
||||||
|
m.message.copy(tail = tail), m.mdAst
|
||||||
)
|
)
|
||||||
|
|
||||||
is ChannelScreenItem.SystemMessage -> ChannelScreenItem.SystemMessage(
|
is ChannelScreenItem.SystemMessage -> ChannelScreenItem.SystemMessage(
|
||||||
m.message.copy(
|
m.message.copy(tail = tail)
|
||||||
tail = tail
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> m
|
else -> m
|
||||||
|
|
|
||||||
|
|
@ -166,15 +166,6 @@ fun LabsHomeScreen(navController: NavController, topNav: NavController) {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text("JB Markdown")
|
|
||||||
},
|
|
||||||
modifier = Modifier.clickable {
|
|
||||||
navController.navigate("sandboxes/jbm")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text("Gradient Editor")
|
Text("Gradient Editor")
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import androidx.navigation.compose.rememberNavController
|
||||||
import chat.stoat.api.settings.FeatureFlags
|
import chat.stoat.api.settings.FeatureFlags
|
||||||
import chat.stoat.screens.labs.ui.mockups.NewLoginExperienceMockup
|
import chat.stoat.screens.labs.ui.mockups.NewLoginExperienceMockup
|
||||||
import chat.stoat.screens.labs.ui.sandbox.GradientEditorSandbox
|
import chat.stoat.screens.labs.ui.sandbox.GradientEditorSandbox
|
||||||
import chat.stoat.screens.labs.ui.sandbox.JBMSandbox
|
|
||||||
import chat.stoat.screens.labs.ui.sandbox.NewCardSandboxScreen
|
import chat.stoat.screens.labs.ui.sandbox.NewCardSandboxScreen
|
||||||
import chat.stoat.screens.labs.ui.sandbox.SettingsDslSandbox
|
import chat.stoat.screens.labs.ui.sandbox.SettingsDslSandbox
|
||||||
import chat.stoat.screens.labs.ui.sandbox.TelecomSandbox
|
import chat.stoat.screens.labs.ui.sandbox.TelecomSandbox
|
||||||
|
|
@ -71,9 +70,6 @@ fun LabsRootScreen(topNav: NavController) {
|
||||||
composable("sandboxes/settingsdsl") {
|
composable("sandboxes/settingsdsl") {
|
||||||
SettingsDslSandbox(labsNav)
|
SettingsDslSandbox(labsNav)
|
||||||
}
|
}
|
||||||
composable("sandboxes/jbm") {
|
|
||||||
JBMSandbox(labsNav)
|
|
||||||
}
|
|
||||||
composable("sandboxes/gradienteditor") {
|
composable("sandboxes/gradienteditor") {
|
||||||
GradientEditorSandbox(labsNav)
|
GradientEditorSandbox(labsNav)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.compose.animation.animateContentSize
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonGroupDefaults
|
|
||||||
import androidx.compose.material3.ElevatedButton
|
import androidx.compose.material3.ElevatedButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.ToggleButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.semantics.role
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
|
@ -39,36 +25,18 @@ import androidx.navigation.NavController
|
||||||
import chat.stoat.BuildConfig
|
import chat.stoat.BuildConfig
|
||||||
import chat.stoat.StoatApplication
|
import chat.stoat.StoatApplication
|
||||||
import chat.stoat.api.settings.Experiments
|
import chat.stoat.api.settings.Experiments
|
||||||
import chat.stoat.api.settings.FeatureFlags
|
|
||||||
import chat.stoat.api.settings.LoadedSettings
|
import chat.stoat.api.settings.LoadedSettings
|
||||||
import chat.stoat.composables.markdown.RichMarkdown
|
import chat.stoat.composables.markdown.prose.UIMarkdown
|
||||||
import chat.stoat.persistence.KVStorage
|
import chat.stoat.persistence.KVStorage
|
||||||
import chat.stoat.settings.dsl.SettingsPage
|
import chat.stoat.settings.dsl.SettingsPage
|
||||||
import chat.stoat.settings.dsl.SubcategoryContentInsets
|
import chat.stoat.settings.dsl.SubcategoryContentInsets
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
enum class MarkdownRenderer {
|
|
||||||
Stendal, JetBrains, FinalMarkdown
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExperimentsSettingsScreenViewModel : ViewModel() {
|
class ExperimentsSettingsScreenViewModel : ViewModel() {
|
||||||
private val kv = KVStorage(StoatApplication.instance)
|
private val kv = KVStorage(StoatApplication.instance)
|
||||||
|
|
||||||
fun init() {
|
fun init() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when {
|
|
||||||
Experiments.useKotlinBasedMarkdownRenderer.isEnabled -> {
|
|
||||||
mdRenderer.value = MarkdownRenderer.JetBrains
|
|
||||||
}
|
|
||||||
|
|
||||||
Experiments.useFinalMarkdownRenderer.isEnabled -> {
|
|
||||||
mdRenderer.value = MarkdownRenderer.FinalMarkdown
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
mdRenderer.value = MarkdownRenderer.Stendal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
usePolarChecked.value = Experiments.usePolar.isEnabled
|
usePolarChecked.value = Experiments.usePolar.isEnabled
|
||||||
enableServerIdentityOptionsChecked.value =
|
enableServerIdentityOptionsChecked.value =
|
||||||
Experiments.enableServerIdentityOptions.isEnabled
|
Experiments.enableServerIdentityOptions.isEnabled
|
||||||
|
|
@ -100,36 +68,6 @@ class ExperimentsSettingsScreenViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val mdRenderer = mutableStateOf(MarkdownRenderer.Stendal)
|
|
||||||
|
|
||||||
fun setMdRenderer(value: MarkdownRenderer) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (value) {
|
|
||||||
MarkdownRenderer.Stendal -> {
|
|
||||||
kv.set("exp/useKotlinBasedMarkdownRenderer", false)
|
|
||||||
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(false)
|
|
||||||
kv.set("exp/useFinalMarkdownRenderer", false)
|
|
||||||
Experiments.useFinalMarkdownRenderer.setEnabled(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
MarkdownRenderer.JetBrains -> {
|
|
||||||
kv.set("exp/useKotlinBasedMarkdownRenderer", true)
|
|
||||||
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(true)
|
|
||||||
kv.set("exp/useFinalMarkdownRenderer", false)
|
|
||||||
Experiments.useFinalMarkdownRenderer.setEnabled(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
MarkdownRenderer.FinalMarkdown -> {
|
|
||||||
kv.set("exp/useKotlinBasedMarkdownRenderer", false)
|
|
||||||
Experiments.useKotlinBasedMarkdownRenderer.setEnabled(false)
|
|
||||||
kv.set("exp/useFinalMarkdownRenderer", true)
|
|
||||||
Experiments.useFinalMarkdownRenderer.setEnabled(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mdRenderer.value = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val usePolarChecked = mutableStateOf(false)
|
val usePolarChecked = mutableStateOf(false)
|
||||||
|
|
||||||
fun setUsePolarChecked(value: Boolean) {
|
fun setUsePolarChecked(value: Boolean) {
|
||||||
|
|
@ -212,64 +150,6 @@ fun ExperimentsSettingsScreen(
|
||||||
Text("Experiments", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text("Experiments", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text("Markdown Renderer")
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
when (viewModel.mdRenderer.value) {
|
|
||||||
MarkdownRenderer.Stendal -> Text("Use the original C++ Markdown renderer for messages.")
|
|
||||||
MarkdownRenderer.JetBrains -> Text("Use the Kotlin-based JetBrains Markdown renderer for messages. This renderer is more feature-complete and has better results.")
|
|
||||||
MarkdownRenderer.FinalMarkdown -> Text("Use a new. blazingly fast markdown renderer for messages. This renderer is experimental and may have missing features.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.animateContentSize()
|
|
||||||
.weight(1f)
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(IntrinsicSize.Max)
|
|
||||||
.padding(end = 8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
|
|
||||||
) {
|
|
||||||
ToggleButton(
|
|
||||||
checked = viewModel.mdRenderer.value == MarkdownRenderer.Stendal,
|
|
||||||
onCheckedChange = { viewModel.setMdRenderer(MarkdownRenderer.Stendal) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.semantics { role = Role.RadioButton }
|
|
||||||
) {
|
|
||||||
Text("Default")
|
|
||||||
}
|
|
||||||
ToggleButton(
|
|
||||||
checked = viewModel.mdRenderer.value == MarkdownRenderer.JetBrains,
|
|
||||||
onCheckedChange = { viewModel.setMdRenderer(MarkdownRenderer.JetBrains) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.semantics { role = Role.RadioButton }
|
|
||||||
) {
|
|
||||||
Text("Kotlin")
|
|
||||||
}
|
|
||||||
if (FeatureFlags.finalMarkdownGranted || viewModel.mdRenderer.value == MarkdownRenderer.FinalMarkdown) {
|
|
||||||
ToggleButton(
|
|
||||||
checked = viewModel.mdRenderer.value == MarkdownRenderer.FinalMarkdown,
|
|
||||||
onCheckedChange = { viewModel.setMdRenderer(MarkdownRenderer.FinalMarkdown) },
|
|
||||||
enabled = FeatureFlags.finalMarkdownGranted,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.semantics { role = Role.RadioButton }
|
|
||||||
) {
|
|
||||||
Text("Final")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text("Threefold Root User Interface")
|
Text("Threefold Root User Interface")
|
||||||
|
|
@ -307,7 +187,7 @@ fun ExperimentsSettingsScreen(
|
||||||
Text("Voice Chats 2.0")
|
Text("Voice Chats 2.0")
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
RichMarkdown("Enable voice chats support.\n‼️ **Not available in this build!** ‼️")
|
UIMarkdown("Enable voice chats support.\n‼️ **Not available in this build!** ‼️")
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Switch(
|
Switch(
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ import chat.stoat.api.routes.auth.logoutAllSessions
|
||||||
import chat.stoat.api.routes.auth.logoutSessionById
|
import chat.stoat.api.routes.auth.logoutSessionById
|
||||||
import chat.stoat.core.model.schemas.Session
|
import chat.stoat.core.model.schemas.Session
|
||||||
import chat.stoat.composables.generic.ListHeader
|
import chat.stoat.composables.generic.ListHeader
|
||||||
import chat.stoat.composables.markdown.RichMarkdown
|
import chat.stoat.composables.markdown.prose.UIMarkdown
|
||||||
import chat.stoat.composables.settings.sessions.SessionItem
|
import chat.stoat.composables.settings.sessions.SessionItem
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -199,7 +199,7 @@ fun SessionSettingsScreen(
|
||||||
}
|
}
|
||||||
} ?: run {
|
} ?: run {
|
||||||
item(key = "noCurrentSession") {
|
item(key = "noCurrentSession") {
|
||||||
RichMarkdown(
|
UIMarkdown(
|
||||||
input = stringResource(id = R.string.settings_sessions_this_device_unavailable),
|
input = stringResource(id = R.string.settings_sessions_this_device_unavailable),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ fun ChannelInfoSheet(channelId: String, onHideSheet: suspend () -> Unit) {
|
||||||
channelIcon = channel.icon,
|
channelIcon = channel.icon,
|
||||||
channelType = channel.channelType ?: ChannelType.TextChannel,
|
channelType = channel.channelType ?: ChannelType.TextChannel,
|
||||||
channelDescription = channel.description,
|
channelDescription = channel.description,
|
||||||
|
serverId = channel.server,
|
||||||
dmPartner = partner
|
dmPartner = partner
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
|
||||||
|
|
@ -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.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.stoat.BuildConfig
|
|
||||||
import chat.stoat.R
|
import chat.stoat.R
|
||||||
import chat.stoat.api.StoatAPI
|
import chat.stoat.api.StoatAPI
|
||||||
import chat.stoat.api.internals.PermissionBit
|
import chat.stoat.api.internals.PermissionBit
|
||||||
|
|
@ -42,7 +41,6 @@ import chat.stoat.api.internals.Roles
|
||||||
import chat.stoat.api.internals.has
|
import chat.stoat.api.internals.has
|
||||||
import chat.stoat.api.routes.channel.deleteMessage
|
import chat.stoat.api.routes.channel.deleteMessage
|
||||||
import chat.stoat.api.routes.channel.react
|
import chat.stoat.api.routes.channel.react
|
||||||
import chat.stoat.api.settings.Experiments
|
|
||||||
import chat.stoat.callbacks.UiCallbacks
|
import chat.stoat.callbacks.UiCallbacks
|
||||||
import chat.stoat.composables.chat.Message
|
import chat.stoat.composables.chat.Message
|
||||||
import chat.stoat.composables.generic.SheetButton
|
import chat.stoat.composables.generic.SheetButton
|
||||||
|
|
@ -77,9 +75,6 @@ fun MessageContextSheet(
|
||||||
var showShareSheet by remember { mutableStateOf(false) }
|
var showShareSheet by remember { mutableStateOf(false) }
|
||||||
var showReactSheet by remember { mutableStateOf(false) }
|
var showReactSheet by remember { mutableStateOf(false) }
|
||||||
var showDeleteMessageConfirmation by remember { mutableStateOf(false) }
|
var showDeleteMessageConfirmation by remember { mutableStateOf(false) }
|
||||||
var showInspectASTSheet by remember { mutableStateOf(false) }
|
|
||||||
val showInspectASTSheetButton =
|
|
||||||
BuildConfig.DEBUG || (message.content != null && Experiments.useKotlinBasedMarkdownRenderer.isEnabled)
|
|
||||||
|
|
||||||
if (showShareSheet) {
|
if (showShareSheet) {
|
||||||
val shareSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val shareSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
@ -309,19 +304,6 @@ fun MessageContextSheet(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showInspectASTSheet) {
|
|
||||||
val inspectASTSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
|
|
||||||
ModalBottomSheet(
|
|
||||||
sheetState = inspectASTSheetState,
|
|
||||||
onDismissRequest = {
|
|
||||||
showInspectASTSheet = false
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
JBMDebuggerSheet(message.content ?: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
|
|
@ -424,26 +406,6 @@ fun MessageContextSheet(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (showInspectASTSheetButton) {
|
|
||||||
SheetButton(
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = R.drawable.ic_account_tree_24dp),
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
text = "Inspect AST"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showInspectASTSheet = true
|
|
||||||
},
|
|
||||||
special = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SheetButton(
|
SheetButton(
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ import chat.stoat.R
|
||||||
import chat.stoat.api.StoatAPI
|
import chat.stoat.api.StoatAPI
|
||||||
import chat.stoat.api.routes.server.leaveOrDeleteServer
|
import chat.stoat.api.routes.server.leaveOrDeleteServer
|
||||||
import chat.stoat.composables.generic.SheetButton
|
import chat.stoat.composables.generic.SheetButton
|
||||||
import chat.stoat.composables.markdown.RichMarkdown
|
import chat.stoat.composables.markdown.prose.ChatMarkdown
|
||||||
import chat.stoat.composables.screens.settings.ServerOverview
|
import chat.stoat.composables.screens.settings.ServerOverview
|
||||||
import chat.stoat.composables.sheets.SheetSelection
|
import chat.stoat.composables.sheets.SheetSelection
|
||||||
import chat.stoat.core.model.data.STOAT_WEB_APP
|
import chat.stoat.core.model.data.STOAT_WEB_APP
|
||||||
|
|
@ -159,14 +159,13 @@ fun ServerContextSheet(
|
||||||
ServerOverview(server)
|
ServerOverview(server)
|
||||||
|
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
RichMarkdown(
|
ChatMarkdown(
|
||||||
input = if (server.description?.isBlank() == false) {
|
content = if (server.description?.isBlank() == false) {
|
||||||
server.description!!
|
server.description!!
|
||||||
} else {
|
} else {
|
||||||
stringResource(
|
stringResource(R.string.server_context_sheet_description_empty)
|
||||||
R.string.server_context_sheet_description_empty
|
},
|
||||||
)
|
serverId = serverId,
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ import chat.stoat.composables.chat.UserBadgeList
|
||||||
import chat.stoat.composables.chat.UserBadgeRow
|
import chat.stoat.composables.chat.UserBadgeRow
|
||||||
import chat.stoat.composables.generic.NonIdealState
|
import chat.stoat.composables.generic.NonIdealState
|
||||||
import chat.stoat.composables.generic.UserAvatar
|
import chat.stoat.composables.generic.UserAvatar
|
||||||
import chat.stoat.composables.markdown.RichMarkdown
|
import chat.stoat.composables.markdown.prose.ChatMarkdown
|
||||||
import chat.stoat.composables.screens.settings.RawUserOverview
|
import chat.stoat.composables.screens.settings.RawUserOverview
|
||||||
import chat.stoat.composables.screens.settings.UserButtons
|
import chat.stoat.composables.screens.settings.UserButtons
|
||||||
import chat.stoat.composables.sheets.SheetTile
|
import chat.stoat.composables.sheets.SheetTile
|
||||||
|
|
@ -403,11 +403,11 @@ fun UserInfoSheet(
|
||||||
Text(stringResource(R.string.user_info_sheet_category_bio))
|
Text(stringResource(R.string.user_info_sheet_category_bio))
|
||||||
},
|
},
|
||||||
contentPreview = {
|
contentPreview = {
|
||||||
RichMarkdown(input = profile?.content!!)
|
ChatMarkdown(content = profile?.content!!, serverId = serverId)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
RichMarkdown(input = profile?.content!!)
|
ChatMarkdown(content = profile?.content!!, serverId = serverId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.os.Build
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.text.ExperimentalTextApi
|
import androidx.compose.ui.text.ExperimentalTextApi
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.DeviceFontFamilyName
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
|
@ -55,7 +56,9 @@ private val InterDisplay = FontFamily(
|
||||||
)
|
)
|
||||||
val FragmentMono = FontFamily(
|
val FragmentMono = FontFamily(
|
||||||
Font(R.font.fragmentmono_regular, FontWeight.Normal),
|
Font(R.font.fragmentmono_regular, FontWeight.Normal),
|
||||||
Font(R.font.fragmentmono_italic, FontWeight.Normal, FontStyle.Italic)
|
Font(R.font.fragmentmono_italic, FontWeight.Normal, FontStyle.Italic),
|
||||||
|
Font(DeviceFontFamilyName("monospace"), FontWeight.Normal, FontStyle.Normal),
|
||||||
|
Font(DeviceFontFamilyName("monospace"), FontWeight.Normal, FontStyle.Italic)
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalTextApi::class)
|
@OptIn(ExperimentalTextApi::class)
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-view
|
||||||
multiplatform-markdown-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "multiplatform-markdown" }
|
multiplatform-markdown-android = { module = "com.mikepenz:multiplatform-markdown-renderer-android", version.ref = "multiplatform-markdown" }
|
||||||
multiplatform-markdown-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatform-markdown" }
|
multiplatform-markdown-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatform-markdown" }
|
||||||
multiplatform-markdown-coil3 = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "multiplatform-markdown" }
|
multiplatform-markdown-coil3 = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "multiplatform-markdown" }
|
||||||
|
multiplatform-markdown-code = { module = "com.mikepenz:multiplatform-markdown-renderer-code", version.ref = "multiplatform-markdown" }
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||||
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
|
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue