From e91652cb788cd3773a7c9df5f9defeff7cc2518a Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 26 Apr 2025 00:58:50 +0200 Subject: [PATCH 01/12] chore: upgrade, uncomment livekit dep Signed-off-by: Infi --- app/build.gradle.kts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e65a0f92..9f5fe783 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,7 +27,7 @@ val hiltVersion = "2.52" val glideVersion = "4.16.0" val ktorVersion = "3.0.0-beta-2" val media3Version = "1.5.0" -val livekitVersion = "2.2.0" +val livekitVersion = "2.14.1" val material3Version = "1.4.0-alpha10" val androidXTestVersion = "1.6.1" @@ -288,8 +288,7 @@ dependencies { implementation("dev.snipme:highlights:1.0.0") // Livekit - // FIXME temporarily not included, re-add when realtime media is to be implemented - // implementation "io.livekit:livekit-android:$livekit_version" + implementation("io.livekit:livekit-android:$livekitVersion") // Firebase - Cloud Messaging implementation(platform("com.google.firebase:firebase-bom:33.9.0")) From 88619b044a4a10081b2bab9b42a3473864ffcaea Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 26 Apr 2025 00:59:01 +0200 Subject: [PATCH 02/12] feat: use livekit compatible root schema Signed-off-by: Infi --- .../java/chat/revolt/api/routes/misc/Root.kt | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/chat/revolt/api/routes/misc/Root.kt b/app/src/main/java/chat/revolt/api/routes/misc/Root.kt index 99b63a88..dfc22cd3 100644 --- a/app/src/main/java/chat/revolt/api/routes/misc/Root.kt +++ b/app/src/main/java/chat/revolt/api/routes/misc/Root.kt @@ -18,36 +18,48 @@ data class Root( @Serializable data class Features( - val captcha: CAPTCHA, + val captcha: CAPTCHAFeature, val email: Boolean, - - @SerialName("invite_only") - val inviteOnly: Boolean, - - val autumn: Autumn, - val january: Autumn, - val voso: Voso + @SerialName("invite_only") val inviteOnly: Boolean, + val autumn: AutumnJanuaryFeature, + val january: AutumnJanuaryFeature, + val voso: LegacyVoiceFeature? = null, + val livekit: LiveKitFeature? = null, ) @Serializable -data class Autumn( +data class AutumnJanuaryFeature( val enabled: Boolean, val url: String ) @Serializable -data class CAPTCHA( +data class CAPTCHAFeature( val enabled: Boolean, val key: String ) @Serializable -data class Voso( +data class LegacyVoiceFeature( val enabled: Boolean, val url: String, val ws: String ) +@Serializable +data class LiveKitFeature( + val enabled: Boolean, + val nodes: List +) + +@Serializable +data class LiveKitNode( + val name: String, + val lat: Double, + val lon: Double, + @SerialName("public_url") val publicUrl: String, +) + suspend fun getRootRoute(): Root { return RevoltHttp.get("/".api()).body() } From 12b08edaf08d0e77cb88fae1be9c415c86533c67 Mon Sep 17 00:00:00 2001 From: Infi Date: Sun, 27 Apr 2025 20:23:00 +0200 Subject: [PATCH 03/12] chore: add (temporary) api url property and LK compose components Signed-off-by: Infi --- app/build.gradle.kts | 16 ++++++++++++++-- app/src/main/java/chat/revolt/api/RevoltAPI.kt | 12 ++++++++---- revoltbuild.properties.example | 3 ++- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9f5fe783..6c93f4c8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,7 +27,12 @@ val hiltVersion = "2.52" val glideVersion = "4.16.0" val ktorVersion = "3.0.0-beta-2" val media3Version = "1.5.0" -val livekitVersion = "2.14.1" + +object LivekitVersion { + val core = "2.14.1" + val componentsCompose = "1.3.1" +} + val material3Version = "1.4.0-alpha10" val androidXTestVersion = "1.6.1" @@ -132,6 +137,12 @@ android { "FLAVOUR_ID", "\"${buildproperty("build.flavour_id", "RVX_BUILD_FLAVOUR_ID")}\"" ) + + buildConfigField( + "boolean", + "USE_ALPHA_API", + "${buildproperty("dev.use_alpha_api", "RVX_DEV_USE_ALPHA_API")}" + ) } } compileOptions { @@ -288,7 +299,8 @@ dependencies { implementation("dev.snipme:highlights:1.0.0") // Livekit - implementation("io.livekit:livekit-android:$livekitVersion") + implementation("io.livekit:livekit-android:${LivekitVersion.core}") + implementation("io.livekit:livekit-android-compose-components:${LivekitVersion.componentsCompose}") // Firebase - Cloud Messaging implementation(platform("com.google.firebase:firebase-bom:33.9.0")) diff --git a/app/src/main/java/chat/revolt/api/RevoltAPI.kt b/app/src/main/java/chat/revolt/api/RevoltAPI.kt index 0e5b66ee..c3937bd5 100644 --- a/app/src/main/java/chat/revolt/api/RevoltAPI.kt +++ b/app/src/main/java/chat/revolt/api/RevoltAPI.kt @@ -53,14 +53,18 @@ import kotlinx.serialization.json.Json import java.net.SocketException import chat.revolt.api.schemas.Channel as ChannelSchema -const val REVOLT_BASE = "https://api.revolt.chat/0.8" +val REVOLT_BASE = + if (BuildConfig.USE_ALPHA_API) "https://alpha.revolt.chat/api" else "https://api.revolt.chat/0.8" const val REVOLT_SUPPORT = "https://support.revolt.chat" const val REVOLT_MARKETING = "https://revolt.chat" -const val REVOLT_FILES = "https://cdn.revoltusercontent.com" -const val REVOLT_JANUARY = "https://jan.revolt.chat" +val REVOLT_FILES = + if (BuildConfig.USE_ALPHA_API) "https://alpha.revolt.chat/autumn" else "https://cdn.revoltusercontent.com" +val REVOLT_JANUARY = + if (BuildConfig.USE_ALPHA_API) "https://alpha.revolt.chat/january" else "https://jan.revolt.chat" const val REVOLT_APP = "https://app.revolt.chat" const val REVOLT_INVITES = "https://rvlt.gg" -const val REVOLT_WEBSOCKET = "wss://ws.revolt.chat" +val REVOLT_WEBSOCKET = + if (BuildConfig.USE_ALPHA_API) "wss://alpha.revolt.chat/ws" else "wss://ws.revolt.chat" const val REVOLT_KJBOOK = "https://revoltchat.github.io/android" fun String.api(): String { diff --git a/revoltbuild.properties.example b/revoltbuild.properties.example index ea6a101c..1ab8b495 100644 --- a/revoltbuild.properties.example +++ b/revoltbuild.properties.example @@ -1,4 +1,5 @@ sentry.dsn= sentry.upload_mappings=true build.debug.app_name= -build.flavour_id=ZZUU \ No newline at end of file +build.flavour_id=ZZUU +dev.use_alpha_api=false \ No newline at end of file From fd8d22b17034352a4be25cf456e088e9c642816c Mon Sep 17 00:00:00 2001 From: Infi Date: Sun, 27 Apr 2025 20:23:53 +0200 Subject: [PATCH 04/12] feat: early definition of ready event with voice state Signed-off-by: Infi --- .../chat/revolt/api/internals/Permissions.kt | 6 ++++-- .../revolt/api/realtime/RealtimeSocket.kt | 13 ++++++++---- .../frames/receivable/ReceivableFrames.kt | 4 +++- .../java/chat/revolt/api/schemas/Voice.kt | 20 +++++++++++++++++++ 4 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/chat/revolt/api/schemas/Voice.kt diff --git a/app/src/main/java/chat/revolt/api/internals/Permissions.kt b/app/src/main/java/chat/revolt/api/internals/Permissions.kt index a7e0cc7b..6c79e9c7 100644 --- a/app/src/main/java/chat/revolt/api/internals/Permissions.kt +++ b/app/src/main/java/chat/revolt/api/internals/Permissions.kt @@ -41,9 +41,10 @@ enum class PermissionBit(val value: Long) { MuteMembers(1L shl 33), DeafenMembers(1L shl 34), MoveMembers(1L shl 35), + Listen(1L shl 36), // * Misc. permissions - // % Bits 36 to 52: free area + // % Bits 37 to 52: free area // % Bits 53 to 64: do not use // * Grant all permissions @@ -85,7 +86,8 @@ object BitDefaults { PermissionBit.SendEmbeds + PermissionBit.UploadFiles + PermissionBit.Connect + - PermissionBit.Speak + PermissionBit.Speak + + PermissionBit.Listen val SavedMessages = PermissionBit.GrantAllSafe.value diff --git a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt index c31dd0c6..ec570cc8 100644 --- a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt @@ -50,6 +50,7 @@ import io.ktor.websocket.readText import io.ktor.websocket.send import kotlinx.coroutines.channels.consumeEach import kotlinx.serialization.SerializationException +import logcat.logcat enum class DisconnectionState { Disconnected, @@ -146,10 +147,14 @@ object RealtimeSocket { "Ready" -> { val readyFrame = RevoltJson.decodeFromString(ReadyFrame.serializer(), rawFrame) - Log.d( - "RealtimeSocket", - "Received ready frame with ${readyFrame.users.size} users, ${readyFrame.servers.size} servers, ${readyFrame.channels.size} channels, and ${readyFrame.emojis.size} emojis." - ) + + logcat { + "Received ready frame with ${readyFrame.users.size} users, " + + "${readyFrame.servers.size} servers, " + + "${readyFrame.channels.size} channels, " + + "${readyFrame.emojis.size} emojis, " + + "and ${readyFrame.voiceStates.size} voice states." + } Log.d("RealtimeSocket", "Adding users to cache.") val userMap = readyFrame.users.associateBy { it.id!! } diff --git a/app/src/main/java/chat/revolt/api/realtime/frames/receivable/ReceivableFrames.kt b/app/src/main/java/chat/revolt/api/realtime/frames/receivable/ReceivableFrames.kt index ad822047..93aa3541 100644 --- a/app/src/main/java/chat/revolt/api/realtime/frames/receivable/ReceivableFrames.kt +++ b/app/src/main/java/chat/revolt/api/realtime/frames/receivable/ReceivableFrames.kt @@ -1,6 +1,7 @@ package chat.revolt.api.realtime.frames.receivable import chat.revolt.api.schemas.Channel +import chat.revolt.api.schemas.ChannelVoiceState import chat.revolt.api.schemas.Embed import chat.revolt.api.schemas.Emoji import chat.revolt.api.schemas.Member @@ -42,7 +43,8 @@ data class ReadyFrame( val users: List, val servers: List, val channels: List, - val emojis: List + val emojis: List, + @SerialName("voice_states") val voiceStates: List, ) typealias MessageFrame = Message diff --git a/app/src/main/java/chat/revolt/api/schemas/Voice.kt b/app/src/main/java/chat/revolt/api/schemas/Voice.kt new file mode 100644 index 00000000..ba643d4d --- /dev/null +++ b/app/src/main/java/chat/revolt/api/schemas/Voice.kt @@ -0,0 +1,20 @@ +package chat.revolt.api.schemas + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ChannelVoiceState( + val id: String, + val participants: List, + val node: String, +) + +@Serializable +data class UserVoiceState( + val id: String, + @SerialName("is_receiving") val isReceiving: Boolean, + @SerialName("is_publishing") val isPublishing: Boolean, + val screensharing: Boolean, + val camera: Boolean, +) \ No newline at end of file From 2c233d30ca8b329cc2ee5de01ae0f3970f4b593c Mon Sep 17 00:00:00 2001 From: Infi Date: Sun, 27 Apr 2025 20:24:06 +0200 Subject: [PATCH 05/12] feat: join call route Signed-off-by: Infi --- .../chat/revolt/api/routes/voice/Voice.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 app/src/main/java/chat/revolt/api/routes/voice/Voice.kt diff --git a/app/src/main/java/chat/revolt/api/routes/voice/Voice.kt b/app/src/main/java/chat/revolt/api/routes/voice/Voice.kt new file mode 100644 index 00000000..9f9e5fdb --- /dev/null +++ b/app/src/main/java/chat/revolt/api/routes/voice/Voice.kt @@ -0,0 +1,35 @@ +package chat.revolt.api.routes.voice + +import chat.revolt.api.RevoltError +import chat.revolt.api.RevoltHttp +import chat.revolt.api.RevoltJson +import chat.revolt.api.api +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException + +@Serializable +data class JoinCallResponse( + val token: String, + val url: String, +) + +suspend fun joinCall(channelId: String, nodeName: String): JoinCallResponse { + val response = RevoltHttp.post("/channels/$channelId/join_call".api()) { + contentType(ContentType.Application.Json) + setBody(mapOf("node" to nodeName)) + }.bodyAsText() + + try { + val error = RevoltJson.decodeFromString(RevoltError.serializer(), response) + throw Exception(error.type) + } catch (e: SerializationException) { + // Not an error + } + + return RevoltJson.decodeFromString(JoinCallResponse.serializer(), response) +} \ No newline at end of file From cde0c808dcf3b0fb118add996b34a2499f0e13a5 Mon Sep 17 00:00:00 2001 From: Infi Date: Sun, 4 May 2025 02:02:18 +0200 Subject: [PATCH 06/12] fix: 1.3.1 was not uploaded to MavenCentral, so we use 1.3.0 Signed-off-by: Infi --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6c93f4c8..3f3d99d5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,7 +30,7 @@ val media3Version = "1.5.0" object LivekitVersion { val core = "2.14.1" - val componentsCompose = "1.3.1" + val componentsCompose = "1.3.0" } val material3Version = "1.4.0-alpha10" From 14522fd6f3cb6174a1340c894a952113e183b6d3 Mon Sep 17 00:00:00 2001 From: Infi Date: Sun, 4 May 2025 04:58:22 +0200 Subject: [PATCH 07/12] feat: basic implementation of voice channel overlay Signed-off-by: Infi --- .../chat/revolt/activities/MainActivity.kt | 546 +++++++++++------- .../revolt/screens/chat/ChatRouterScreen.kt | 15 +- .../chat/views/channel/ChannelScreen.kt | 140 +++-- 3 files changed, 415 insertions(+), 286 deletions(-) diff --git a/app/src/main/java/chat/revolt/activities/MainActivity.kt b/app/src/main/java/chat/revolt/activities/MainActivity.kt index 5e5254d2..63790cd4 100644 --- a/app/src/main/java/chat/revolt/activities/MainActivity.kt +++ b/app/src/main/java/chat/revolt/activities/MainActivity.kt @@ -13,18 +13,33 @@ import android.view.Menu import android.view.View import android.view.ViewTreeObserver import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.EaseInOutExpo import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -34,13 +49,21 @@ import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -394,6 +417,8 @@ val RevoltTweenColour: FiniteAnimationSpec = tween(400, easing = EaseInOu val NavTweenInt: FiniteAnimationSpec = tween(350, easing = EaseInOutExpo) val NavTweenFloat: FiniteAnimationSpec = tween(350, easing = EaseInOutExpo) +// This composable handles the main compose entrypoint of the app, provides the main navigation +// graph, and handles the animation and layout for the voice chat UI. @Composable fun AppEntrypoint( windowSizeClass: WindowSizeClass, @@ -408,251 +433,332 @@ fun AppEntrypoint( onRetryConnection: () -> Unit, onUpdateNextDestination: (String) -> Unit = {} ) { + var showVoiceUI by remember { mutableStateOf(false) } + val chatUIScale by animateFloatAsState( + if (showVoiceUI) 0.8f else 1.0f, + animationSpec = tween( + durationMillis = 300, + easing = EasingTokens.EmphasizedDecelerate + ) + ) + val chatUIOpacity by animateFloatAsState( + if (showVoiceUI) 0.8f else 1.0f, + animationSpec = tween( + durationMillis = 300, + easing = EasingTokens.EmphasizedDecelerate + ) + ) + + BackHandler(showVoiceUI) { + showVoiceUI = false + } + val navController = rememberNavController() RevoltTheme( requestedTheme = LoadedSettings.theme, colourOverrides = SyncedSettings.android.colourOverrides ) { - Surface( - modifier = Modifier - .fillMaxSize(), - color = MaterialTheme.colorScheme.background + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLowest) ) { - if (isHealthAlertActive) { - healthNotice?.let { - HealthAlert(notice = healthNotice, onDismiss = onDismissHealthAlert) - } - } - - if (couldNotLogIn) { - AlertDialog( - onDismissRequest = { - // no-op - }, - title = { - Text(stringResource(R.string.could_not_log_in_heading)) - }, - text = { - Text(stringResource(R.string.could_not_log_in_body)) - }, - confirmButton = { - TextButton( - onClick = { - onDismissLoginError() - onRetryConnection() - } - ) { - Text(stringResource(R.string.could_not_log_in_cta_try_again)) - } - }, - dismissButton = { - TextButton( - onClick = { - onDismissLoginError() - onLogout() - } - ) { - Text(stringResource(R.string.could_not_log_in_cta_logout)) - } - } - ) - } - - NavHost( - navController = navController, - startDestination = "default", - enterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = NavTweenInt, - initialOffset = { it / 3 } - ) + fadeIn(animationSpec = NavTweenFloat) - }, - exitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = NavTweenInt, - targetOffset = { it / 3 } - ) + fadeOut(animationSpec = NavTweenFloat) - }, - popEnterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = NavTweenInt, - initialOffset = { it / 3 } - ) + fadeIn(animationSpec = NavTweenFloat) - }, - popExitTransition = { - slideOutOfContainer( - AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = NavTweenInt, - targetOffset = { it / 2 } - ) + fadeOut(animationSpec = NavTweenFloat) - } + Surface( + modifier = Modifier + .fillMaxSize() + .scale(chatUIScale) + .alpha(chatUIOpacity), + color = MaterialTheme.colorScheme.background ) { - composable("default") { - DefaultDestinationScreen( - navController, - nextDestination, - isConnected, - onRetryConnection - ) + if (isHealthAlertActive) { + healthNotice?.let { + HealthAlert(notice = healthNotice, onDismiss = onDismissHealthAlert) + } } - composable("login/greeting") { LoginGreetingScreen(navController) } - composable("login/login") { LoginScreen(navController) } - composable("login/mfa/{mfaTicket}/{allowedAuthTypes}") { backStackEntry -> - val mfaTicket = backStackEntry.arguments?.getString("mfaTicket") ?: "" - val allowedAuthTypes = - backStackEntry.arguments?.getString("allowedAuthTypes") ?: "" - - MfaScreen(navController, allowedAuthTypes, mfaTicket) - } - - composable("register/greeting") { RegisterGreetingScreen(navController) } - composable("register/details") { RegisterDetailsScreen(navController) } - composable("register/verify/{email}") { backStackEntry -> - val email = backStackEntry.arguments?.getString("email") ?: "" - - RegisterVerifyScreen(navController, email) - } - composable("register/onboarding") { - OnboardingScreen( - navController, - onOnboardingComplete = { - onUpdateNextDestination("chat") - navController.popBackStack( - navController.graph.startDestinationRoute!!, - inclusive = true - ) - navController.navigate("default") + if (couldNotLogIn) { + AlertDialog( + onDismissRequest = { + // no-op + }, + title = { + Text(stringResource(R.string.could_not_log_in_heading)) + }, + text = { + Text(stringResource(R.string.could_not_log_in_body)) + }, + confirmButton = { + TextButton( + onClick = { + onDismissLoginError() + onRetryConnection() + } + ) { + Text(stringResource(R.string.could_not_log_in_cta_try_again)) + } + }, + dismissButton = { + TextButton( + onClick = { + onDismissLoginError() + onLogout() + } + ) { + Text(stringResource(R.string.could_not_log_in_cta_logout)) + } } ) } - composable("login2/init") { InitScreen(navController, windowSizeClass) } - - // This is only used outside of Polar mode - // Otherwise you may be looking for "main" right below - composable( - "chat", - enterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Up, - animationSpec = tween( - 400, - easing = EasingTokens.EmphasizedDecelerate - ), - initialOffset = { it / 3 } - ) + fadeIn(animationSpec = RevoltTweenFloat) - } - ) { - ChatRouterScreen( - navController, - windowSizeClass, - onNullifiedUser = { - onRetryConnection() - navController.popBackStack( - navController.graph.startDestinationRoute!!, - inclusive = true - ) - navController.navigate("default") - } - ) - } - - // This is only the main screen in Polar mode - // Otherwise you may be looking for "chat" right above - composable( - "main", - enterTransition = { - slideIntoContainer( - AnimatedContentTransitionScope.SlideDirection.Up, - animationSpec = tween( - 400, - easing = EasingTokens.EmphasizedDecelerate - ), - initialOffset = { it / 3 } - ) + fadeIn(animationSpec = RevoltTweenFloat) + scaleIn( - animationSpec = tween( - 400, - easing = EasingTokens.EmphasizedDecelerate - ), - initialScale = 0.8f, - transformOrigin = TransformOrigin.Center - ) - } - ) { - MainScreen(navController) - } - composable( - "main/conversation/{channelId}", + NavHost( + navController = navController, + startDestination = "default", enterTransition = { slideIntoContainer( AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween( - 600, - easing = EasingTokens.EmphasizedDecelerate - ), - initialOffset = { it } - ) + fadeIn(animationSpec = RevoltTweenFloat) + animationSpec = NavTweenInt, + initialOffset = { it / 3 } + ) + fadeIn(animationSpec = NavTweenFloat) }, exitTransition = { slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = NavTweenInt, + targetOffset = { it / 3 } + ) + fadeOut(animationSpec = NavTweenFloat) + }, + popEnterTransition = { + slideIntoContainer( AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween( - 600, - easing = EasingTokens.EmphasizedDecelerate - ), - targetOffset = { it } - ) + fadeOut(animationSpec = RevoltTweenFloat) + animationSpec = NavTweenInt, + initialOffset = { it / 3 } + ) + fadeIn(animationSpec = NavTweenFloat) + }, + popExitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = NavTweenInt, + targetOffset = { it / 2 } + ) + fadeOut(animationSpec = NavTweenFloat) } - ) { backStackEntry -> - val channelId = backStackEntry.arguments?.getString("channelId") ?: "" - ChannelScreen( - channelId = channelId, - onToggleDrawer = {}, - useDrawer = false, - useBackButton = true, - backButtonAction = { - navController.popBackStack() + ) { + composable("default") { + DefaultDestinationScreen( + navController, + nextDestination, + isConnected, + onRetryConnection + ) + } + + composable("login/greeting") { LoginGreetingScreen(navController) } + composable("login/login") { LoginScreen(navController) } + composable("login/mfa/{mfaTicket}/{allowedAuthTypes}") { backStackEntry -> + val mfaTicket = backStackEntry.arguments?.getString("mfaTicket") ?: "" + val allowedAuthTypes = + backStackEntry.arguments?.getString("allowedAuthTypes") ?: "" + + MfaScreen(navController, allowedAuthTypes, mfaTicket) + } + + composable("register/greeting") { RegisterGreetingScreen(navController) } + composable("register/details") { RegisterDetailsScreen(navController) } + composable("register/verify/{email}") { backStackEntry -> + val email = backStackEntry.arguments?.getString("email") ?: "" + + RegisterVerifyScreen(navController, email) + } + composable("register/onboarding") { + OnboardingScreen( + navController, + onOnboardingComplete = { + onUpdateNextDestination("chat") + navController.popBackStack( + navController.graph.startDestinationRoute!!, + inclusive = true + ) + navController.navigate("default") + } + ) + } + + composable("login2/init") { InitScreen(navController, windowSizeClass) } + + // This is only used outside of Polar mode + // Otherwise you may be looking for "main" right below + composable( + "chat", + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Up, + animationSpec = tween( + 400, + easing = EasingTokens.EmphasizedDecelerate + ), + initialOffset = { it / 3 } + ) + fadeIn(animationSpec = RevoltTweenFloat) + } + ) { + ChatRouterScreen( + navController, + windowSizeClass, + disableBackHandler = showVoiceUI, + onNullifiedUser = { + onRetryConnection() + navController.popBackStack( + navController.graph.startDestinationRoute!!, + inclusive = true + ) + navController.navigate("default") + }, + onEnterVoiceUI = { + showVoiceUI = true + }, + ) + } + + // This is only the main screen in Polar mode + // Otherwise you may be looking for "chat" right above + composable( + "main", + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Up, + animationSpec = tween( + 400, + easing = EasingTokens.EmphasizedDecelerate + ), + initialOffset = { it / 3 } + ) + fadeIn(animationSpec = RevoltTweenFloat) + scaleIn( + animationSpec = tween( + 400, + easing = EasingTokens.EmphasizedDecelerate + ), + initialScale = 0.8f, + transformOrigin = TransformOrigin.Center + ) + } + ) { + MainScreen(navController) + } + composable( + "main/conversation/{channelId}", + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween( + 600, + easing = EasingTokens.EmphasizedDecelerate + ), + initialOffset = { it } + ) + fadeIn(animationSpec = RevoltTweenFloat) }, - useChatUI = true + exitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween( + 600, + easing = EasingTokens.EmphasizedDecelerate + ), + targetOffset = { it } + ) + fadeOut(animationSpec = RevoltTweenFloat) + } + ) { backStackEntry -> + val channelId = backStackEntry.arguments?.getString("channelId") ?: "" + ChannelScreen( + channelId = channelId, + onToggleDrawer = {}, + useDrawer = false, + useBackButton = true, + backButtonAction = { + navController.popBackStack() + }, + useChatUI = true + ) + } + + composable("create/group") { CreateGroupScreen(navController) } + + composable("discover") { DiscoverScreen(navController) } + + composable("settings") { SettingsScreen(navController) } + composable("settings/profile") { ProfileSettingsScreen(navController) } + composable("settings/sessions") { SessionSettingsScreen(navController) } + composable("settings/appearance") { AppearanceSettingsScreen(navController) } + composable("settings/chat") { ChatSettingsScreen(navController) } + composable("settings/debug") { DebugSettingsScreen(navController) } + composable("settings/experiments") { ExperimentsSettingsScreen(navController) } + composable("settings/changelogs") { ChangelogsSettingsScreen(navController) } + composable("settings/language") { LanguagePickerSettingsScreen(navController) } + + composable("settings/channel/{channelId}") { backStackEntry -> + val channelId = backStackEntry.arguments?.getString("channelId") ?: "" + ChannelSettingsHome(navController, channelId) + } + composable("settings/channel/{channelId}/overview") { backStackEntry -> + val channelId = backStackEntry.arguments?.getString("channelId") ?: "" + ChannelSettingsOverview(navController, channelId) + } + composable("settings/channel/{channelId}/permissions") { backStackEntry -> + val channelId = backStackEntry.arguments?.getString("channelId") ?: "" + ChannelSettingsPermissions(navController, channelId) + } + + composable("about") { AboutScreen(navController) } + composable("about/oss") { AttributionScreen(navController) } + + composable("labs") { LabsRootScreen(navController) } + } + } + + if (showVoiceUI) { // if tapped outside the voice UI, close it + Box( + Modifier + .fillMaxSize() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + showVoiceUI = false + } + ) + } + + AnimatedVisibility( + visible = showVoiceUI, + modifier = Modifier.align(Alignment.BottomCenter), + enter = slideInVertically( + initialOffsetY = { it -> it }, + animationSpec = tween( + durationMillis = 300, + easing = EasingTokens.EmphasizedDecelerate ) + ), + exit = slideOutVertically( + targetOffsetY = { it -> it }, + animationSpec = tween( + durationMillis = 300, + easing = EasingTokens.EmphasizedDecelerate + ) + ) + ) { + // We need a box as applying the padding elsewhere leads to either + // janky animation or layout + Box(Modifier.safeDrawingPadding()) { + Card( + Modifier + .fillMaxWidth() + .widthIn(max = 600.dp) + .padding(8.dp) + ) { + Button(onClick = { + showVoiceUI = false + }) { + Text("Close voice UI") + } + } } - - composable("create/group") { CreateGroupScreen(navController) } - - composable("discover") { DiscoverScreen(navController) } - - composable("settings") { SettingsScreen(navController) } - composable("settings/profile") { ProfileSettingsScreen(navController) } - composable("settings/sessions") { SessionSettingsScreen(navController) } - composable("settings/appearance") { AppearanceSettingsScreen(navController) } - composable("settings/chat") { ChatSettingsScreen(navController) } - composable("settings/debug") { DebugSettingsScreen(navController) } - composable("settings/experiments") { ExperimentsSettingsScreen(navController) } - composable("settings/changelogs") { ChangelogsSettingsScreen(navController) } - composable("settings/language") { LanguagePickerSettingsScreen(navController) } - - composable("settings/channel/{channelId}") { backStackEntry -> - val channelId = backStackEntry.arguments?.getString("channelId") ?: "" - ChannelSettingsHome(navController, channelId) - } - composable("settings/channel/{channelId}/overview") { backStackEntry -> - val channelId = backStackEntry.arguments?.getString("channelId") ?: "" - ChannelSettingsOverview(navController, channelId) - } - composable("settings/channel/{channelId}/permissions") { backStackEntry -> - val channelId = backStackEntry.arguments?.getString("channelId") ?: "" - ChannelSettingsPermissions(navController, channelId) - } - - composable("about") { AboutScreen(navController) } - composable("about/oss") { AttributionScreen(navController) } - - composable("labs") { LabsRootScreen(navController) } } } } diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index 8678e753..d9467596 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -266,7 +266,9 @@ class ChatRouterViewModel @Inject constructor( fun ChatRouterScreen( topNav: NavController, windowSizeClass: WindowSizeClass, + disableBackHandler: Boolean, onNullifiedUser: () -> Unit, + onEnterVoiceUI: () -> Unit, viewModel: ChatRouterViewModel = hiltViewModel() ) { val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -848,9 +850,11 @@ fun ChatRouterScreen( dest = viewModel.currentDestination, topNav = topNav, useDrawer = false, + disableBackHandler = disableBackHandler, toggleDrawer = { toggleDrawerLambda() - } + }, + onEnterVoiceUI = onEnterVoiceUI, ) } } else { @@ -891,6 +895,7 @@ fun ChatRouterScreen( dest = viewModel.currentDestination, topNav = topNav, useDrawer = true, + disableBackHandler = disableBackHandler, toggleDrawer = { toggleDrawerLambda() }, @@ -898,7 +903,8 @@ fun ChatRouterScreen( drawerGestureEnabled = useSidebarGesture, setDrawerGestureEnabled = { useSidebarGesture = it - } + }, + onEnterVoiceUI = onEnterVoiceUI, ) } } @@ -942,11 +948,13 @@ fun ChannelNavigator( toggleDrawer: () -> Unit, drawerState: DrawerState? = null, drawerGestureEnabled: Boolean = true, + disableBackHandler: Boolean = false, + onEnterVoiceUI: () -> Unit = {}, setDrawerGestureEnabled: (Boolean) -> Unit = {}, ) { val scope = rememberCoroutineScope() - BackHandler(enabled = useDrawer) { + BackHandler(useDrawer && !disableBackHandler) { toggleDrawer() } @@ -984,6 +992,7 @@ fun ChannelNavigator( drawerGestureEnabled = drawerGestureEnabled, setDrawerGestureEnabled = setDrawerGestureEnabled, drawerIsOpen = drawerState?.isOpen == true, + onEnterVoiceUI = onEnterVoiceUI, ) } diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index 1825658e..b29f6cb8 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -61,7 +61,6 @@ import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DrawerState import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -95,7 +94,6 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.Placeholder @@ -200,6 +198,7 @@ fun ChannelScreen( drawerIsOpen: Boolean = false, backButtonAction: (() -> Unit)? = null, useChatUI: Boolean = false, + onEnterVoiceUI: () -> Unit = {}, viewModel: ChannelScreenViewModel = hiltViewModel() ) { // @@ -848,74 +847,89 @@ fun ChannelScreen( } } - if (viewModel.showPhysicalKeyboardSpark) { - Card( - modifier = Modifier - .align(Alignment.TopCenter) - .padding(8.dp) - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(16.dp) - ) { - Text( - stringResource(R.string.spark_keyboard_shortcuts), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold - ) - Text( - buildAnnotatedString { - val raw = - stringResource(R.string.spark_keyboard_shortcuts_description) - val before = raw.substringBefore("%1\$s") - val after = raw.substringAfter("%1\$s") - - append(before) - appendInlineContent("metaKey", "Meta") - append(" + /") - append(after) - }, - inlineContent = mapOf( - "metaKey" to InlineTextContent( - placeholder = Placeholder( - width = 1.em, - height = 1.em, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ) - ) { - with(LocalDensity.current) { - Image( - painterResource(R.drawable.ic_meta_key_24dp), - contentDescription = null, - /*modifier = Modifier.size(1.em.toDp())*/ - ) - } - } - ), - style = MaterialTheme.typography.bodyLarge - ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(8.dp) + ) { + if (viewModel.showPhysicalKeyboardSpark) { + Card { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(16.dp) ) { - Button( - onClick = { - viewModel.dismissPhysicalKeyboardSpark() + Text( + stringResource(R.string.spark_keyboard_shortcuts), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + buildAnnotatedString { + val raw = + stringResource(R.string.spark_keyboard_shortcuts_description) + val before = raw.substringBefore("%1\$s") + val after = raw.substringAfter("%1\$s") + + append(before) + appendInlineContent("metaKey", "Meta") + append(" + /") + append(after) }, - modifier = Modifier.weight(1f) + inlineContent = mapOf( + "metaKey" to InlineTextContent( + placeholder = Placeholder( + width = 1.em, + height = 1.em, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + with(LocalDensity.current) { + Image( + painterResource(R.drawable.ic_meta_key_24dp), + contentDescription = null, + /*modifier = Modifier.size(1.em.toDp())*/ + ) + } + } + ), + style = MaterialTheme.typography.bodyLarge + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(stringResource(R.string.spark_keyboard_shortcuts_dismiss)) - } - TextButton( - onClick = { - (context as Activity).requestShowKeyboardShortcuts() - }, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(R.string.spark_keyboard_shortcuts_cta)) + Button( + onClick = { + viewModel.dismissPhysicalKeyboardSpark() + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.spark_keyboard_shortcuts_dismiss)) + } + TextButton( + onClick = { + (context as Activity).requestShowKeyboardShortcuts() + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.spark_keyboard_shortcuts_cta)) + } } } } } + + if (viewModel.channel?.channelType == ChannelType.VoiceChannel) { + Button( + onClick = { + onEnterVoiceUI() + }, + modifier = Modifier + .fillMaxWidth() + ) { + Text("Join Voice Channel") + } + } } } From 9563ea6cce9307e41c526870028dde839ed165c7 Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 17 May 2025 09:18:48 +0200 Subject: [PATCH 08/12] fix: tint meta key with content colour in physical keyboard spark Signed-off-by: Infi --- .../chat/revolt/screens/chat/views/channel/ChannelScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index b29f6cb8..c7261beb 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -91,6 +91,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -888,7 +889,9 @@ fun ChannelScreen( Image( painterResource(R.drawable.ic_meta_key_24dp), contentDescription = null, - /*modifier = Modifier.size(1.em.toDp())*/ + colorFilter = ColorFilter.tint( + LocalContentColor.current + ) ) } } From ade9e03a9d6a3c5c904c45416dd11d5a626c931a Mon Sep 17 00:00:00 2001 From: Infi Date: Thu, 29 May 2025 20:03:14 +0200 Subject: [PATCH 09/12] fix: deduplicate messages during grouping Signed-off-by: Infi --- .../chat/views/channel/ChannelScreenViewModel.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt index 1e41e22e..633b083f 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreenViewModel.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.util.fastDistinctBy import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import chat.revolt.R @@ -876,7 +877,14 @@ class ChannelScreenViewModel @Inject constructor( withContext(Dispatchers.Main) { items.clear() - items.addAll(groupedItems) + items.addAll(groupedItems.fastDistinctBy { + when (it) { + is ChannelScreenItem.RegularMessage -> it.message.id + is ChannelScreenItem.SystemMessage -> it.message.id + is ChannelScreenItem.DateDivider -> it.instant.toString() + else -> it.toString() // Fallback for other item types + } + }) } } From 2b752fefbcb9cd65c2a978570ebf7aa299a885dd Mon Sep 17 00:00:00 2001 From: Infi Date: Thu, 29 May 2025 21:32:21 +0200 Subject: [PATCH 10/12] feat: full edge to edge in chat router Signed-off-by: Infi --- .../main/java/chat/revolt/api/RevoltAPI.kt | 14 +- .../frames/receivable/ReceivableFrames.kt | 2 +- .../screens/chat/drawer/ChannelSideDrawer.kt | 224 ++++++++++------- .../revolt/screens/chat/ChatRouterScreen.kt | 183 ++++++++------ .../screens/chat/views/FriendsScreen.kt | 125 ++++++---- .../chat/views/NoCurrentChannelScreen.kt | 45 ++-- .../screens/chat/views/OverviewScreen.kt | 42 ++-- .../chat/views/channel/ChannelScreen.kt | 235 +++++++++--------- 8 files changed, 508 insertions(+), 362 deletions(-) diff --git a/app/src/main/java/chat/revolt/api/RevoltAPI.kt b/app/src/main/java/chat/revolt/api/RevoltAPI.kt index c3937bd5..3fd34cb3 100644 --- a/app/src/main/java/chat/revolt/api/RevoltAPI.kt +++ b/app/src/main/java/chat/revolt/api/RevoltAPI.kt @@ -53,18 +53,20 @@ import kotlinx.serialization.json.Json import java.net.SocketException import chat.revolt.api.schemas.Channel as ChannelSchema +private const val USE_ALPHA_API = false + val REVOLT_BASE = - if (BuildConfig.USE_ALPHA_API) "https://alpha.revolt.chat/api" else "https://api.revolt.chat/0.8" + if (USE_ALPHA_API) "https://alpha.revolt.chat/api" else "https://api.revolt.chat/0.8" const val REVOLT_SUPPORT = "https://support.revolt.chat" const val REVOLT_MARKETING = "https://revolt.chat" val REVOLT_FILES = - if (BuildConfig.USE_ALPHA_API) "https://alpha.revolt.chat/autumn" else "https://cdn.revoltusercontent.com" + if (USE_ALPHA_API) "https://alpha.revolt.chat/autumn" else "https://cdn.revoltusercontent.com" val REVOLT_JANUARY = - if (BuildConfig.USE_ALPHA_API) "https://alpha.revolt.chat/january" else "https://jan.revolt.chat" + if (USE_ALPHA_API) "https://alpha.revolt.chat/january" else "https://jan.revolt.chat" const val REVOLT_APP = "https://app.revolt.chat" const val REVOLT_INVITES = "https://rvlt.gg" val REVOLT_WEBSOCKET = - if (BuildConfig.USE_ALPHA_API) "wss://alpha.revolt.chat/ws" else "wss://ws.revolt.chat" + if (USE_ALPHA_API) "wss://alpha.revolt.chat/ws" else "wss://ws.revolt.chat" const val REVOLT_KJBOOK = "https://revoltchat.github.io/android" fun String.api(): String { @@ -72,7 +74,9 @@ fun String.api(): String { } fun buildUserAgent(accessMethod: String = "Ktor"): String { - return "$accessMethod RevoltAndroid/${BuildConfig.VERSION_NAME} ${BuildConfig.APPLICATION_ID} (Android ${android.os.Build.VERSION.SDK_INT}; ${android.os.Build.MANUFACTURER} ${android.os.Build.DEVICE}; (Kotlin ${KotlinVersion.CURRENT})" + return "$accessMethod RevoltAndroid/${BuildConfig.VERSION_NAME} " + + "${BuildConfig.APPLICATION_ID} Android/${android.os.Build.VERSION.SDK_INT} " + + "(${android.os.Build.MANUFACTURER} ${android.os.Build.DEVICE}) Kotlin/${KotlinVersion.CURRENT}" } private const val BACKEND_IS_STABLE = false diff --git a/app/src/main/java/chat/revolt/api/realtime/frames/receivable/ReceivableFrames.kt b/app/src/main/java/chat/revolt/api/realtime/frames/receivable/ReceivableFrames.kt index 93aa3541..a6d7dbdc 100644 --- a/app/src/main/java/chat/revolt/api/realtime/frames/receivable/ReceivableFrames.kt +++ b/app/src/main/java/chat/revolt/api/realtime/frames/receivable/ReceivableFrames.kt @@ -44,7 +44,7 @@ data class ReadyFrame( val servers: List, val channels: List, val emojis: List, - @SerialName("voice_states") val voiceStates: List, + @SerialName("voice_states") val voiceStates: List = listOf(), ) typealias MessageFrame = Message diff --git a/app/src/main/java/chat/revolt/composables/screens/chat/drawer/ChannelSideDrawer.kt b/app/src/main/java/chat/revolt/composables/screens/chat/drawer/ChannelSideDrawer.kt index 75442216..4df73658 100644 --- a/app/src/main/java/chat/revolt/composables/screens/chat/drawer/ChannelSideDrawer.kt +++ b/app/src/main/java/chat/revolt/composables/screens/chat/drawer/ChannelSideDrawer.kt @@ -1,7 +1,9 @@ package chat.revolt.composables.screens.chat.drawer +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring @@ -28,6 +30,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -71,6 +74,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight 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 androidx.navigation.NavController @@ -96,6 +100,7 @@ import chat.revolt.composables.generic.UserAvatar import chat.revolt.composables.generic.presenceFromStatus import chat.revolt.composables.screens.chat.ChannelIcon import chat.revolt.screens.chat.ChatRouterDestination +import chat.revolt.screens.chat.LocalIsConnected import chat.revolt.sheets.ChannelContextSheet import kotlinx.coroutines.launch @@ -154,6 +159,17 @@ fun ChannelSideDrawer( ), label = "Server banner height" ) + val serverInfoOffset by animateDpAsState( + if (LocalIsConnected.current) + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + else + 0.dp, + animationSpec = spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Dp.VisibilityThreshold + ) + ) + // - Take the list of servers and filter them by the ones that are in the ordering. // - Sort the servers that are in the ordering using the ordering. // - Add the servers that aren't in the ordering to the end of the list. @@ -202,30 +218,40 @@ fun ChannelSideDrawer( ) ) { stickyHeader(key = "self") { - UserAvatar( - username = RevoltAPI.userCache[RevoltAPI.selfId]?.let { - User.resolveDefaultName( - it + Column(Modifier.background(MaterialTheme.colorScheme.background)) { + AnimatedVisibility(LocalIsConnected.current) { + Spacer( + Modifier + .height( + WindowInsets.statusBars.asPaddingValues() + .calculateTopPadding() + ) ) } - ?: "", - presence = presenceFromStatus( - RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence, - RevoltAPI.userCache[RevoltAPI.selfId]?.online ?: false - ), - userId = RevoltAPI.selfId ?: "", - avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar, - size = 48.dp, - presenceSize = 16.dp, - onClick = { - onDestinationChanged(ChatRouterDestination.defaultForDMList) - }, - onLongClick = onLongPressAvatar, - modifier = Modifier - .background(MaterialTheme.colorScheme.background) - .padding(8.dp) - .size(48.dp) - ) + UserAvatar( + username = RevoltAPI.userCache[RevoltAPI.selfId]?.let { + User.resolveDefaultName( + it + ) + } + ?: "", + presence = presenceFromStatus( + RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence, + RevoltAPI.userCache[RevoltAPI.selfId]?.online ?: false + ), + userId = RevoltAPI.selfId ?: "", + avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar, + size = 48.dp, + presenceSize = 16.dp, + onClick = { + onDestinationChanged(ChatRouterDestination.defaultForDMList) + }, + onLongClick = onLongPressAvatar, + modifier = Modifier + .padding(8.dp) + .size(48.dp) + ) + } } items( @@ -314,12 +340,12 @@ fun ChannelSideDrawer( ) val leftIndicatorColour = animateColorAsState( targetValue = - if (serverInList.id == currentServer) - MaterialTheme.colorScheme.primary - else if (serverHasUnread) - MaterialTheme.colorScheme.onSurfaceVariant - else - Color.Transparent, + if (serverInList.id == currentServer) + MaterialTheme.colorScheme.primary + else if (serverHasUnread) + MaterialTheme.colorScheme.onSurfaceVariant + else + Color.Transparent, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow @@ -433,27 +459,30 @@ fun ChannelSideDrawer( } Column( Modifier - .clip( - MaterialTheme.shapes.extraLarge.copy( - bottomEnd = CornerSize(0) - ) - ) .background(MaterialTheme.colorScheme.surfaceContainer) .weight(1f) .fillMaxHeight() ) { - Box(Modifier.height(serverBannerHeight)) { + Box( + Modifier + .clip( + MaterialTheme.shapes.medium.copy( + topStart = CornerSize(0.dp), + topEnd = CornerSize(0.dp) + ) + ) + .height( + serverBannerHeight + WindowInsets.statusBars.asPaddingValues() + .calculateTopPadding() + ) + //.offset(y = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) + ) { if (server?.banner != null) { RemoteImage( url = "$REVOLT_FILES/banners/${server.banner.id}", description = null, contentScale = ContentScale.Crop, modifier = Modifier - .clip( - MaterialTheme.shapes.medium.copy( - topStart = CornerSize(0), topEnd = CornerSize(0) - ) - ) .fillMaxSize() ) @@ -465,7 +494,7 @@ fun ChannelSideDrawer( drawRect( Brush.linearGradient( listOf( - surfaceContainer.copy(alpha = 0.8f), + Color.Black.copy(alpha = 0.6f), Color.Transparent ), Offset.Zero, @@ -477,63 +506,72 @@ fun ChannelSideDrawer( } Row( - Modifier.padding(16.dp), + Modifier + .padding(16.dp) + .offset(y = serverInfoOffset), verticalAlignment = Alignment.CenterVertically ) { - Row( - Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + CompositionLocalProvider( + LocalContentColor provides + if (server?.banner != null) Color.White + else LocalContentColor.current ) { - if (server?.flags has ServerFlags.Official) { - Icon( - painter = painterResource( - id = R.drawable.ic_revolt_decagram_24dp - ), - contentDescription = stringResource( - R.string.server_flag_official - ), - tint = LocalContentColor.current, - modifier = Modifier - .size(24.dp) - ) - } - if (server?.flags has ServerFlags.Verified) { - Icon( - painter = painterResource( - id = R.drawable.ic_check_decagram_24dp - ), - contentDescription = stringResource( - R.string.server_flag_verified - ), - tint = LocalContentColor.current, - modifier = Modifier - .size(24.dp) + Row( + Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (server?.flags has ServerFlags.Official) { + Icon( + painter = painterResource( + id = R.drawable.ic_revolt_decagram_24dp + ), + contentDescription = stringResource( + R.string.server_flag_official + ), + tint = LocalContentColor.current, + modifier = Modifier + .size(24.dp) + ) + } + if (server?.flags has ServerFlags.Verified) { + Icon( + painter = painterResource( + id = R.drawable.ic_check_decagram_24dp + ), + contentDescription = stringResource( + R.string.server_flag_verified + ), + tint = LocalContentColor.current, + modifier = Modifier + .size(24.dp) + ) + } + + Text( + text = when (currentServer) { + null -> stringResource(R.string.direct_messages) + else -> server?.name ?: stringResource(R.string.unknown) + }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } - Text( - text = when (currentServer) { - null -> stringResource(R.string.direct_messages) - else -> server?.name ?: stringResource(R.string.unknown) - }, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - if (currentServer != null) { - IconButton(onClick = { - server?.id?.let { srvId -> onShowServerContextSheet(srvId) } - }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.menu) - ) + if (currentServer != null) { + IconButton(onClick = { + server?.id?.let { srvId -> onShowServerContextSheet(srvId) } + }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.menu), + tint = LocalContentColor.current + ) + } + } else { + Spacer(Modifier.height(64.dp)) } - } else { - Spacer(Modifier.height(64.dp)) } } } @@ -762,7 +800,8 @@ fun ColumnScope.ServerChannelListRenderer( items(categorisedChannels?.size ?: 0) { when (val channelOrCat = categorisedChannels?.get(it)) { is CategorisedChannelList.Channel -> { - ChannelItem(channel = channelOrCat.channel, + ChannelItem( + channel = channelOrCat.channel, isCurrent = when (currentDestination) { is ChatRouterDestination.Channel -> { currentDestination.channelId == channelOrCat.channel.id @@ -840,7 +879,8 @@ fun ChannelItem( } } ) { - Row(verticalAlignment = Alignment.CenterVertically, + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start), modifier = Modifier .padding(start = 8.dp, end = 8.dp) diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index d9467596..79ebe7b5 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -10,15 +10,17 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.AlertDialog @@ -38,15 +40,20 @@ import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.structuralEqualityPolicy import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource @@ -261,6 +268,8 @@ class ChatRouterViewModel @Inject constructor( } } +val LocalIsConnected = compositionLocalOf(structuralEqualityPolicy()) { false } + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatRouterScreen( @@ -276,6 +285,8 @@ fun ChatRouterScreen( val context = LocalContext.current val view = LocalView.current + var drawerWidth by remember { mutableFloatStateOf(0.0f) } + var showPlatformModDMHint by remember { mutableStateOf(false) } var showStatusSheet by remember { mutableStateOf(false) } @@ -814,55 +825,11 @@ fun ChatRouterScreen( ) } - AnimatedVisibility( - visible = RealtimeSocket.disconnectionState == DisconnectionState.Connected + CompositionLocalProvider( + LocalIsConnected provides (RealtimeSocket.disconnectionState == DisconnectionState.Connected) ) { - Spacer(Modifier.windowInsetsPadding(WindowInsets.statusBars)) - } - - if (useTabletAwareUI) { - Row { - DismissibleDrawerSheet( - drawerContainerColor = Color.Transparent, - windowInsets = WindowInsets.zero - ) { - Sidebar( - viewModel = viewModel, - topNav = topNav, - currentServer = currentServer, - onShowStatusSheet = { - showStatusSheet = true - }, - onShowServerContextSheet = { - serverContextSheetTarget = it - showServerContextSheet = true - }, - onShowAddServerSheet = { - showAddServerSheet = true - }, - showSettingsButton = isTouchExplorationEnabled, - onOpenSettings = { - topNav.navigate("settings") - }, - ) - } - ChannelNavigator( - dest = viewModel.currentDestination, - topNav = topNav, - useDrawer = false, - disableBackHandler = disableBackHandler, - toggleDrawer = { - toggleDrawerLambda() - }, - onEnterVoiceUI = onEnterVoiceUI, - ) - } - } else { - var useSidebarGesture by remember { mutableStateOf(true) } - DismissibleNavigationDrawer( - drawerState = drawerState, - gesturesEnabled = useSidebarGesture, - drawerContent = { + if (useTabletAwareUI) { + Row { DismissibleDrawerSheet( drawerContainerColor = Color.Transparent, windowInsets = WindowInsets.zero @@ -885,30 +852,104 @@ fun ChatRouterScreen( onOpenSettings = { topNav.navigate("settings") }, - drawerState = drawerState - ) - } - }, - content = { - Row(Modifier.fillMaxSize()) { - ChannelNavigator( - dest = viewModel.currentDestination, - topNav = topNav, - useDrawer = true, - disableBackHandler = disableBackHandler, - toggleDrawer = { - toggleDrawerLambda() - }, - drawerState = drawerState, - drawerGestureEnabled = useSidebarGesture, - setDrawerGestureEnabled = { - useSidebarGesture = it - }, - onEnterVoiceUI = onEnterVoiceUI, ) } + ChannelNavigator( + dest = viewModel.currentDestination, + topNav = topNav, + useDrawer = false, + disableBackHandler = disableBackHandler, + toggleDrawer = { + toggleDrawerLambda() + }, + onEnterVoiceUI = onEnterVoiceUI, + ) } - ) + } else { + var useSidebarGesture by remember { mutableStateOf(true) } + DismissibleNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = useSidebarGesture, + drawerContent = { + DismissibleDrawerSheet( + drawerContainerColor = Color.Transparent, + windowInsets = WindowInsets.zero, + modifier = Modifier.onSizeChanged { + drawerWidth = it.width.toFloat() + } + ) { + Sidebar( + viewModel = viewModel, + topNav = topNav, + currentServer = currentServer, + onShowStatusSheet = { + showStatusSheet = true + }, + onShowServerContextSheet = { + serverContextSheetTarget = it + showServerContextSheet = true + }, + onShowAddServerSheet = { + showAddServerSheet = true + }, + showSettingsButton = isTouchExplorationEnabled, + onOpenSettings = { + topNav.navigate("settings") + }, + drawerState = drawerState + ) + } + }, + content = { + Box(Modifier.fillMaxSize()) { + ChannelNavigator( + dest = viewModel.currentDestination, + topNav = topNav, + useDrawer = true, + disableBackHandler = disableBackHandler, + toggleDrawer = { + toggleDrawerLambda() + }, + drawerState = drawerState, + drawerGestureEnabled = useSidebarGesture, + setDrawerGestureEnabled = { + useSidebarGesture = it + }, + onEnterVoiceUI = onEnterVoiceUI, + ) + + // This is the overlay on the main content when the drawer is open + val interactionSource = remember { MutableInteractionSource() } + Box( + Modifier + .then( + if (drawerState.isOpen) { + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + enabled = drawerState.isOpen, + onClick = { + scope.launch { + drawerState.close() + } + } + ) + } else Modifier + ) + .fillMaxSize() + .background( + MaterialTheme + .colorScheme + .surfaceContainerLowest + .copy( + alpha = (1.0f + (drawerState.currentOffset / drawerWidth)) * 0.7f + ) + ) + ) + } + } + ) + } } } } diff --git a/app/src/main/java/chat/revolt/screens/chat/views/FriendsScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/FriendsScreen.kt index e8dd5163..e08efae7 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/FriendsScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/FriendsScreen.kt @@ -1,11 +1,16 @@ package chat.revolt.screens.chat.views +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu @@ -37,6 +42,7 @@ import chat.revolt.callbacks.ActionChannel import chat.revolt.composables.chat.MemberListItem import chat.revolt.composables.generic.CountableListHeader import chat.revolt.internals.extensions.zero +import chat.revolt.screens.chat.LocalIsConnected import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -48,69 +54,80 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: () Scaffold( topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.friends), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Column { + AnimatedVisibility(LocalIsConnected.current) { + Spacer( + Modifier + .height( + WindowInsets.statusBars.asPaddingValues() + .calculateTopPadding() + ) ) - }, - navigationIcon = { - if (useDrawer) { + } + TopAppBar( + title = { + Text( + text = stringResource(R.string.friends), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + if (useDrawer) { + IconButton(onClick = { + onDrawerClicked() + }) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(id = R.string.menu) + ) + } + } + }, + actions = { IconButton(onClick = { - onDrawerClicked() + topNav.navigate("create/group") }) { Icon( - imageVector = Icons.Default.Menu, - contentDescription = stringResource(id = R.string.menu) + painter = painterResource(R.drawable.ic_account_multiple_plus_24dp), + contentDescription = stringResource(R.string.frends_new_group) ) } - } - }, - actions = { - IconButton(onClick = { - topNav.navigate("create/group") - }) { - Icon( - painter = painterResource(R.drawable.ic_account_multiple_plus_24dp), - contentDescription = stringResource(R.string.frends_new_group) - ) - } - IconButton(onClick = { - overflowMenuShown = true - }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.menu) - ) - } - DropdownMenu( - expanded = overflowMenuShown, - onDismissRequest = { - overflowMenuShown = false + IconButton(onClick = { + overflowMenuShown = true + }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.menu) + ) } - ) { - DropdownMenuItem( - text = { - Text(stringResource(R.string.friends_deny_all_incoming)) - }, - onClick = { - scope.launch { - overflowMenuShown = false - } - with(Dispatchers.IO) { + DropdownMenu( + expanded = overflowMenuShown, + onDismissRequest = { + overflowMenuShown = false + } + ) { + DropdownMenuItem( + text = { + Text(stringResource(R.string.friends_deny_all_incoming)) + }, + onClick = { scope.launch { - FriendRequests.getIncoming() - .forEach { it.id?.let { id -> unfriendUser(id) } } + overflowMenuShown = false + } + with(Dispatchers.IO) { + scope.launch { + FriendRequests.getIncoming() + .forEach { it.id?.let { id -> unfriendUser(id) } } + } } } - } - ) - } - }, - windowInsets = WindowInsets.zero - ) + ) + } + }, + windowInsets = WindowInsets.zero + ) + } }, ) { pv -> Column( @@ -234,7 +251,7 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: () items(FriendRequests.getBlocked().size) { val item = FriendRequests.getBlocked().getOrNull(it) if (item == null) return@items - + MemberListItem( member = null, user = item, diff --git a/app/src/main/java/chat/revolt/screens/chat/views/NoCurrentChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/NoCurrentChannelScreen.kt index acb45ba4..20e1432f 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/NoCurrentChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/NoCurrentChannelScreen.kt @@ -1,10 +1,15 @@ package chat.revolt.screens.chat.views +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu import androidx.compose.material3.ExperimentalMaterial3Api @@ -23,28 +28,40 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.revolt.R import chat.revolt.internals.extensions.zero +import chat.revolt.screens.chat.LocalIsConnected @OptIn(ExperimentalMaterial3Api::class) @Composable fun NoCurrentChannelScreen(useDrawer: Boolean, onDrawerClicked: () -> Unit) { Scaffold( topBar = { - TopAppBar( - title = {}, - navigationIcon = { - if (useDrawer) { - IconButton(onClick = { - onDrawerClicked() - }) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = stringResource(id = R.string.menu) + Column { + AnimatedVisibility(LocalIsConnected.current) { + Spacer( + Modifier + .height( + WindowInsets.statusBars.asPaddingValues() + .calculateTopPadding() ) + ) + } + TopAppBar( + title = {}, + navigationIcon = { + if (useDrawer) { + IconButton(onClick = { + onDrawerClicked() + }) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(id = R.string.menu) + ) + } } - } - }, - windowInsets = WindowInsets.zero - ) + }, + windowInsets = WindowInsets.zero + ) + } }, ) { pv -> Column( diff --git a/app/src/main/java/chat/revolt/screens/chat/views/OverviewScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/OverviewScreen.kt index 14a891e3..2b0769b8 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/OverviewScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/OverviewScreen.kt @@ -13,11 +13,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.material.icons.Icons @@ -62,6 +64,7 @@ import chat.revolt.composables.generic.NonIdealState import chat.revolt.composables.screens.settings.UserOverview import chat.revolt.composables.skeletons.UserOverviewSkeleton import chat.revolt.internals.extensions.zero +import chat.revolt.screens.chat.LocalIsConnected import chat.revolt.sheets.UserCardSheet import io.sentry.Sentry @@ -110,22 +113,33 @@ fun OverviewScreen( Scaffold( topBar = { - CenterAlignedTopAppBar( - title = { Text(stringResource(R.string.overview_screen_title)) }, - navigationIcon = { - if (useDrawer) { - IconButton(onClick = { - onDrawerClicked() - }) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = stringResource(id = R.string.menu) + Column { + AnimatedVisibility(LocalIsConnected.current) { + Spacer( + Modifier + .height( + WindowInsets.statusBars.asPaddingValues() + .calculateTopPadding() ) + ) + } + CenterAlignedTopAppBar( + title = { Text(stringResource(R.string.overview_screen_title)) }, + navigationIcon = { + if (useDrawer) { + IconButton(onClick = { + onDrawerClicked() + }) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(id = R.string.menu) + ) + } } - } - }, - windowInsets = WindowInsets.zero - ) + }, + windowInsets = WindowInsets.zero + ) + } }, contentWindowInsets = if (includePadding) ScaffoldDefaults.contentWindowInsets else ScaffoldDefaults.contentWindowInsets.exclude( NavigationBarDefaults.windowInsets diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index c7261beb..ba163362 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -145,6 +146,7 @@ import chat.revolt.composables.skeletons.MessageSkeleton import chat.revolt.composables.skeletons.MessageSkeletonVariant import chat.revolt.internals.extensions.rememberChannelPermissions import chat.revolt.internals.extensions.zero +import chat.revolt.screens.chat.LocalIsConnected import chat.revolt.sheets.ChannelInfoSheet import chat.revolt.sheets.MessageContextSheet import chat.revolt.sheets.ReactSheet @@ -513,126 +515,137 @@ fun ChannelScreen( Scaffold( contentWindowInsets = WindowInsets.zero, topBar = { - TopAppBar( - modifier = Modifier.clickable { - channelInfoSheetShown = true - }, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - viewModel.channel?.let { - when (it.channelType) { - ChannelType.DirectMessage -> { + Column { + AnimatedVisibility(LocalIsConnected.current) { + Spacer( + Modifier + .height( + WindowInsets.statusBars.asPaddingValues() + .calculateTopPadding() + ) + ) + } + TopAppBar( + modifier = Modifier.clickable { + channelInfoSheetShown = true + }, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + viewModel.channel?.let { + when (it.channelType) { + ChannelType.DirectMessage -> { + val partner = + RevoltAPI.userCache[ChannelUtils.resolveDMPartner(it)] + UserAvatar( + username = it.name ?: stringResource(R.string.unknown), + userId = ChannelUtils.resolveDMPartner(it) ?: "", + size = 24.dp, + presenceSize = 12.dp, + avatar = partner?.avatar + ) + } + + ChannelType.Group -> { + GroupIcon( + name = it.name ?: stringResource(R.string.unknown), + size = 24.dp, + icon = it.icon + ) + } + + else -> { + ChannelIcon( + channelType = it.channelType ?: ChannelType.TextChannel, + modifier = Modifier + .size(24.dp) + .alpha(0.8f) + ) + } + } + + CompositionLocalProvider( + LocalTextStyle provides LocalTextStyle.current.copy( + fontSize = 20.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Bottom, + trim = LineHeightStyle.Trim.LastLineBottom + ) + ) + ) { + when (it.channelType) { + ChannelType.TextChannel, ChannelType.VoiceChannel, ChannelType.Group -> Text( + it.name ?: stringResource(R.string.unknown), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + ChannelType.SavedMessages -> Text( + stringResource(R.string.channel_notes), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + ChannelType.DirectMessage -> Text( + ChannelUtils.resolveName(it) + ?: stringResource(R.string.unknown), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + else -> Text( + stringResource(R.string.unknown), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + if (it.channelType == ChannelType.DirectMessage) { val partner = RevoltAPI.userCache[ChannelUtils.resolveDMPartner(it)] - UserAvatar( - username = it.name ?: stringResource(R.string.unknown), - userId = ChannelUtils.resolveDMPartner(it) ?: "", - size = 24.dp, - presenceSize = 12.dp, - avatar = partner?.avatar + PresenceBadge( + presence = presenceFromStatus( + partner?.status?.presence, + online = partner?.online == true + ), + size = 12.dp ) } - ChannelType.Group -> { - GroupIcon( - name = it.name ?: stringResource(R.string.unknown), - size = 24.dp, - icon = it.icon - ) - } - - else -> { - ChannelIcon( - channelType = it.channelType ?: ChannelType.TextChannel, - modifier = Modifier - .size(24.dp) - .alpha(0.8f) - ) - } - } - - CompositionLocalProvider( - LocalTextStyle provides LocalTextStyle.current.copy( - fontSize = 20.sp, - lineHeightStyle = LineHeightStyle( - alignment = LineHeightStyle.Alignment.Bottom, - trim = LineHeightStyle.Trim.LastLineBottom - ) - ) - ) { - when (it.channelType) { - ChannelType.TextChannel, ChannelType.VoiceChannel, ChannelType.Group -> Text( - it.name ?: stringResource(R.string.unknown), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - ChannelType.SavedMessages -> Text( - stringResource(R.string.channel_notes), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - ChannelType.DirectMessage -> Text( - ChannelUtils.resolveName(it) - ?: stringResource(R.string.unknown), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - else -> Text( - stringResource(R.string.unknown), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - if (it.channelType == ChannelType.DirectMessage) { - val partner = - RevoltAPI.userCache[ChannelUtils.resolveDMPartner(it)] - PresenceBadge( - presence = presenceFromStatus( - partner?.status?.presence, - online = partner?.online == true - ), - size = 12.dp + Icon( + imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier + .size(16.dp) + .alpha(0.5f) + ) + } + } + }, + windowInsets = if (useChatUI) WindowInsets.statusBars else WindowInsets.zero, + navigationIcon = { + if (useDrawer) { + IconButton(onClick = onToggleDrawer) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(id = R.string.menu) + ) + } + } + if (useBackButton) { + IconButton(onClick = backButtonAction ?: {}) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(id = R.string.back) ) } - - Icon( - imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier - .size(16.dp) - .alpha(0.5f) - ) } } - }, - windowInsets = if (useChatUI) WindowInsets.statusBars else WindowInsets.zero, - navigationIcon = { - if (useDrawer) { - IconButton(onClick = onToggleDrawer) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = stringResource(id = R.string.menu) - ) - } - } - if (useBackButton) { - IconButton(onClick = backButtonAction ?: {}) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = stringResource(id = R.string.back) - ) - } - } - } - ) + ) + } } ) { pv -> Crossfade( From afba6f9ecf537c08448e858c9e29f8b00b40cbd7 Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 31 May 2025 20:15:23 +0200 Subject: [PATCH 11/12] feat: permission management for voice chat Signed-off-by: Infi --- .../chat/revolt/activities/MainActivity.kt | 30 +++- .../screens/chat/TypingIndicator.kt | 17 ++- .../chat/molecules/JoinVoiceChannelButton.kt | 61 ++++++++ .../screens/voice/VoiceChannelOverlay.kt | 64 -------- .../voice/VoicePermissionSwitch.kt | 144 ++++++++++++++++++ .../revolt/composables/voice/VoiceSheet.kt | 8 + .../revolt/screens/chat/ChatRouterScreen.kt | 18 +-- .../chat/views/channel/ChannelScreen.kt | 12 +- app/src/main/res/drawable/icn_mic_24dp.xml | 10 ++ .../main/res/drawable/icn_videocam_24dp.xml | 11 ++ app/src/main/res/values/strings.xml | 13 +- 11 files changed, 287 insertions(+), 101 deletions(-) create mode 100644 app/src/main/java/chat/revolt/composables/screens/chat/molecules/JoinVoiceChannelButton.kt delete mode 100644 app/src/main/java/chat/revolt/composables/screens/voice/VoiceChannelOverlay.kt create mode 100644 app/src/main/java/chat/revolt/composables/voice/VoicePermissionSwitch.kt create mode 100644 app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt create mode 100644 app/src/main/res/drawable/icn_mic_24dp.xml create mode 100644 app/src/main/res/drawable/icn_videocam_24dp.xml diff --git a/app/src/main/java/chat/revolt/activities/MainActivity.kt b/app/src/main/java/chat/revolt/activities/MainActivity.kt index 63790cd4..3588fdab 100644 --- a/app/src/main/java/chat/revolt/activities/MainActivity.kt +++ b/app/src/main/java/chat/revolt/activities/MainActivity.kt @@ -48,10 +48,12 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,6 +62,7 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset @@ -84,6 +87,7 @@ import chat.revolt.api.settings.Experiments import chat.revolt.api.settings.LoadedSettings import chat.revolt.api.settings.SyncedSettings import chat.revolt.composables.generic.HealthAlert +import chat.revolt.composables.voice.VoicePermissionSwitch import chat.revolt.material.EasingTokens import chat.revolt.ndk.NativeLibraries import chat.revolt.persistence.KVStorage @@ -433,7 +437,9 @@ fun AppEntrypoint( onRetryConnection: () -> Unit, onUpdateNextDestination: (String) -> Unit = {} ) { - var showVoiceUI by remember { mutableStateOf(false) } + var showVoiceUI by rememberSaveable { mutableStateOf(false) } + var voiceChannelID by rememberSaveable { mutableStateOf(null) } + val chatUIScale by animateFloatAsState( if (showVoiceUI) 0.8f else 1.0f, animationSpec = tween( @@ -453,6 +459,11 @@ fun AppEntrypoint( showVoiceUI = false } + val keyboardController = LocalSoftwareKeyboardController.current + LaunchedEffect(showVoiceUI) { + if (showVoiceUI) keyboardController?.hide() + } + val navController = rememberNavController() RevoltTheme( @@ -612,8 +623,9 @@ fun AppEntrypoint( ) navController.navigate("default") }, - onEnterVoiceUI = { + onEnterVoiceUI = { channelId -> showVoiceUI = true + voiceChannelID = channelId }, ) } @@ -752,10 +764,16 @@ fun AppEntrypoint( .widthIn(max = 600.dp) .padding(8.dp) ) { - Button(onClick = { - showVoiceUI = false - }) { - Text("Close voice UI") + VoicePermissionSwitch( + onCancel = { + showVoiceUI = false + } + ) { + Button(onClick = { + showVoiceUI = false + }) { + Text("Close voice UI") + } } } } diff --git a/app/src/main/java/chat/revolt/composables/screens/chat/TypingIndicator.kt b/app/src/main/java/chat/revolt/composables/screens/chat/TypingIndicator.kt index 9af1c0c3..17fa440d 100644 --- a/app/src/main/java/chat/revolt/composables/screens/chat/TypingIndicator.kt +++ b/app/src/main/java/chat/revolt/composables/screens/chat/TypingIndicator.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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.revolt.R @@ -29,10 +30,16 @@ import chat.revolt.api.schemas.User import chat.revolt.composables.generic.UserAvatar @Composable -fun StackedUserAvatars(users: List, amount: Int = 3, serverId: String?) { +fun StackedUserAvatars( + users: List, + amount: Int = 3, + size: Dp = 16.dp, + offset: Dp = 8.dp, + serverId: String? +) { Box( modifier = Modifier - .size(16.dp + (8.dp * minOf(users.size, amount)), 16.dp) + .size(size + (offset * minOf(users.size, amount)), size) ) { users.take(amount).forEachIndexed { index, userId -> val user = RevoltAPI.userCache[userId] @@ -44,10 +51,10 @@ fun StackedUserAvatars(users: List, amount: Int = 3, serverId: String?) username = user?.let { User.resolveDefaultName(it) } ?: stringResource(id = R.string.unknown), rawUrl = maybeMember?.avatar?.let { "$REVOLT_FILES/avatars/${it.id}" }, - size = 16.dp, + size = size, modifier = Modifier .offset( - x = (index * 8).dp + x = (index * offset.value).dp ) ) } @@ -91,7 +98,7 @@ fun TypingIndicator(users: List, serverId: String?) { RevoltAPI.userCache[userId]?.let { u -> val maybeMember = serverId?.let { RevoltAPI.members.getMember(serverId, userId) } - + maybeMember?.nickname ?: User.resolveDefaultName(u) } ?: userId } diff --git a/app/src/main/java/chat/revolt/composables/screens/chat/molecules/JoinVoiceChannelButton.kt b/app/src/main/java/chat/revolt/composables/screens/chat/molecules/JoinVoiceChannelButton.kt new file mode 100644 index 00000000..dcdec0a9 --- /dev/null +++ b/app/src/main/java/chat/revolt/composables/screens/chat/molecules/JoinVoiceChannelButton.kt @@ -0,0 +1,61 @@ +package chat.revolt.composables.screens.chat.molecules + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import chat.revolt.R +import chat.revolt.callbacks.Action +import chat.revolt.callbacks.ActionChannel +import chat.revolt.composables.screens.chat.StackedUserAvatars +import kotlinx.coroutines.launch + +@Composable +fun JoinVoiceChannelButton(channelId: String, modifier: Modifier = Modifier) { + val scope = rememberCoroutineScope() + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .clip(MaterialTheme.shapes.large) + .clickable { + scope.launch { ActionChannel.send(Action.OpenVoiceChannelOverlay(channelId)) } + } + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(16.dp) + .fillMaxWidth() + ) { + StackedUserAvatars( + listOf( + "01FHGJ3NPP7XANQQH8C2BE44ZY", + "01F1WKM5TK2V6KCZWR6DGBJDTZ", + "01EX2NCWQ0CHS3QJF0FEQS1GR4" + ), + size = 24.dp, + offset = 12.dp, + amount = 3, + serverId = null + ) + Text( + stringResource(R.string.voice_join_offering), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + Text( + stringResource(R.string.voice_join_offering_description_other, Integer.MAX_VALUE), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/composables/screens/voice/VoiceChannelOverlay.kt b/app/src/main/java/chat/revolt/composables/screens/voice/VoiceChannelOverlay.kt deleted file mode 100644 index 0720ab77..00000000 --- a/app/src/main/java/chat/revolt/composables/screens/voice/VoiceChannelOverlay.kt +++ /dev/null @@ -1,64 +0,0 @@ -package chat.revolt.composables.screens.voice - -import android.content.Context -import android.util.DisplayMetrics -import android.view.WindowManager -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties - -@Composable -fun VoiceChannelOverlay(channelId: String, onCollapse: () -> Unit) { - val context = LocalContext.current - val windowManager = - remember { context.getSystemService(Context.WINDOW_SERVICE) as WindowManager } - - val metrics = DisplayMetrics().apply { - @Suppress("DEPRECATION") // We *need* the real metrics here, not the window metrics - windowManager.defaultDisplay.getRealMetrics(this) - } - val (width, height) = with(LocalDensity.current) { - Pair(metrics.widthPixels.toDp(), metrics.heightPixels.toDp()) - } - - Dialog( - onDismissRequest = { - onCollapse() - }, - properties = DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = true, - dismissOnClickOutside = false, - dismissOnBackPress = true - ) - ) { - Column( - Modifier - .requiredSize(width, height) - .background(MaterialTheme.colorScheme.background), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) - ) { - Text(text = "Voice channel overlay for $channelId", textAlign = TextAlign.Center) - Button(onClick = { - onCollapse() - }) { - Text("Close") - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/composables/voice/VoicePermissionSwitch.kt b/app/src/main/java/chat/revolt/composables/voice/VoicePermissionSwitch.kt new file mode 100644 index 00000000..7410e153 --- /dev/null +++ b/app/src/main/java/chat/revolt/composables/voice/VoicePermissionSwitch.kt @@ -0,0 +1,144 @@ +package chat.revolt.composables.voice + +import android.Manifest +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import chat.revolt.R +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.shouldShowRationale + +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) +@Composable +fun VoicePermissionSwitch(onCancel: () -> Unit, onPermissionGranted: @Composable () -> Unit) { + val context = LocalContext.current + + var permissionState = rememberMultiplePermissionsState( + listOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.CAMERA + ), + onPermissionsResult = {} + ) + var fullyRevoked = permissionState + .revokedPermissions + .any { it.status is PermissionStatus.Denied && !it.status.shouldShowRationale } + + if (permissionState.allPermissionsGranted) { + onPermissionGranted() + } else if (permissionState.shouldShowRationale || fullyRevoked) { + ModalBottomSheet( + onDismissRequest = onCancel, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + sheetGesturesEnabled = false, + dragHandle = {} + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp, vertical = 16.dp) + ) { + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.voice_join_permission_rationale_heading), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.voice_join_permission_rationale_description), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.icn_mic_24dp), + contentDescription = null + ) + Text( + text = stringResource(R.string.voice_join_permission_rationale_permission_mic), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f) + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.icn_videocam_24dp), + contentDescription = null + ) + Text( + text = stringResource(R.string.voice_join_permission_rationale_permission_camera), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f) + ) + } + Text( + text = stringResource(R.string.voice_join_permission_rationale_assurance), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { + if (fullyRevoked) { + // Launch settings to allow the user to manually enable permissions + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = "package:${context.packageName}".toUri() + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } else { + permissionState.launchMultiplePermissionRequest() + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.voice_join_permission_rationale_cta)) + } + } + } + } else { + LaunchedEffect(Unit) { + permissionState.launchMultiplePermissionRequest() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt b/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt new file mode 100644 index 00000000..0eaa06b4 --- /dev/null +++ b/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt @@ -0,0 +1,8 @@ +package chat.revolt.composables.voice + +import androidx.compose.runtime.Composable + +@Composable +fun VoiceSheet(channelId: String) { + +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index 79ebe7b5..ff53b22e 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -77,7 +77,6 @@ import chat.revolt.callbacks.Action import chat.revolt.callbacks.ActionChannel import chat.revolt.composables.chat.DisconnectedNotice import chat.revolt.composables.screens.chat.drawer.ChannelSideDrawer -import chat.revolt.composables.screens.voice.VoiceChannelOverlay import chat.revolt.dialogs.NotificationRationaleDialog import chat.revolt.internals.Changelogs import chat.revolt.internals.extensions.zero @@ -277,7 +276,7 @@ fun ChatRouterScreen( windowSizeClass: WindowSizeClass, disableBackHandler: Boolean, onNullifiedUser: () -> Unit, - onEnterVoiceUI: () -> Unit, + onEnterVoiceUI: (String) -> Unit, viewModel: ChatRouterViewModel = hiltViewModel() ) { val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -315,9 +314,6 @@ fun ChatRouterScreen( var useTabletAwareUI by remember { mutableStateOf(false) } - var voiceChannelOverlay by remember { mutableStateOf(false) } - var voiceChannelOverlayChannelId by remember { mutableStateOf("") } - var showReportUser by remember { mutableStateOf(false) } var reportUserTarget by remember { mutableStateOf("") } @@ -457,8 +453,7 @@ fun ChatRouterScreen( } is Action.OpenVoiceChannelOverlay -> { - voiceChannelOverlayChannelId = action.channelId - voiceChannelOverlay = true + onEnterVoiceUI(action.channelId) } is Action.OpenWebhookSheet -> { @@ -725,12 +720,6 @@ fun ChatRouterScreen( } } - if (voiceChannelOverlay) { - VoiceChannelOverlay(voiceChannelOverlayChannelId) { - voiceChannelOverlay = false - } - } - val askNotificationsPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (isGranted) { @@ -990,7 +979,7 @@ fun ChannelNavigator( drawerState: DrawerState? = null, drawerGestureEnabled: Boolean = true, disableBackHandler: Boolean = false, - onEnterVoiceUI: () -> Unit = {}, + onEnterVoiceUI: (String) -> Unit = {}, setDrawerGestureEnabled: (Boolean) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -1033,7 +1022,6 @@ fun ChannelNavigator( drawerGestureEnabled = drawerGestureEnabled, setDrawerGestureEnabled = setDrawerGestureEnabled, drawerIsOpen = drawerState?.isOpen == true, - onEnterVoiceUI = onEnterVoiceUI, ) } diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index ba163362..03df56af 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -142,6 +142,7 @@ import chat.revolt.composables.screens.chat.ChannelIcon import chat.revolt.composables.screens.chat.ReplyManager import chat.revolt.composables.screens.chat.TypingIndicator import chat.revolt.composables.screens.chat.atoms.RegularMessage +import chat.revolt.composables.screens.chat.molecules.JoinVoiceChannelButton import chat.revolt.composables.skeletons.MessageSkeleton import chat.revolt.composables.skeletons.MessageSkeletonVariant import chat.revolt.internals.extensions.rememberChannelPermissions @@ -201,7 +202,6 @@ fun ChannelScreen( drawerIsOpen: Boolean = false, backButtonAction: (() -> Unit)? = null, useChatUI: Boolean = false, - onEnterVoiceUI: () -> Unit = {}, viewModel: ChannelScreenViewModel = hiltViewModel() ) { // @@ -936,15 +936,7 @@ fun ChannelScreen( } if (viewModel.channel?.channelType == ChannelType.VoiceChannel) { - Button( - onClick = { - onEnterVoiceUI() - }, - modifier = Modifier - .fillMaxWidth() - ) { - Text("Join Voice Channel") - } + JoinVoiceChannelButton(channelId) } } } diff --git a/app/src/main/res/drawable/icn_mic_24dp.xml b/app/src/main/res/drawable/icn_mic_24dp.xml new file mode 100644 index 00000000..b13baa43 --- /dev/null +++ b/app/src/main/res/drawable/icn_mic_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/icn_videocam_24dp.xml b/app/src/main/res/drawable/icn_videocam_24dp.xml new file mode 100644 index 00000000..ce6074f4 --- /dev/null +++ b/app/src/main/res/drawable/icn_videocam_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 177b4ff7..08db6b0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -593,6 +593,17 @@ Search results Clear search + Join the voice channel + Start the call + with %1$s + with %1$d others + We need your permission + To join a voice channel, you need to grant the following permissions: + Your microphone, so others can hear you when unmuted + Your camera, so others can see you if you want to share video + Don\'t worry, we won\'t use your microphone or camera without your permission. + Grant permissions + Stay in the loop Enable notifications to be kept up to date with messages and mentions. Enable notifications @@ -752,7 +763,7 @@ This is not a valid share intent. This attachment is too large for Revolt (max. $1$s). Search channels - m select a channel to share to. + Select a channel to share to. Conversations Friends and Social From 740d693be454f72b8a937c5eca3aab0173631675 Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 31 May 2025 21:51:13 +0200 Subject: [PATCH 12/12] feat: basic livekit/voice chat impl Signed-off-by: Infi --- app/build.gradle.kts | 4 +- .../chat/revolt/activities/MainActivity.kt | 18 ++- .../revolt/composables/voice/VoiceSheet.kt | 144 +++++++++++++++++- app/src/main/res/values/strings.xml | 4 + 4 files changed, 160 insertions(+), 10 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3f3d99d5..78be7001 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,8 +29,8 @@ val ktorVersion = "3.0.0-beta-2" val media3Version = "1.5.0" object LivekitVersion { - val core = "2.14.1" - val componentsCompose = "1.3.0" + val core = "2.16.0" + val componentsCompose = "1.3.1" } val material3Version = "1.4.0-alpha10" diff --git a/app/src/main/java/chat/revolt/activities/MainActivity.kt b/app/src/main/java/chat/revolt/activities/MainActivity.kt index 3588fdab..153b10c6 100644 --- a/app/src/main/java/chat/revolt/activities/MainActivity.kt +++ b/app/src/main/java/chat/revolt/activities/MainActivity.kt @@ -38,7 +38,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -88,6 +87,7 @@ import chat.revolt.api.settings.LoadedSettings import chat.revolt.api.settings.SyncedSettings import chat.revolt.composables.generic.HealthAlert import chat.revolt.composables.voice.VoicePermissionSwitch +import chat.revolt.composables.voice.VoiceSheet import chat.revolt.material.EasingTokens import chat.revolt.ndk.NativeLibraries import chat.revolt.persistence.KVStorage @@ -438,7 +438,7 @@ fun AppEntrypoint( onUpdateNextDestination: (String) -> Unit = {} ) { var showVoiceUI by rememberSaveable { mutableStateOf(false) } - var voiceChannelID by rememberSaveable { mutableStateOf(null) } + var voiceChannelId by rememberSaveable { mutableStateOf(null) } val chatUIScale by animateFloatAsState( if (showVoiceUI) 0.8f else 1.0f, @@ -625,7 +625,7 @@ fun AppEntrypoint( }, onEnterVoiceUI = { channelId -> showVoiceUI = true - voiceChannelID = channelId + voiceChannelId = channelId }, ) } @@ -769,10 +769,14 @@ fun AppEntrypoint( showVoiceUI = false } ) { - Button(onClick = { - showVoiceUI = false - }) { - Text("Close voice UI") + voiceChannelId?.let { + VoiceSheet( + it, + onDisconnect = { + showVoiceUI = false + voiceChannelId = null + } + ) } } } diff --git a/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt b/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt index 0eaa06b4..c2f83ada 100644 --- a/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt +++ b/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt @@ -1,8 +1,150 @@ package chat.revolt.composables.voice +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import chat.revolt.R +import chat.revolt.api.routes.misc.getRootRoute +import chat.revolt.api.routes.voice.joinCall +import io.livekit.android.compose.local.RoomLocal +import io.livekit.android.compose.local.RoomScope +import io.livekit.android.compose.state.rememberTracks +import io.livekit.android.compose.ui.VideoTrackView +import logcat.LogPriority +import logcat.asLog +import logcat.logcat + +class VoiceSheetViewModel(private val state: SavedStateHandle) : ViewModel() { + private val _channelId = mutableStateOf(state.get("channelId") ?: "") + var channelId: String + get() = _channelId.value + set(value) { + _channelId.value = value + state["channelId"] = value + } + + var voiceLkNode by mutableStateOf("") + private val _voiceToken = mutableStateOf(state.get("voiceToken") ?: "") + var voiceToken: String + get() = _voiceToken.value + private set(value) { + _voiceToken.value = value + state["voiceToken"] = value + } + + var errorResource by mutableStateOf(null) + private set + + suspend fun getVoiceToken() { + val root = getRootRoute() + val lk = root.features.livekit + + if (lk == null) { + logcat(LogPriority.ERROR) { + IllegalStateException("LiveKit is not supported by this API version!").asLog() + } + errorResource = R.string.voice_error_not_supported + return + } + + if (lk.nodes.isEmpty()) { + logcat(LogPriority.ERROR) { IllegalStateException("No LiveKit nodes available!").asLog() } + errorResource = R.string.voice_error_no_nodes + return + } + + val node = lk.nodes.random() + try { + val joined = joinCall(channelId, node.name) + voiceLkNode = joined.url + voiceToken = joined.token + } catch (e: Exception) { + logcat(LogPriority.ERROR) { "Could not get LiveKit token\n" + e.asLog() } + errorResource = R.string.voice_error_generic + return + } + } +} @Composable -fun VoiceSheet(channelId: String) { +fun VoiceSheet( + channelId: String, + onDisconnect: () -> Unit, + viewModel: VoiceSheetViewModel = viewModel() +) { + LaunchedEffect(channelId) { + viewModel.channelId = channelId + viewModel.getVoiceToken() + } + RoomScope( + url = viewModel.voiceLkNode, + token = viewModel.voiceToken, + audio = true, + video = false, + connect = true, + ) { + val room = RoomLocal.current + val trackRefs = rememberTracks() + + Column { + LazyColumn(modifier = Modifier.animateContentSize()) { + items(trackRefs.size) { index -> + VideoTrackView( + trackReference = trackRefs[index], + modifier = Modifier.fillParentMaxHeight(0.5f) + ) + } + item(key = "stats") { + Text("status = ${room.state.name}") + } + item(key = "controls") { + Button(onClick = { + room.setMicrophoneMute(true) + }) { + Text("mute 🎤🫷😤") + } + Button(onClick = { + room.setMicrophoneMute(false) + }) { + Text("unmute 🗣️📢🔥") + } + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Button( + colors = ButtonDefaults.buttonColors().copy( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + disabledContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f), + disabledContentColor = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.5f) + ), + onClick = { + room.disconnect() + onDisconnect() + }, + ) { + Text("disconnect") + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 08db6b0a..e4d98e87 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -604,6 +604,10 @@ Don\'t worry, we won\'t use your microphone or camera without your permission. Grant permissions + Voice channels are not available at the moment. + All voice nodes are unavailable at the moment. Please try again later. + An error occurred. Please try again later. + Stay in the loop Enable notifications to be kept up to date with messages and mentions. Enable notifications