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 glideVersion = "4.16.0"
val ktorVersion = "3.0.0-beta-2" val ktorVersion = "3.0.0-beta-2"
val media3Version = "1.7.1" val media3Version = "1.7.1"
val livekitVersion = "2.2.0"
val material3Version = "1.4.0-alpha15" 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" val androidXTestVersion = "1.6.1"
fun property(fileName: String, propertyName: String, fallbackEnv: String? = null): String? { fun property(fileName: String, propertyName: String, fallbackEnv: String? = null): String? {
@ -132,6 +139,12 @@ android {
"FLAVOUR_ID", "FLAVOUR_ID",
"\"${buildproperty("build.flavour_id", "RVX_BUILD_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 { compileOptions {
@ -291,8 +304,8 @@ dependencies {
implementation("dev.snipme:highlights:1.0.0") implementation("dev.snipme:highlights:1.0.0")
// Livekit // Livekit
// FIXME temporarily not included, re-add when realtime media is to be implemented implementation("io.livekit:livekit-android:${LivekitVersion.core}")
// implementation "io.livekit:livekit-android:$livekit_version" implementation("io.livekit:livekit-android-compose-components:${LivekitVersion.componentsCompose}")
// Firebase - Cloud Messaging // Firebase - Cloud Messaging
implementation(platform("com.google.firebase:firebase-bom:33.15.0")) 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.View
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.EaseInOutExpo import androidx.compose.animation.core.EaseInOutExpo
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn 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.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.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text 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.WindowSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState 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.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.LoadedSettings
import chat.revolt.api.settings.SyncedSettings import chat.revolt.api.settings.SyncedSettings
import chat.revolt.composables.generic.HealthAlert 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.material.EasingTokens
import chat.revolt.ndk.NativeLibraries import chat.revolt.ndk.NativeLibraries
import chat.revolt.persistence.KVStorage 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 NavTweenInt: FiniteAnimationSpec<IntOffset> = tween(350, easing = EaseInOutExpo)
val NavTweenFloat: FiniteAnimationSpec<Float> = 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 @Composable
fun AppEntrypoint( fun AppEntrypoint(
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
@ -408,251 +437,350 @@ fun AppEntrypoint(
onRetryConnection: () -> Unit, onRetryConnection: () -> Unit,
onUpdateNextDestination: (String) -> 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() val navController = rememberNavController()
RevoltTheme( RevoltTheme(
requestedTheme = LoadedSettings.theme, requestedTheme = LoadedSettings.theme,
colourOverrides = SyncedSettings.android.colourOverrides colourOverrides = SyncedSettings.android.colourOverrides
) { ) {
Surface( Box(
modifier = Modifier Modifier
.fillMaxSize(), .fillMaxSize()
color = MaterialTheme.colorScheme.background .background(MaterialTheme.colorScheme.surfaceContainerLowest)
) { ) {
if (isHealthAlertActive) { Surface(
healthNotice?.let { modifier = Modifier
HealthAlert(notice = healthNotice, onDismiss = onDismissHealthAlert) .fillMaxSize()
} .scale(chatUIScale)
} .alpha(chatUIOpacity),
color = MaterialTheme.colorScheme.background
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)
}
) { ) {
composable("default") { if (isHealthAlertActive) {
DefaultDestinationScreen( healthNotice?.let {
navController, HealthAlert(notice = healthNotice, onDismiss = onDismissHealthAlert)
nextDestination, }
isConnected,
onRetryConnection
)
} }
composable("login/greeting") { LoginGreetingScreen(navController) } if (couldNotLogIn) {
composable("login/login") { LoginScreen(navController) } AlertDialog(
composable("login/mfa/{mfaTicket}/{allowedAuthTypes}") { backStackEntry -> onDismissRequest = {
val mfaTicket = backStackEntry.arguments?.getString("mfaTicket") ?: "" // no-op
val allowedAuthTypes = },
backStackEntry.arguments?.getString("allowedAuthTypes") ?: "" title = {
Text(stringResource(R.string.could_not_log_in_heading))
MfaScreen(navController, allowedAuthTypes, mfaTicket) },
} text = {
Text(stringResource(R.string.could_not_log_in_body))
composable("register/greeting") { RegisterGreetingScreen(navController) } },
composable("register/details") { RegisterDetailsScreen(navController) } confirmButton = {
composable("register/verify/{email}") { backStackEntry -> TextButton(
val email = backStackEntry.arguments?.getString("email") ?: "" onClick = {
onDismissLoginError()
RegisterVerifyScreen(navController, email) onRetryConnection()
} }
composable("register/onboarding") { ) {
OnboardingScreen( Text(stringResource(R.string.could_not_log_in_cta_try_again))
navController, }
onOnboardingComplete = { },
onUpdateNextDestination("chat") dismissButton = {
navController.popBackStack( TextButton(
navController.graph.startDestinationRoute!!, onClick = {
inclusive = true onDismissLoginError()
) onLogout()
navController.navigate("default") }
) {
Text(stringResource(R.string.could_not_log_in_cta_logout))
}
} }
) )
} }
composable("login2/init") { InitScreen(navController, windowSizeClass) } NavHost(
navController = navController,
// This is only used outside of Polar mode startDestination = "default",
// 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}",
enterTransition = { enterTransition = {
slideIntoContainer( slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left, AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween( animationSpec = NavTweenInt,
600, initialOffset = { it / 3 }
easing = EasingTokens.EmphasizedDecelerate ) + fadeIn(animationSpec = NavTweenFloat)
),
initialOffset = { it }
) + fadeIn(animationSpec = RevoltTweenFloat)
}, },
exitTransition = { exitTransition = {
slideOutOfContainer( slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = NavTweenInt,
targetOffset = { it / 3 }
) + fadeOut(animationSpec = NavTweenFloat)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right, AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween( animationSpec = NavTweenInt,
600, initialOffset = { it / 3 }
easing = EasingTokens.EmphasizedDecelerate ) + fadeIn(animationSpec = NavTweenFloat)
), },
targetOffset = { it } popExitTransition = {
) + fadeOut(animationSpec = RevoltTweenFloat) slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = NavTweenInt,
targetOffset = { it / 2 }
) + fadeOut(animationSpec = NavTweenFloat)
} }
) { backStackEntry -> ) {
val channelId = backStackEntry.arguments?.getString("channelId") ?: "" composable("default") {
ChannelScreen( DefaultDestinationScreen(
channelId = channelId, navController,
onToggleDrawer = {}, nextDestination,
useDrawer = false, isConnected,
useBackButton = true, onRetryConnection
backButtonAction = { )
navController.popBackStack() }
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 java.net.SocketException
import chat.revolt.api.schemas.Channel as ChannelSchema 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_SUPPORT = "https://support.revolt.chat"
const val REVOLT_MARKETING = "https://revolt.chat" const val REVOLT_MARKETING = "https://revolt.chat"
const val REVOLT_FILES = "https://cdn.revoltusercontent.com" val REVOLT_FILES =
const val REVOLT_JANUARY = "https://jan.revolt.chat" 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_APP = "https://app.revolt.chat"
const val REVOLT_INVITES = "https://rvlt.gg" 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" const val REVOLT_KJBOOK = "https://revoltchat.github.io/android"
fun String.api(): String { fun String.api(): String {
@ -68,7 +74,9 @@ fun String.api(): String {
} }
fun buildUserAgent(accessMethod: String = "Ktor"): 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) @OptIn(ExperimentalSerializationApi::class)

View File

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

View File

@ -53,6 +53,7 @@ import io.ktor.websocket.readText
import io.ktor.websocket.send import io.ktor.websocket.send
import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.channels.consumeEach
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import logcat.logcat
enum class DisconnectionState { enum class DisconnectionState {
Disconnected, Disconnected,
@ -149,10 +150,14 @@ object RealtimeSocket {
"Ready" -> { "Ready" -> {
val readyFrame = RevoltJson.decodeFromString(ReadyFrame.serializer(), rawFrame) val readyFrame = RevoltJson.decodeFromString(ReadyFrame.serializer(), rawFrame)
Log.d(
"RealtimeSocket", logcat {
"Received ready frame with ${readyFrame.users.size} users, ${readyFrame.servers.size} servers, ${readyFrame.channels.size} channels, and ${readyFrame.emojis.size} emojis." "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.") Log.d("RealtimeSocket", "Adding users to cache.")
val userMap = readyFrame.users.associateBy { it.id!! } val userMap = readyFrame.users.associateBy { it.id!! }

View File

@ -1,6 +1,7 @@
package chat.revolt.api.realtime.frames.receivable package chat.revolt.api.realtime.frames.receivable
import chat.revolt.api.schemas.Channel import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.ChannelVoiceState
import chat.revolt.api.schemas.Embed import chat.revolt.api.schemas.Embed
import chat.revolt.api.schemas.Emoji import chat.revolt.api.schemas.Emoji
import chat.revolt.api.schemas.Member import chat.revolt.api.schemas.Member
@ -42,7 +43,8 @@ data class ReadyFrame(
val users: List<User>, val users: List<User>,
val servers: List<Server>, val servers: List<Server>,
val channels: List<Channel>, val channels: List<Channel>,
val emojis: List<Emoji> val emojis: List<Emoji>,
@SerialName("voice_states") val voiceStates: List<ChannelVoiceState> = listOf(),
) )
typealias MessageFrame = Message typealias MessageFrame = Message

View File

@ -18,36 +18,48 @@ data class Root(
@Serializable @Serializable
data class Features( data class Features(
val captcha: CAPTCHA, val captcha: CAPTCHAFeature,
val email: Boolean, val email: Boolean,
@SerialName("invite_only") val inviteOnly: Boolean,
@SerialName("invite_only") val autumn: AutumnJanuaryFeature,
val inviteOnly: Boolean, val january: AutumnJanuaryFeature,
val voso: LegacyVoiceFeature? = null,
val autumn: Autumn, val livekit: LiveKitFeature? = null,
val january: Autumn,
val voso: Voso
) )
@Serializable @Serializable
data class Autumn( data class AutumnJanuaryFeature(
val enabled: Boolean, val enabled: Boolean,
val url: String val url: String
) )
@Serializable @Serializable
data class CAPTCHA( data class CAPTCHAFeature(
val enabled: Boolean, val enabled: Boolean,
val key: String val key: String
) )
@Serializable @Serializable
data class Voso( data class LegacyVoiceFeature(
val enabled: Boolean, val enabled: Boolean,
val url: String, val url: String,
val ws: 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 { suspend fun getRootRoute(): Root {
return RevoltHttp.get("/".api()).body() 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.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.revolt.R import chat.revolt.R
@ -29,10 +30,16 @@ import chat.revolt.api.schemas.User
import chat.revolt.composables.generic.UserAvatar import chat.revolt.composables.generic.UserAvatar
@Composable @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( Box(
modifier = Modifier 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 -> users.take(amount).forEachIndexed { index, userId ->
val user = RevoltAPI.userCache[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) } username = user?.let { User.resolveDefaultName(it) }
?: stringResource(id = R.string.unknown), ?: stringResource(id = R.string.unknown),
rawUrl = maybeMember?.avatar?.let { "$REVOLT_FILES/avatars/${it.id}" }, rawUrl = maybeMember?.avatar?.let { "$REVOLT_FILES/avatars/${it.id}" },
size = 16.dp, size = size,
modifier = Modifier modifier = Modifier
.offset( .offset(
x = (index * 8).dp x = (index * offset.value).dp
) )
) )
} }

View File

@ -1,7 +1,9 @@
package chat.revolt.composables.screens.chat.drawer package chat.revolt.composables.screens.chat.drawer
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring 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.padding
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
@ -96,6 +100,7 @@ import chat.revolt.composables.generic.UserAvatar
import chat.revolt.composables.generic.presenceFromStatus import chat.revolt.composables.generic.presenceFromStatus
import chat.revolt.composables.screens.chat.ChannelIcon import chat.revolt.composables.screens.chat.ChannelIcon
import chat.revolt.screens.chat.ChatRouterDestination import chat.revolt.screens.chat.ChatRouterDestination
import chat.revolt.screens.chat.LocalIsConnected
import chat.revolt.sheets.ChannelContextSheet import chat.revolt.sheets.ChannelContextSheet
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -154,6 +159,17 @@ fun ChannelSideDrawer(
), label = "Server banner height" ), 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. // - 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. // - 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. // - Add the servers that aren't in the ordering to the end of the list.
@ -202,30 +218,40 @@ fun ChannelSideDrawer(
) )
) { ) {
stickyHeader(key = "self") { stickyHeader(key = "self") {
UserAvatar( Column(Modifier.background(MaterialTheme.colorScheme.background)) {
username = RevoltAPI.userCache[RevoltAPI.selfId]?.let { AnimatedVisibility(LocalIsConnected.current) {
User.resolveDefaultName( Spacer(
it Modifier
.height(
WindowInsets.statusBars.asPaddingValues()
.calculateTopPadding()
)
) )
} }
?: "", UserAvatar(
presence = presenceFromStatus( username = RevoltAPI.userCache[RevoltAPI.selfId]?.let {
RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence, User.resolveDefaultName(
RevoltAPI.userCache[RevoltAPI.selfId]?.online ?: false it
), )
userId = RevoltAPI.selfId ?: "", }
avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar, ?: "",
size = 48.dp, presence = presenceFromStatus(
presenceSize = 16.dp, RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence,
onClick = { RevoltAPI.userCache[RevoltAPI.selfId]?.online ?: false
onDestinationChanged(ChatRouterDestination.defaultForDMList) ),
}, userId = RevoltAPI.selfId ?: "",
onLongClick = onLongPressAvatar, avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar,
modifier = Modifier size = 48.dp,
.background(MaterialTheme.colorScheme.background) presenceSize = 16.dp,
.padding(8.dp) onClick = {
.size(48.dp) onDestinationChanged(ChatRouterDestination.defaultForDMList)
) },
onLongClick = onLongPressAvatar,
modifier = Modifier
.padding(8.dp)
.size(48.dp)
)
}
} }
items( items(
@ -314,12 +340,12 @@ fun ChannelSideDrawer(
) )
val leftIndicatorColour = animateColorAsState( val leftIndicatorColour = animateColorAsState(
targetValue = targetValue =
if (serverInList.id == currentServer) if (serverInList.id == currentServer)
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary
else if (serverHasUnread) else if (serverHasUnread)
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
else else
Color.Transparent, Color.Transparent,
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessLow
@ -433,27 +459,30 @@ fun ChannelSideDrawer(
} }
Column( Column(
Modifier Modifier
.clip(
MaterialTheme.shapes.extraLarge.copy(
bottomEnd = CornerSize(0)
)
)
.background(MaterialTheme.colorScheme.surfaceContainer) .background(MaterialTheme.colorScheme.surfaceContainer)
.weight(1f) .weight(1f)
.fillMaxHeight() .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) { if (server?.banner != null) {
RemoteImage( RemoteImage(
url = "$REVOLT_FILES/banners/${server.banner.id}", url = "$REVOLT_FILES/banners/${server.banner.id}",
description = null, description = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.clip(
MaterialTheme.shapes.medium.copy(
topStart = CornerSize(0), topEnd = CornerSize(0)
)
)
.fillMaxSize() .fillMaxSize()
) )
@ -465,7 +494,7 @@ fun ChannelSideDrawer(
drawRect( drawRect(
Brush.linearGradient( Brush.linearGradient(
listOf( listOf(
surfaceContainer.copy(alpha = 0.8f), Color.Black.copy(alpha = 0.6f),
Color.Transparent Color.Transparent
), ),
Offset.Zero, Offset.Zero,
@ -477,63 +506,72 @@ fun ChannelSideDrawer(
} }
Row( Row(
Modifier.padding(16.dp), Modifier
.padding(16.dp)
.offset(y = serverInfoOffset),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row( CompositionLocalProvider(
Modifier.weight(1f), LocalContentColor provides
verticalAlignment = Alignment.CenterVertically, if (server?.banner != null) Color.White
horizontalArrangement = Arrangement.spacedBy(8.dp) else LocalContentColor.current
) { ) {
if (server?.flags has ServerFlags.Official) { Row(
Icon( Modifier.weight(1f),
painter = painterResource( verticalAlignment = Alignment.CenterVertically,
id = R.drawable.ic_revolt_decagram_24dp horizontalArrangement = Arrangement.spacedBy(8.dp)
), ) {
contentDescription = stringResource( if (server?.flags has ServerFlags.Official) {
R.string.server_flag_official Icon(
), painter = painterResource(
tint = LocalContentColor.current, id = R.drawable.ic_revolt_decagram_24dp
modifier = Modifier ),
.size(24.dp) contentDescription = stringResource(
) R.string.server_flag_official
} ),
if (server?.flags has ServerFlags.Verified) { tint = LocalContentColor.current,
Icon( modifier = Modifier
painter = painterResource( .size(24.dp)
id = R.drawable.ic_check_decagram_24dp )
), }
contentDescription = stringResource( if (server?.flags has ServerFlags.Verified) {
R.string.server_flag_verified Icon(
), painter = painterResource(
tint = LocalContentColor.current, id = R.drawable.ic_check_decagram_24dp
modifier = Modifier ),
.size(24.dp) 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( if (currentServer != null) {
text = when (currentServer) { IconButton(onClick = {
null -> stringResource(R.string.direct_messages) server?.id?.let { srvId -> onShowServerContextSheet(srvId) }
else -> server?.name ?: stringResource(R.string.unknown) }) {
}, Icon(
style = MaterialTheme.typography.titleMedium, imageVector = Icons.Default.MoreVert,
maxLines = 1, contentDescription = stringResource(R.string.menu),
overflow = TextOverflow.Ellipsis tint = LocalContentColor.current
) )
} }
} else {
if (currentServer != null) { Spacer(Modifier.height(64.dp))
IconButton(onClick = {
server?.id?.let { srvId -> onShowServerContextSheet(srvId) }
}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.menu)
)
} }
} else {
Spacer(Modifier.height(64.dp))
} }
} }
} }
@ -762,7 +800,8 @@ fun ColumnScope.ServerChannelListRenderer(
items(categorisedChannels?.size ?: 0) { items(categorisedChannels?.size ?: 0) {
when (val channelOrCat = categorisedChannels?.get(it)) { when (val channelOrCat = categorisedChannels?.get(it)) {
is CategorisedChannelList.Channel -> { is CategorisedChannelList.Channel -> {
ChannelItem(channel = channelOrCat.channel, ChannelItem(
channel = channelOrCat.channel,
isCurrent = when (currentDestination) { isCurrent = when (currentDestination) {
is ChatRouterDestination.Channel -> { is ChatRouterDestination.Channel -> {
currentDestination.channelId == channelOrCat.channel.id 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), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
modifier = Modifier modifier = Modifier
.padding(start = 8.dp, end = 8.dp) .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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.AlertDialog 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.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -70,7 +77,6 @@ import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel import chat.revolt.callbacks.ActionChannel
import chat.revolt.composables.chat.DisconnectedNotice import chat.revolt.composables.chat.DisconnectedNotice
import chat.revolt.composables.screens.chat.drawer.ChannelSideDrawer import chat.revolt.composables.screens.chat.drawer.ChannelSideDrawer
import chat.revolt.composables.screens.voice.VoiceChannelOverlay
import chat.revolt.dialogs.NotificationRationaleDialog import chat.revolt.dialogs.NotificationRationaleDialog
import chat.revolt.internals.Changelogs import chat.revolt.internals.Changelogs
import chat.revolt.internals.extensions.zero import chat.revolt.internals.extensions.zero
@ -261,12 +267,16 @@ class ChatRouterViewModel @Inject constructor(
} }
} }
val LocalIsConnected = compositionLocalOf(structuralEqualityPolicy()) { false }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatRouterScreen( fun ChatRouterScreen(
topNav: NavController, topNav: NavController,
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
disableBackHandler: Boolean,
onNullifiedUser: () -> Unit, onNullifiedUser: () -> Unit,
onEnterVoiceUI: (String) -> Unit,
viewModel: ChatRouterViewModel = hiltViewModel() viewModel: ChatRouterViewModel = hiltViewModel()
) { ) {
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@ -274,6 +284,8 @@ fun ChatRouterScreen(
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
var drawerWidth by remember { mutableFloatStateOf(0.0f) }
var showPlatformModDMHint by remember { mutableStateOf(false) } var showPlatformModDMHint by remember { mutableStateOf(false) }
var showStatusSheet by remember { mutableStateOf(false) } var showStatusSheet by remember { mutableStateOf(false) }
@ -302,9 +314,6 @@ fun ChatRouterScreen(
var useTabletAwareUI by remember { mutableStateOf(false) } var useTabletAwareUI by remember { mutableStateOf(false) }
var voiceChannelOverlay by remember { mutableStateOf(false) }
var voiceChannelOverlayChannelId by remember { mutableStateOf("") }
var showReportUser by remember { mutableStateOf(false) } var showReportUser by remember { mutableStateOf(false) }
var reportUserTarget by remember { mutableStateOf("") } var reportUserTarget by remember { mutableStateOf("") }
@ -444,8 +453,7 @@ fun ChatRouterScreen(
} }
is Action.OpenVoiceChannelOverlay -> { is Action.OpenVoiceChannelOverlay -> {
voiceChannelOverlayChannelId = action.channelId onEnterVoiceUI(action.channelId)
voiceChannelOverlay = true
} }
is Action.OpenWebhookSheet -> { is Action.OpenWebhookSheet -> {
@ -712,12 +720,6 @@ fun ChatRouterScreen(
} }
} }
if (voiceChannelOverlay) {
VoiceChannelOverlay(voiceChannelOverlayChannelId) {
voiceChannelOverlay = false
}
}
val askNotificationsPermission = val askNotificationsPermission =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) { if (isGranted) {
@ -812,53 +814,11 @@ fun ChatRouterScreen(
) )
} }
AnimatedVisibility( CompositionLocalProvider(
visible = RealtimeSocket.disconnectionState == DisconnectionState.Connected LocalIsConnected provides (RealtimeSocket.disconnectionState == DisconnectionState.Connected)
) { ) {
Spacer(Modifier.windowInsetsPadding(WindowInsets.statusBars)) if (useTabletAwareUI) {
} Row {
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 = {
DismissibleDrawerSheet( DismissibleDrawerSheet(
drawerContainerColor = Color.Transparent, drawerContainerColor = Color.Transparent,
windowInsets = WindowInsets.zero windowInsets = WindowInsets.zero
@ -881,28 +841,104 @@ fun ChatRouterScreen(
onOpenSettings = { onOpenSettings = {
topNav.navigate("settings") 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, toggleDrawer: () -> Unit,
drawerState: DrawerState? = null, drawerState: DrawerState? = null,
drawerGestureEnabled: Boolean = true, drawerGestureEnabled: Boolean = true,
disableBackHandler: Boolean = false,
onEnterVoiceUI: (String) -> Unit = {},
setDrawerGestureEnabled: (Boolean) -> Unit = {}, setDrawerGestureEnabled: (Boolean) -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
BackHandler(enabled = useDrawer) { BackHandler(useDrawer && !disableBackHandler) {
toggleDrawer() toggleDrawer()
} }

View File

@ -1,104 +1,47 @@
package chat.revolt.screens.chat.views package chat.revolt.screens.chat.views
import android.content.Context import androidx.compose.animation.AnimatedVisibility
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight 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.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn 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.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.Menu
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api 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.Icon
import androidx.compose.material3.IconButton 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.Scaffold
import androidx.compose.material3.Text 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.TopAppBar
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.painterResource
import androidx.compose.ui.res.stringResource 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import chat.revolt.R import chat.revolt.R
import chat.revolt.api.internals.FriendRequests 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.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.Action
import chat.revolt.callbacks.ActionChannel 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.chat.MemberListItem
import chat.revolt.composables.generic.CountableListHeader import chat.revolt.composables.generic.CountableListHeader
import chat.revolt.composables.generic.UserAvatar
import chat.revolt.internals.extensions.zero 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -470,6 +413,16 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: ()
Scaffold( Scaffold(
topBar = { topBar = {
Column {
AnimatedVisibility(LocalIsConnected.current) {
Spacer(
Modifier
.height(
WindowInsets.statusBars.asPaddingValues()
.calculateTopPadding()
)
)
}
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
@ -525,14 +478,15 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: ()
}, },
windowInsets = WindowInsets.zero windowInsets = WindowInsets.zero
) )
} }
},
) { pv -> ) { pv ->
Box( Column(
modifier = Modifier modifier = Modifier
.padding(pv) .padding(pv)
.fillMaxHeight() .fillMaxHeight()
) { ) {
LazyColumn(state = listState) { LazyColumn {
stickyHeader(key = "incoming") { stickyHeader(key = "incoming") {
CountableListHeader( CountableListHeader(
text = stringResource(id = R.string.friends_incoming_requests), text = stringResource(id = R.string.friends_incoming_requests),

View File

@ -1,10 +1,15 @@
package chat.revolt.screens.chat.views package chat.revolt.screens.chat.views
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -23,28 +28,40 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.revolt.R import chat.revolt.R
import chat.revolt.internals.extensions.zero import chat.revolt.internals.extensions.zero
import chat.revolt.screens.chat.LocalIsConnected
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun NoCurrentChannelScreen(useDrawer: Boolean, onDrawerClicked: () -> Unit) { fun NoCurrentChannelScreen(useDrawer: Boolean, onDrawerClicked: () -> Unit) {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( Column {
title = {}, AnimatedVisibility(LocalIsConnected.current) {
navigationIcon = { Spacer(
if (useDrawer) { Modifier
IconButton(onClick = { .height(
onDrawerClicked() WindowInsets.statusBars.asPaddingValues()
}) { .calculateTopPadding()
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(id = R.string.menu)
) )
)
}
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 -> ) { pv ->
Column( Column(

View File

@ -13,11 +13,13 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.material.icons.Icons 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.screens.settings.UserOverview
import chat.revolt.composables.skeletons.UserOverviewSkeleton import chat.revolt.composables.skeletons.UserOverviewSkeleton
import chat.revolt.internals.extensions.zero import chat.revolt.internals.extensions.zero
import chat.revolt.screens.chat.LocalIsConnected
import chat.revolt.sheets.UserCardSheet import chat.revolt.sheets.UserCardSheet
import io.sentry.Sentry import io.sentry.Sentry
@ -110,22 +113,33 @@ fun OverviewScreen(
Scaffold( Scaffold(
topBar = { topBar = {
CenterAlignedTopAppBar( Column {
title = { Text(stringResource(R.string.overview_screen_title)) }, AnimatedVisibility(LocalIsConnected.current) {
navigationIcon = { Spacer(
if (useDrawer) { Modifier
IconButton(onClick = { .height(
onDrawerClicked() WindowInsets.statusBars.asPaddingValues()
}) { .calculateTopPadding()
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(id = R.string.menu)
) )
)
}
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( contentWindowInsets = if (includePadding) ScaffoldDefaults.contentWindowInsets else ScaffoldDefaults.contentWindowInsets.exclude(
NavigationBarDefaults.windowInsets 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -91,6 +92,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity 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.ReplyManager
import chat.revolt.composables.screens.chat.TypingIndicator import chat.revolt.composables.screens.chat.TypingIndicator
import chat.revolt.composables.screens.chat.atoms.RegularMessage 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.MessageSkeleton
import chat.revolt.composables.skeletons.MessageSkeletonVariant import chat.revolt.composables.skeletons.MessageSkeletonVariant
import chat.revolt.internals.extensions.rememberChannelPermissions import chat.revolt.internals.extensions.rememberChannelPermissions
import chat.revolt.internals.extensions.zero import chat.revolt.internals.extensions.zero
import chat.revolt.screens.chat.LocalIsConnected
import chat.revolt.sheets.ChannelInfoSheet import chat.revolt.sheets.ChannelInfoSheet
import chat.revolt.sheets.MessageContextSheet import chat.revolt.sheets.MessageContextSheet
import chat.revolt.sheets.ReactSheet import chat.revolt.sheets.ReactSheet
@ -512,126 +516,137 @@ fun ChannelScreen(
Scaffold( Scaffold(
contentWindowInsets = WindowInsets.zero, contentWindowInsets = WindowInsets.zero,
topBar = { topBar = {
TopAppBar( Column {
modifier = Modifier.clickable { AnimatedVisibility(LocalIsConnected.current) {
channelInfoSheetShown = true Spacer(
}, Modifier
title = { .height(
Row( WindowInsets.statusBars.asPaddingValues()
verticalAlignment = Alignment.CenterVertically, .calculateTopPadding()
horizontalArrangement = Arrangement.spacedBy(8.dp) )
) { )
viewModel.channel?.let { }
when (it.channelType) { TopAppBar(
ChannelType.DirectMessage -> { 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 = val partner =
RevoltAPI.userCache[ChannelUtils.resolveDMPartner(it)] RevoltAPI.userCache[ChannelUtils.resolveDMPartner(it)]
UserAvatar( PresenceBadge(
username = it.name ?: stringResource(R.string.unknown), presence = presenceFromStatus(
userId = ChannelUtils.resolveDMPartner(it) ?: "", partner?.status?.presence,
size = 24.dp, online = partner?.online == true
presenceSize = 12.dp, ),
avatar = partner?.avatar size = 12.dp
) )
} }
ChannelType.Group -> { Icon(
GroupIcon( imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
name = it.name ?: stringResource(R.string.unknown), contentDescription = null,
size = 24.dp, modifier = Modifier
icon = it.icon .size(16.dp)
) .alpha(0.5f)
} )
}
else -> { }
ChannelIcon( },
channelType = it.channelType ?: ChannelType.TextChannel, windowInsets = if (useChatUI) WindowInsets.statusBars else WindowInsets.zero,
modifier = Modifier navigationIcon = {
.size(24.dp) if (useDrawer) {
.alpha(0.8f) IconButton(onClick = onToggleDrawer) {
) Icon(
} imageVector = Icons.Default.Menu,
} contentDescription = stringResource(id = R.string.menu)
)
CompositionLocalProvider( }
LocalTextStyle provides LocalTextStyle.current.copy( }
fontSize = 20.sp, if (useBackButton) {
lineHeightStyle = LineHeightStyle( IconButton(onClick = backButtonAction ?: {}) {
alignment = LineHeightStyle.Alignment.Bottom, Icon(
trim = LineHeightStyle.Trim.LastLineBottom imageVector = Icons.AutoMirrored.Default.ArrowBack,
) contentDescription = stringResource(id = R.string.back)
)
) {
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)
)
}
}
}
)
} }
) { pv -> ) { pv ->
Crossfade( Crossfade(
@ -847,74 +862,83 @@ fun ChannelScreen(
} }
} }
if (viewModel.showPhysicalKeyboardSpark) { Column(
Card( verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(8.dp) .padding(8.dp)
) { ) {
Column( if (viewModel.showPhysicalKeyboardSpark) {
verticalArrangement = Arrangement.spacedBy(8.dp), Card {
modifier = Modifier.padding(16.dp) Column(
) { verticalArrangement = Arrangement.spacedBy(8.dp),
Text( modifier = Modifier.padding(16.dp)
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),
) { ) {
Button( Text(
onClick = { stringResource(R.string.spark_keyboard_shortcuts),
viewModel.dismissPhysicalKeyboardSpark() 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)) Button(
} onClick = {
TextButton( viewModel.dismissPhysicalKeyboardSpark()
onClick = { },
(context as Activity).requestShowKeyboardShortcuts() modifier = Modifier.weight(1f)
}, ) {
modifier = Modifier.weight(1f) Text(stringResource(R.string.spark_keyboard_shortcuts_dismiss))
) { }
Text(stringResource(R.string.spark_keyboard_shortcuts_cta)) 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.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.util.fastDistinctBy
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import chat.revolt.R import chat.revolt.R
@ -876,7 +877,14 @@ class ChannelScreenViewModel @Inject constructor(
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
items.clear() 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_search_results_header">Search results</string>
<string name="emoji_picker_clear_search">Clear search</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">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_description">Enable notifications to be kept up to date with messages and mentions.</string>
<string name="spark_notifications_rationale_cta">Enable notifications</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_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_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_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_conversations">Conversations</string>
<string name="notification_channel_group_social">Friends and Social</string> <string name="notification_channel_group_social">Friends and Social</string>

View File

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