diff --git a/app/src/main/java/chat/stoat/api/StoatAPI.kt b/app/src/main/java/chat/stoat/api/StoatAPI.kt index 2d31b418..f006d50f 100644 --- a/app/src/main/java/chat/stoat/api/StoatAPI.kt +++ b/app/src/main/java/chat/stoat/api/StoatAPI.kt @@ -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() val messageCache = mutableStateMapOf() val voiceStateCache = mutableStateMapOf() + val userSlowmodeCache = mutableStateMapOf() val members = Members() @@ -274,6 +276,7 @@ object StoatAPI { channelCache.clear() emojiCache.clear() messageCache.clear() + userSlowmodeCache.clear() members.clear() unreads.clear() diff --git a/app/src/main/java/chat/stoat/api/internals/Permissions.kt b/app/src/main/java/chat/stoat/api/internals/Permissions.kt index 7547a5d3..f2243d23 100644 --- a/app/src/main/java/chat/stoat/api/internals/Permissions.kt +++ b/app/src/main/java/chat/stoat/api/internals/Permissions.kt @@ -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 diff --git a/app/src/main/java/chat/stoat/api/internals/Slowmode.kt b/app/src/main/java/chat/stoat/api/internals/Slowmode.kt new file mode 100644 index 00000000..5d82aea4 --- /dev/null +++ b/app/src/main/java/chat/stoat/api/internals/Slowmode.kt @@ -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" } +} diff --git a/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt b/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt index 27738260..41de7ade 100644 --- a/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/stoat/api/realtime/RealtimeSocket.kt @@ -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) diff --git a/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt b/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt index f8d7ad36..c7a25423 100644 --- a/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt +++ b/app/src/main/java/chat/stoat/api/realtime/frames/receivable/ReceivableFrames.kt @@ -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, +) + @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, -) \ No newline at end of file +) diff --git a/app/src/main/java/chat/stoat/composables/chat/MessageField.kt b/app/src/main/java/chat/stoat/composables/chat/MessageField.kt index b174b5a7..20274f39 100644 --- a/app/src/main/java/chat/stoat/composables/chat/MessageField.kt +++ b/app/src/main/java/chat/stoat/composables/chat/MessageField.kt @@ -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") diff --git a/app/src/main/java/chat/stoat/composables/screens/chat/TypingIndicator.kt b/app/src/main/java/chat/stoat/composables/screens/chat/TypingIndicator.kt index 21b55273..9bbb1063 100644 --- a/app/src/main/java/chat/stoat/composables/screens/chat/TypingIndicator.kt +++ b/app/src/main/java/chat/stoat/composables/screens/chat/TypingIndicator.kt @@ -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, serverId: String?) { +fun TypingIndicator( + users: List, + 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, serverId: String?) { } AnimatedVisibility( - visible = users.isNotEmpty(), + visible = users.isNotEmpty() || slowmodeEnabled, enter = slideInVertically( animationSpec = StoatTweenInt, initialOffsetY = { it } @@ -97,26 +134,163 @@ fun TypingIndicator(users: List, 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) +} diff --git a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt index 367164ea..396d8fb0 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/channel/ChannelScreen.kt @@ -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( // // 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) diff --git a/app/src/main/res/drawable/ic_timer_24dp.xml b/app/src/main/res/drawable/ic_timer_24dp.xml new file mode 100644 index 00000000..b4b68a1f --- /dev/null +++ b/app/src/main/res/drawable/ic_timer_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2516793f..16efe400 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -121,6 +121,7 @@ %1$s is typing… %1$s are typing… Several people are typing + Immune Send Pick emoji diff --git a/core/model/src/main/java/chat/stoat/core/model/schemas/Channel.kt b/core/model/src/main/java/chat/stoat/core/model/schemas/Channel.kt index 010d0896..70b0a687 100644 --- a/core/model/src/main/java/chat/stoat/core/model/schemas/Channel.kt +++ b/core/model/src/main/java/chat/stoat/core/model/schemas/Channel.kt @@ -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"),