feat: support for slow mode

This commit is contained in:
infi 2026-07-29 08:02:33 +02:00
parent 12e71c1bc6
commit 8ae4654a09
11 changed files with 346 additions and 27 deletions

View File

@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateMapOf
import chat.stoat.BuildConfig
import chat.stoat.StoatApplication
import chat.stoat.api.StoatAPI.initialize
import chat.stoat.api.internals.ActiveSlowmode
import chat.stoat.api.internals.Members
import chat.stoat.api.realtime.DisconnectionState
import chat.stoat.api.realtime.RealtimeSocket
@ -151,6 +152,7 @@ object StoatAPI {
val emojiCache = mutableStateMapOf<String, Emoji>()
val messageCache = mutableStateMapOf<String, Message>()
val voiceStateCache = mutableStateMapOf<String, ChannelVoiceState>()
val userSlowmodeCache = mutableStateMapOf<String, ActiveSlowmode>()
val members = Members()
@ -274,6 +276,7 @@ object StoatAPI {
channelCache.clear()
emojiCache.clear()
messageCache.clear()
userSlowmodeCache.clear()
members.clear()
unreads.clear()

View File

@ -48,9 +48,10 @@ enum class PermissionBit(val value: Long) {
// * Channel permissions cont.
MentionEveryone(1L shl 37),
MentionRoles(1L shl 38),
BypassSlowmode(1L shl 39),
// * Misc. permissions
// % Bits 38 to 52: free area
// % Bits 40 to 52: free area
// % Bits 53 to 64: do not use
// * Grant all permissions

View File

@ -0,0 +1,45 @@
package chat.stoat.api.internals
import chat.stoat.core.model.schemas.ChannelSlowmode
data class ActiveSlowmode(
val durationSeconds: Long,
val expiresAtMilliseconds: Long,
) {
fun remainingSeconds(nowMilliseconds: Long): Long {
val remainingMilliseconds = expiresAtMilliseconds - nowMilliseconds
if (remainingMilliseconds <= 0) return 0
return (remainingMilliseconds + 999) / 1_000
}
companion object {
fun from(
slowmode: ChannelSlowmode,
receivedAtMilliseconds: Long,
) = ActiveSlowmode(
durationSeconds = slowmode.duration,
expiresAtMilliseconds =
receivedAtMilliseconds + slowmode.retryAfter.coerceAtLeast(0) * 1_000,
)
}
}
internal fun formatCompactDuration(totalSeconds: Long): String {
var remaining = totalSeconds.coerceAtLeast(0)
val days = remaining / 86_400
remaining %= 86_400
val hours = remaining / 3_600
remaining %= 3_600
val minutes = remaining / 60
val seconds = remaining % 60
val parts = buildList {
if (days > 0) add("${days}d")
if (hours > 0) add("${hours}h")
if (minutes > 0) add("${minutes}m")
if (seconds > 0) add("${seconds}s")
}
return parts.take(2).joinToString(" ").ifEmpty { "0s" }
}

View File

@ -1,11 +1,13 @@
package chat.stoat.api.realtime
import android.os.SystemClock
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import chat.stoat.StoatApplication
import chat.stoat.api.StoatAPI
import chat.stoat.api.StoatHttp
import chat.stoat.api.StoatJson
import chat.stoat.api.internals.ActiveSlowmode
import chat.stoat.api.realtime.frames.receivable.AnyFrame
import chat.stoat.api.realtime.frames.receivable.BulkFrame
import chat.stoat.api.realtime.frames.receivable.ChannelAckFrame
@ -30,6 +32,7 @@ import chat.stoat.api.realtime.frames.receivable.ServerRoleUpdateFrame
import chat.stoat.api.realtime.frames.receivable.ServerUpdateFrame
import chat.stoat.api.realtime.frames.receivable.UserMoveVoiceChannelFrame
import chat.stoat.api.realtime.frames.receivable.UserRelationshipFrame
import chat.stoat.api.realtime.frames.receivable.UserSlowmodesFrame
import chat.stoat.api.realtime.frames.receivable.UserUpdateFrame
import chat.stoat.api.realtime.frames.receivable.UserVoiceStateUpdateFrame
import chat.stoat.api.realtime.frames.receivable.VoiceChannelJoinFrame
@ -176,6 +179,7 @@ object RealtimeSocket {
"Ready" -> {
val readyFrame = StoatJson.decodeFromString(ReadyFrame.serializer(), rawFrame)
StoatAPI.userSlowmodeCache.clear()
logcat {
"Received ready frame with ${readyFrame.users.size} users, " +
@ -513,8 +517,15 @@ object RealtimeSocket {
val existing = StoatAPI.channelCache[channelUpdateFrame.id]
?: return // if we don't have the channel no point in updating it
val combined = existing.mergeWithPartial(channelUpdateFrame.data)
var combined = existing.mergeWithPartial(channelUpdateFrame.data)
if ("Slowmode" in channelUpdateFrame.clear.orEmpty()) {
combined = combined.copy(slowmode = null)
}
StoatAPI.channelCache[channelUpdateFrame.id] = combined
if ((combined.slowmode ?: 0) <= 0) {
StoatAPI.userSlowmodeCache.remove(channelUpdateFrame.id)
}
database.channelQueries.upsert(
channelUpdateFrame.id,
@ -576,6 +587,7 @@ object RealtimeSocket {
}
StoatAPI.channelCache.remove(channelDeleteFrame.id)
StoatAPI.userSlowmodeCache.remove(channelDeleteFrame.id)
database.channelQueries.delete(channelDeleteFrame.id)
if (currentChannel.server != null) {
@ -609,6 +621,17 @@ object RealtimeSocket {
StoatAPI.unreads.processExternalAck(channelAckFrame.id, channelAckFrame.messageId)
}
"UserSlowmodes" -> {
val userSlowmodesFrame =
StoatJson.decodeFromString(UserSlowmodesFrame.serializer(), rawFrame)
val receivedAt = SystemClock.elapsedRealtime()
userSlowmodesFrame.slowmodes.forEach { slowmode ->
StoatAPI.userSlowmodeCache[slowmode.channelId] =
ActiveSlowmode.from(slowmode, receivedAt)
}
}
"ServerCreate" -> {
val serverCreateFrame =
StoatJson.decodeFromString(ServerCreateFrame.serializer(), rawFrame)

View File

@ -1,6 +1,7 @@
package chat.stoat.api.realtime.frames.receivable
import chat.stoat.core.model.schemas.Channel
import chat.stoat.core.model.schemas.ChannelSlowmode
import chat.stoat.core.model.util.ChannelVoiceState
import chat.stoat.core.model.schemas.Embed
import chat.stoat.core.model.schemas.Emoji
@ -159,6 +160,12 @@ data class ChannelAckFrame(
val messageId: String
)
@Serializable
data class UserSlowmodesFrame(
val type: String = "UserSlowmodes",
val slowmodes: List<ChannelSlowmode>,
)
@Serializable
data class ServerCreateFrame(
val type: String = "ServerCreate",
@ -280,4 +287,4 @@ data class UserMoveVoiceChannelFrame(
val type: String = "UserMoveVoiceChannel",
val node: String,
val token: String,
)
)

View File

@ -152,6 +152,7 @@ fun MessageField(
channelName: String,
modifier: Modifier = Modifier,
forceSendButton: Boolean = false,
sendEnabled: Boolean = true,
canAttach: Boolean = true,
disabled: Boolean = false,
failedValidation: Boolean = false,
@ -548,7 +549,9 @@ fun MessageField(
!it.isAltPressed &&
it.isCtrlPressed &&
!it.isMetaPressed -> {
onSendMessage()
if (sendEnabled) {
onSendMessage()
}
return@onKeyEvent true
}
@ -624,12 +627,16 @@ fun MessageField(
editMode -> painterResource(R.drawable.ic_edit_24dp)
else -> painterResource(R.drawable.ic_send_24dp)
},
tint = MaterialTheme.colorScheme.primary,
tint = if (sendEnabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
},
contentDescription = stringResource(id = R.string.send_alt),
modifier = Modifier
.padding(end = 8.dp)
.clip(CircleShape)
.clickable { onSendMessage() }
.clickable(enabled = sendEnabled) { onSendMessage() }
.size(32.dp)
.padding(4.dp)
.testTag("send_message")

View File

@ -1,37 +1,55 @@
package chat.stoat.composables.screens.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.text
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.stoat.R
import chat.stoat.activities.StoatTweenColour
import chat.stoat.activities.StoatTweenFloat
import chat.stoat.activities.StoatTweenInt
import chat.stoat.activities.StoatTweenSize
import chat.stoat.api.StoatAPI
import chat.stoat.core.model.schemas.User
import chat.stoat.api.internals.formatCompactDuration
import chat.stoat.composables.generic.UserAvatar
import chat.stoat.core.model.data.STOAT_FILES
import chat.stoat.core.model.schemas.User
@Composable
fun StackedUserAvatars(
@ -66,7 +84,26 @@ fun StackedUserAvatars(
}
@Composable
fun TypingIndicator(users: List<String>, serverId: String?) {
fun TypingIndicator(
users: List<String>,
serverId: String?,
slowmodeSeconds: Long? = null,
slowmodeRemainingSeconds: Long = 0,
slowmodeImmune: Boolean = false,
) {
val slowmodeEnabled = slowmodeSeconds != null && slowmodeSeconds > 0
val slowmodeActive = !slowmodeImmune && slowmodeRemainingSeconds > 0
val idleSlowmodeColor = LocalContentColor.current
val slowmodeColor by animateColorAsState(
targetValue = if (slowmodeActive) {
MaterialTheme.colorScheme.primary
} else {
idleSlowmodeColor
},
animationSpec = StoatTweenColour,
label = "Slowmode color",
)
fun typingMessageResource(): Int {
return when (users.size) {
0 -> R.string.typing_blank
@ -77,7 +114,7 @@ fun TypingIndicator(users: List<String>, serverId: String?) {
}
AnimatedVisibility(
visible = users.isNotEmpty(),
visible = users.isNotEmpty() || slowmodeEnabled,
enter = slideInVertically(
animationSpec = StoatTweenInt,
initialOffsetY = { it }
@ -97,26 +134,163 @@ fun TypingIndicator(users: List<String>, serverId: String?) {
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.9f))
.padding(vertical = 8.dp, horizontal = 16.dp)
.padding(vertical = 8.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
StackedUserAvatars(users = users, serverId = serverId)
if (users.isNotEmpty()) {
StackedUserAvatars(users = users, serverId = serverId)
Text(
text = stringResource(
id = typingMessageResource(),
users.joinToString { userId ->
StoatAPI.userCache[userId]?.let { u ->
val maybeMember =
serverId?.let { StoatAPI.members.getMember(serverId, userId) }
Text(
text = stringResource(
id = typingMessageResource(),
users.joinToString { userId ->
StoatAPI.userCache[userId]?.let { u ->
val maybeMember =
serverId?.let { StoatAPI.members.getMember(serverId, userId) }
maybeMember?.nickname ?: User.resolveDefaultName(u)
} ?: userId
maybeMember?.nickname ?: User.resolveDefaultName(u)
} ?: userId
}
),
modifier = Modifier.weight(1f),
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
} else {
Spacer(Modifier.weight(1f))
}
if (slowmodeEnabled) {
if (users.isNotEmpty()) {
Spacer(Modifier.width(8.dp))
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(R.drawable.ic_timer_24dp),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = slowmodeColor,
)
if (slowmodeImmune) {
Text(
text = stringResource(R.string.slowmode_immune),
color = slowmodeColor,
fontSize = 12.sp,
maxLines = 1,
)
} else {
AnimatedSlowmodeDuration(
seconds = slowmodeRemainingSeconds.takeIf { it > 0 }
?: checkNotNull(slowmodeSeconds),
color = slowmodeColor,
)
}
),
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@Composable
private fun AnimatedSlowmodeDuration(
seconds: Long,
color: Color,
) {
val formattedDuration = formatCompactDuration(seconds)
AnimatedContent(
targetState = seconds,
modifier = Modifier
.clearAndSetSemantics {
text = AnnotatedString(formattedDuration)
},
contentAlignment = Alignment.CenterEnd,
contentKey = { formatCompactDuration(it).length },
transitionSpec = {
val movement = if (targetState > initialState) {
(slideInVertically(StoatTweenInt) { -it } + fadeIn(StoatTweenFloat))
.togetherWith(
slideOutVertically(StoatTweenInt) { it } +
fadeOut(StoatTweenFloat)
)
} else {
(slideInVertically(StoatTweenInt) { it } + fadeIn(StoatTweenFloat))
.togetherWith(
slideOutVertically(StoatTweenInt) { -it } +
fadeOut(StoatTweenFloat)
)
}
movement.using(
SizeTransform(clip = false) { _, _ -> StoatTweenSize }
)
},
label = "Slowmode duration width",
) { targetSeconds ->
Row {
formatCompactDuration(targetSeconds)
.mapIndexed { index, character ->
SlowmodeCharacter(
character = character,
totalSeconds = targetSeconds,
place = index,
)
}
.forEach { character ->
AnimatedContent(
targetState = character,
transitionSpec = {
if (targetState > initialState) {
slideInVertically(StoatTweenInt) { -it } togetherWith
slideOutVertically(StoatTweenInt) { it }
} else {
slideInVertically(StoatTweenInt) { it } togetherWith
slideOutVertically(StoatTweenInt) { -it }
}
},
label = "Slowmode character",
) { target ->
Text(
text = target.character.toString(),
color = color,
maxLines = 1,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFeatureSettings = "tnum",
),
)
}
}
}
}
}
private data class SlowmodeCharacter(
val character: Char,
val totalSeconds: Long,
val place: Int,
) {
override fun equals(other: Any?): Boolean {
return when (other) {
is SlowmodeCharacter -> character == other.character
else -> super.equals(other)
}
}
override fun hashCode(): Int {
var result = character.hashCode()
result = 31 * result + totalSeconds.hashCode()
result = 31 * result + place
return result
}
}
private operator fun SlowmodeCharacter.compareTo(other: SlowmodeCharacter): Int {
return totalSeconds.compareTo(other.totalSeconds)
}

View File

@ -6,6 +6,7 @@ import android.content.ContentValues
import android.content.res.Configuration
import android.net.Uri
import android.os.Environment
import android.os.SystemClock
import android.provider.MediaStore
import android.util.DisplayMetrics
import android.util.Log
@ -92,6 +93,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -239,6 +241,36 @@ fun ChannelScreen(
// </editor-fold>
// <editor-fold desc="Load/switch channel">
val channelPermissions by rememberChannelPermissions(channelId, viewModel.ensuredSelfMember)
val slowmodeSeconds = StoatAPI.channelCache[channelId]?.slowmode?.takeIf { it > 0 }
val slowmodeEnabled = slowmodeSeconds != null
val slowmodeImmune = channelPermissions has PermissionBit.BypassSlowmode
val activeSlowmode = StoatAPI.userSlowmodeCache[channelId]
var slowmodeNowMilliseconds by remember(channelId, activeSlowmode?.expiresAtMilliseconds) {
mutableLongStateOf(SystemClock.elapsedRealtime())
}
LaunchedEffect(
channelId,
activeSlowmode?.expiresAtMilliseconds,
slowmodeEnabled,
slowmodeImmune,
) {
if (!slowmodeEnabled || slowmodeImmune || activeSlowmode == null) {
return@LaunchedEffect
}
while (true) {
slowmodeNowMilliseconds = SystemClock.elapsedRealtime()
if (activeSlowmode.remainingSeconds(slowmodeNowMilliseconds) <= 0) break
delay(1_000)
}
}
val slowmodeRemainingSeconds =
activeSlowmode?.remainingSeconds(slowmodeNowMilliseconds) ?: 0
val slowmodeActive =
slowmodeEnabled && !slowmodeImmune && slowmodeRemainingSeconds > 0
LaunchedEffect(channelId) {
viewModel.switchChannel(channelId)
@ -439,7 +471,7 @@ fun ChannelScreen(
}
val scrollDownFABPadding by animateDpAsState(
if (viewModel.typingUsers.isNotEmpty()) 25.dp else 0.dp,
if (viewModel.typingUsers.isNotEmpty() || slowmodeEnabled) 25.dp else 0.dp,
animationSpec = StoatTweenDp,
label = "ScrollDownFABPadding"
)
@ -1018,7 +1050,10 @@ fun ChannelScreen(
)
TypingIndicator(
users = viewModel.typingUsers,
serverId = viewModel.channel?.server
serverId = viewModel.channel?.server,
slowmodeSeconds = slowmodeSeconds,
slowmodeRemainingSeconds = slowmodeRemainingSeconds,
slowmodeImmune = slowmodeImmune,
)
}
@ -1303,6 +1338,8 @@ fun ChannelScreen(
channelId = channelId,
failedValidation = viewModel.draftContent.length > 2000,
valueIsBlank = viewModel.draftContent.isBlank(),
sendEnabled =
viewModel.editingMessage != null || !slowmodeActive,
cancelEdit = {
viewModel.editingMessage = null
viewModel.putDraftContent("", true)

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M360,120L360,40L600,40L600,120L360,120ZM440,560L520,560L520,320L440,320L440,560ZM340.5,851.5Q275,823 226,774Q177,725 148.5,659.5Q120,594 120,520Q120,446 148.5,380.5Q177,315 226,266Q275,217 340.5,188.5Q406,160 480,160Q542,160 599,180Q656,200 706,238L762,182L818,238L762,294Q800,344 820,401Q840,458 840,520Q840,594 811.5,659.5Q783,725 734,774Q685,823 619.5,851.5Q554,880 480,880Q406,880 340.5,851.5ZM678,718Q760,636 760,520Q760,404 678,322Q596,240 480,240Q364,240 282,322Q200,404 200,520Q200,636 282,718Q364,800 480,800Q596,800 678,718ZM480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Z"/>
</vector>

View File

@ -121,6 +121,7 @@
<string name="typing_one">%1$s is typing…</string>
<string name="typing_many">%1$s are typing…</string>
<string name="typing_several">Several people are typing</string>
<string name="slowmode_immune">Immune</string>
<string name="send_alt">Send</string>
<string name="pick_emoji_alt">Pick emoji</string>

View File

@ -77,6 +77,7 @@ data class Channel(
val defaultPermissions: PermissionDescription? = null,
val nsfw: Boolean? = null,
val voice: JsonElement? = null,
val slowmode: Long? = null,
val type: String? = null // this is _only_ used for websocket events!
) {
fun mergeWithPartial(partial: Channel): Channel {
@ -96,11 +97,21 @@ data class Channel(
rolePermissions = partial.rolePermissions ?: rolePermissions,
defaultPermissions = partial.defaultPermissions ?: defaultPermissions,
nsfw = partial.nsfw ?: nsfw,
slowmode = partial.slowmode ?: slowmode,
type = partial.type ?: type
)
}
}
@Serializable
data class ChannelSlowmode(
@SerialName("channel_id")
val channelId: String,
val duration: Long,
@SerialName("retry_after")
val retryAfter: Long
)
@Serializable
enum class ChannelType(val value: String) {
DirectMessage("DirectMessage"),