feat: support for slow mode
This commit is contained in:
parent
12e71c1bc6
commit
8ae4654a09
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue