Merge branch 'feat/voice-chats-2.0' into dev

# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/chat/revolt/api/internals/Permissions.kt
#	app/src/main/java/chat/revolt/screens/chat/views/FriendsScreen.kt
This commit is contained in:
Infi 2025-07-23 02:23:36 +02:00
commit efa103c5e5
25 changed files with 1410 additions and 755 deletions

View File

@ -27,8 +27,15 @@ val hiltVersion = "2.52"
val glideVersion = "4.16.0"
val ktorVersion = "3.0.0-beta-2"
val media3Version = "1.7.1"
val livekitVersion = "2.2.0"
val material3Version = "1.4.0-alpha15"
val media3Version = "1.5.0"
object LivekitVersion {
val core = "2.16.0"
val componentsCompose = "1.3.1"
}
val material3Version = "1.4.0-alpha10"
val androidXTestVersion = "1.6.1"
fun property(fileName: String, propertyName: String, fallbackEnv: String? = null): String? {
@ -132,6 +139,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 {
@ -291,8 +304,8 @@ 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.core}")
implementation("io.livekit:livekit-android-compose-components:${LivekitVersion.componentsCompose}")
// Firebase - Cloud Messaging
implementation(platform("com.google.firebase:firebase-bom:33.15.0"))

View File

@ -13,18 +13,32 @@ 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.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -33,14 +47,25 @@ 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
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.platform.LocalSoftwareKeyboardController
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
@ -61,6 +86,8 @@ 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.composables.voice.VoiceSheet
import chat.revolt.material.EasingTokens
import chat.revolt.ndk.NativeLibraries
import chat.revolt.persistence.KVStorage
@ -394,6 +421,8 @@ val RevoltTweenColour: FiniteAnimationSpec<Color> = tween(400, easing = EaseInOu
val NavTweenInt: FiniteAnimationSpec<IntOffset> = tween(350, easing = EaseInOutExpo)
val NavTweenFloat: FiniteAnimationSpec<Float> = 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 +437,350 @@ fun AppEntrypoint(
onRetryConnection: () -> Unit,
onUpdateNextDestination: (String) -> Unit = {}
) {
var showVoiceUI by rememberSaveable { mutableStateOf(false) }
var voiceChannelId by rememberSaveable { mutableStateOf<String?>(null) }
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 keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(showVoiceUI) {
if (showVoiceUI) keyboardController?.hide()
}
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 = { channelId ->
showVoiceUI = true
voiceChannelId = channelId
},
)
}
// 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)
) {
VoicePermissionSwitch(
onCancel = {
showVoiceUI = false
}
) {
voiceChannelId?.let {
VoiceSheet(
it,
onDisconnect = {
showVoiceUI = false
voiceChannelId = null
}
)
}
}
}
}
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) }
}
}
}

View File

@ -53,14 +53,20 @@ 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"
private const val USE_ALPHA_API = false
val REVOLT_BASE =
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"
const val REVOLT_FILES = "https://cdn.revoltusercontent.com"
const val REVOLT_JANUARY = "https://jan.revolt.chat"
val REVOLT_FILES =
if (USE_ALPHA_API) "https://alpha.revolt.chat/autumn" else "https://cdn.revoltusercontent.com"
val REVOLT_JANUARY =
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"
const val REVOLT_WEBSOCKET = "wss://ws.revolt.chat"
val REVOLT_WEBSOCKET =
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 {
@ -68,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}"
}
@OptIn(ExperimentalSerializationApi::class)

View File

@ -41,6 +41,7 @@ enum class PermissionBit(val value: Long) {
MuteMembers(1L shl 33),
DeafenMembers(1L shl 34),
MoveMembers(1L shl 35),
Listen(1L shl 36),
// % 1 bit reserved
@ -91,7 +92,8 @@ object BitDefaults {
PermissionBit.SendEmbeds +
PermissionBit.UploadFiles +
PermissionBit.Connect +
PermissionBit.Speak
PermissionBit.Speak +
PermissionBit.Listen
val SavedMessages =
PermissionBit.GrantAllSafe.value

View File

@ -53,6 +53,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,
@ -149,10 +150,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!! }

View File

@ -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<User>,
val servers: List<Server>,
val channels: List<Channel>,
val emojis: List<Emoji>
val emojis: List<Emoji>,
@SerialName("voice_states") val voiceStates: List<ChannelVoiceState> = listOf(),
)
typealias MessageFrame = Message

View File

@ -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<LiveKitNode>
)
@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()
}

View File

@ -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)
}

View File

@ -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<UserVoiceState>,
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,
)

View File

@ -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<String>, amount: Int = 3, serverId: String?) {
fun StackedUserAvatars(
users: List<String>,
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<String>, 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<String>, serverId: String?) {
RevoltAPI.userCache[userId]?.let { u ->
val maybeMember =
serverId?.let { RevoltAPI.members.getMember(serverId, userId) }
maybeMember?.nickname ?: User.resolveDefaultName(u)
} ?: userId
}

View File

@ -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)

View File

@ -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
)
}
}

View File

@ -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")
}
}
}
}

View File

@ -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()
}
}
}

View File

@ -0,0 +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<String>("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<String>("voiceToken") ?: "")
var voiceToken: String
get() = _voiceToken.value
private set(value) {
_voiceToken.value = value
state["voiceToken"] = value
}
var errorResource by mutableStateOf<Int?>(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,
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")
}
}
}
}
}

View File

@ -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
@ -70,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
@ -261,12 +267,16 @@ class ChatRouterViewModel @Inject constructor(
}
}
val LocalIsConnected = compositionLocalOf(structuralEqualityPolicy()) { false }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatRouterScreen(
topNav: NavController,
windowSizeClass: WindowSizeClass,
disableBackHandler: Boolean,
onNullifiedUser: () -> Unit,
onEnterVoiceUI: (String) -> Unit,
viewModel: ChatRouterViewModel = hiltViewModel()
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
@ -274,6 +284,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) }
@ -302,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("") }
@ -444,8 +453,7 @@ fun ChatRouterScreen(
}
is Action.OpenVoiceChannelOverlay -> {
voiceChannelOverlayChannelId = action.channelId
voiceChannelOverlay = true
onEnterVoiceUI(action.channelId)
}
is Action.OpenWebhookSheet -> {
@ -712,12 +720,6 @@ fun ChatRouterScreen(
}
}
if (voiceChannelOverlay) {
VoiceChannelOverlay(voiceChannelOverlayChannelId) {
voiceChannelOverlay = false
}
}
val askNotificationsPermission =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
@ -812,53 +814,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,
toggleDrawer = {
toggleDrawerLambda()
}
)
}
} else {
var useSidebarGesture by remember { mutableStateOf(true) }
DismissibleNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = useSidebarGesture,
drawerContent = {
if (useTabletAwareUI) {
Row {
DismissibleDrawerSheet(
drawerContainerColor = Color.Transparent,
windowInsets = WindowInsets.zero
@ -881,28 +841,104 @@ fun ChatRouterScreen(
onOpenSettings = {
topNav.navigate("settings")
},
drawerState = drawerState
)
}
},
content = {
Row(Modifier.fillMaxSize()) {
ChannelNavigator(
dest = viewModel.currentDestination,
topNav = topNav,
useDrawer = true,
toggleDrawer = {
toggleDrawerLambda()
},
drawerState = drawerState,
drawerGestureEnabled = useSidebarGesture,
setDrawerGestureEnabled = {
useSidebarGesture = it
}
)
}
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
)
)
)
}
}
)
}
}
}
}
@ -942,11 +978,13 @@ fun ChannelNavigator(
toggleDrawer: () -> Unit,
drawerState: DrawerState? = null,
drawerGestureEnabled: Boolean = true,
disableBackHandler: Boolean = false,
onEnterVoiceUI: (String) -> Unit = {},
setDrawerGestureEnabled: (Boolean) -> Unit = {},
) {
val scope = rememberCoroutineScope()
BackHandler(enabled = useDrawer) {
BackHandler(useDrawer && !disableBackHandler) {
toggleDrawer()
}

View File

@ -1,104 +1,47 @@
package chat.revolt.screens.chat.views
import android.content.Context
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButtonMenu
import androidx.compose.material3.FloatingActionButtonMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.ToggleFloatingActionButton
import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
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.sp
import androidx.navigation.NavController
import chat.revolt.R
import chat.revolt.api.internals.FriendRequests
import chat.revolt.api.internals.UserQR
import chat.revolt.api.internals.UserQRContents
import chat.revolt.api.routes.user.friendUser
import chat.revolt.api.routes.user.unfriendUser
import chat.revolt.api.schemas.AutumnResource
import chat.revolt.api.schemas.Metadata
import chat.revolt.api.settings.LoadedSettings
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.components.vectorassets.HL_TAG
import chat.revolt.components.vectorassets.HL_USERNAME
import chat.revolt.components.vectorassets.RevoltTagIntro
import chat.revolt.composables.chat.MemberListItem
import chat.revolt.composables.generic.CountableListHeader
import chat.revolt.composables.generic.UserAvatar
import chat.revolt.internals.extensions.zero
import chat.revolt.markdown.jbm.asHexString
import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanQRCode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -470,6 +413,16 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: ()
Scaffold(
topBar = {
Column {
AnimatedVisibility(LocalIsConnected.current) {
Spacer(
Modifier
.height(
WindowInsets.statusBars.asPaddingValues()
.calculateTopPadding()
)
)
}
TopAppBar(
title = {
Text(
@ -525,14 +478,15 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: ()
},
windowInsets = WindowInsets.zero
)
}
}
},
) { pv ->
Box(
Column(
modifier = Modifier
.padding(pv)
.fillMaxHeight()
) {
LazyColumn(state = listState) {
LazyColumn {
stickyHeader(key = "incoming") {
CountableListHeader(
text = stringResource(id = R.string.friends_incoming_requests),
@ -648,7 +602,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,

View File

@ -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(

View File

@ -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

View File

@ -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
@ -91,6 +92,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
@ -140,10 +142,12 @@ 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
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
@ -512,126 +516,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(
@ -847,74 +862,83 @@ 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,
colorFilter = ColorFilter.tint(
LocalContentColor.current
)
)
}
}
),
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) {
JoinVoiceChannelButton(channelId)
}
}
}

View File

@ -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
}
})
}
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,560Q430,560 395,525Q360,490 360,440L360,200Q360,150 395,115Q430,80 480,80Q530,80 565,115Q600,150 600,200L600,440Q600,490 565,525Q530,560 480,560ZM480,320Q480,320 480,320Q480,320 480,320L480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320L480,320Q480,320 480,320Q480,320 480,320ZM440,840L440,717Q336,703 268,624Q200,545 200,440L280,440Q280,523 338.5,581.5Q397,640 480,640Q563,640 621.5,581.5Q680,523 680,440L760,440Q760,545 692,624Q624,703 520,717L520,840L440,840ZM480,480Q497,480 508.5,468.5Q520,457 520,440L520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200L440,440Q440,457 451.5,468.5Q463,480 480,480Z"/>
</vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L640,160Q673,160 696.5,183.5Q720,207 720,240L720,420L880,260L880,700L720,540L720,720Q720,753 696.5,776.5Q673,800 640,800L160,800ZM160,720L640,720Q640,720 640,720Q640,720 640,720L640,240Q640,240 640,240Q640,240 640,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720Z"/>
</vector>

View File

@ -610,6 +610,21 @@
<string name="emoji_picker_search_results_header">Search results</string>
<string name="emoji_picker_clear_search">Clear search</string>
<string name="voice_join_offering">Join the voice channel</string>
<string name="voice_join_offering_description_zero">Start the call</string>
<string name="voice_join_offering_description_one">with %1$s</string>
<string name="voice_join_offering_description_other">with %1$d others</string>
<string name="voice_join_permission_rationale_heading">We need your permission</string>
<string name="voice_join_permission_rationale_description">To join a voice channel, you need to grant the following permissions:</string>
<string name="voice_join_permission_rationale_permission_mic">Your microphone, so others can hear you when unmuted</string>
<string name="voice_join_permission_rationale_permission_camera">Your camera, so others can see you if you want to share video</string>
<string name="voice_join_permission_rationale_assurance">Don\'t worry, we won\'t use your microphone or camera without your permission.</string>
<string name="voice_join_permission_rationale_cta">Grant permissions</string>
<string name="voice_error_not_supported">Voice channels are not available at the moment.</string>
<string name="voice_error_no_nodes">All voice nodes are unavailable at the moment. Please try again later.</string>
<string name="voice_error_generic">An error occurred. Please try again later.</string>
<string name="spark_notifications_rationale">Stay in the loop</string>
<string name="spark_notifications_rationale_description">Enable notifications to be kept up to date with messages and mentions.</string>
<string name="spark_notifications_rationale_cta">Enable notifications</string>
@ -769,7 +784,7 @@
<string name="share_target_invalid_intent">This is not a valid share intent.</string>
<string name="share_target_attachment_too_large">This attachment is too large for Revolt (max. $1$s).</string>
<string name="share_target_search_channels">Search channels</string>
<string name="share_target_select_channel">m select a channel to share to.</string>
<string name="share_target_select_channel">Select a channel to share to.</string>
<string name="notification_channel_group_conversations">Conversations</string>
<string name="notification_channel_group_social">Friends and Social</string>

View File

@ -1,4 +1,5 @@
sentry.dsn=
sentry.upload_mappings=true
build.debug.app_name=
build.flavour_id=ZZUU
build.flavour_id=ZZUU
dev.use_alpha_api=false