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:
commit
efa103c5e5
|
|
@ -27,8 +27,15 @@ val hiltVersion = "2.52"
|
|||
val glideVersion = "4.16.0"
|
||||
val ktorVersion = "3.0.0-beta-2"
|
||||
val media3Version = "1.7.1"
|
||||
val livekitVersion = "2.2.0"
|
||||
val material3Version = "1.4.0-alpha15"
|
||||
val media3Version = "1.5.0"
|
||||
|
||||
object LivekitVersion {
|
||||
val core = "2.16.0"
|
||||
val componentsCompose = "1.3.1"
|
||||
}
|
||||
|
||||
val material3Version = "1.4.0-alpha10"
|
||||
val androidXTestVersion = "1.6.1"
|
||||
|
||||
fun property(fileName: String, propertyName: String, fallbackEnv: String? = null): String? {
|
||||
|
|
@ -132,6 +139,12 @@ android {
|
|||
"FLAVOUR_ID",
|
||||
"\"${buildproperty("build.flavour_id", "RVX_BUILD_FLAVOUR_ID")}\""
|
||||
)
|
||||
|
||||
buildConfigField(
|
||||
"boolean",
|
||||
"USE_ALPHA_API",
|
||||
"${buildproperty("dev.use_alpha_api", "RVX_DEV_USE_ALPHA_API")}"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
|
|
@ -291,8 +304,8 @@ dependencies {
|
|||
implementation("dev.snipme:highlights:1.0.0")
|
||||
|
||||
// Livekit
|
||||
// FIXME temporarily not included, re-add when realtime media is to be implemented
|
||||
// implementation "io.livekit:livekit-android:$livekit_version"
|
||||
implementation("io.livekit:livekit-android:${LivekitVersion.core}")
|
||||
implementation("io.livekit:livekit-android-compose-components:${LivekitVersion.componentsCompose}")
|
||||
|
||||
// Firebase - Cloud Messaging
|
||||
implementation(platform("com.google.firebase:firebase-bom:33.15.0"))
|
||||
|
|
|
|||
|
|
@ -13,18 +13,32 @@ import android.view.Menu
|
|||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.EaseInOutExpo
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -33,14 +47,25 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz
|
|||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
|
@ -61,6 +86,8 @@ import chat.revolt.api.settings.Experiments
|
|||
import chat.revolt.api.settings.LoadedSettings
|
||||
import chat.revolt.api.settings.SyncedSettings
|
||||
import chat.revolt.composables.generic.HealthAlert
|
||||
import chat.revolt.composables.voice.VoicePermissionSwitch
|
||||
import chat.revolt.composables.voice.VoiceSheet
|
||||
import chat.revolt.material.EasingTokens
|
||||
import chat.revolt.ndk.NativeLibraries
|
||||
import chat.revolt.persistence.KVStorage
|
||||
|
|
@ -394,6 +421,8 @@ val RevoltTweenColour: FiniteAnimationSpec<Color> = tween(400, easing = EaseInOu
|
|||
val NavTweenInt: FiniteAnimationSpec<IntOffset> = tween(350, easing = EaseInOutExpo)
|
||||
val NavTweenFloat: FiniteAnimationSpec<Float> = tween(350, easing = EaseInOutExpo)
|
||||
|
||||
// This composable handles the main compose entrypoint of the app, provides the main navigation
|
||||
// graph, and handles the animation and layout for the voice chat UI.
|
||||
@Composable
|
||||
fun AppEntrypoint(
|
||||
windowSizeClass: WindowSizeClass,
|
||||
|
|
@ -408,251 +437,350 @@ fun AppEntrypoint(
|
|||
onRetryConnection: () -> Unit,
|
||||
onUpdateNextDestination: (String) -> Unit = {}
|
||||
) {
|
||||
var showVoiceUI by rememberSaveable { mutableStateOf(false) }
|
||||
var voiceChannelId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val chatUIScale by animateFloatAsState(
|
||||
if (showVoiceUI) 0.8f else 1.0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
)
|
||||
)
|
||||
val chatUIOpacity by animateFloatAsState(
|
||||
if (showVoiceUI) 0.8f else 1.0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
)
|
||||
)
|
||||
|
||||
BackHandler(showVoiceUI) {
|
||||
showVoiceUI = false
|
||||
}
|
||||
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
LaunchedEffect(showVoiceUI) {
|
||||
if (showVoiceUI) keyboardController?.hide()
|
||||
}
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
RevoltTheme(
|
||||
requestedTheme = LoadedSettings.theme,
|
||||
colourOverrides = SyncedSettings.android.colourOverrides
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||
) {
|
||||
if (isHealthAlertActive) {
|
||||
healthNotice?.let {
|
||||
HealthAlert(notice = healthNotice, onDismiss = onDismissHealthAlert)
|
||||
}
|
||||
}
|
||||
|
||||
if (couldNotLogIn) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
// no-op
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.could_not_log_in_heading))
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.could_not_log_in_body))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismissLoginError()
|
||||
onRetryConnection()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.could_not_log_in_cta_try_again))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismissLoginError()
|
||||
onLogout()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.could_not_log_in_cta_logout))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "default",
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = NavTweenInt,
|
||||
initialOffset = { it / 3 }
|
||||
) + fadeIn(animationSpec = NavTweenFloat)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = NavTweenInt,
|
||||
targetOffset = { it / 3 }
|
||||
) + fadeOut(animationSpec = NavTweenFloat)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = NavTweenInt,
|
||||
initialOffset = { it / 3 }
|
||||
) + fadeIn(animationSpec = NavTweenFloat)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = NavTweenInt,
|
||||
targetOffset = { it / 2 }
|
||||
) + fadeOut(animationSpec = NavTweenFloat)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.scale(chatUIScale)
|
||||
.alpha(chatUIOpacity),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
composable("default") {
|
||||
DefaultDestinationScreen(
|
||||
navController,
|
||||
nextDestination,
|
||||
isConnected,
|
||||
onRetryConnection
|
||||
)
|
||||
if (isHealthAlertActive) {
|
||||
healthNotice?.let {
|
||||
HealthAlert(notice = healthNotice, onDismiss = onDismissHealthAlert)
|
||||
}
|
||||
}
|
||||
|
||||
composable("login/greeting") { LoginGreetingScreen(navController) }
|
||||
composable("login/login") { LoginScreen(navController) }
|
||||
composable("login/mfa/{mfaTicket}/{allowedAuthTypes}") { backStackEntry ->
|
||||
val mfaTicket = backStackEntry.arguments?.getString("mfaTicket") ?: ""
|
||||
val allowedAuthTypes =
|
||||
backStackEntry.arguments?.getString("allowedAuthTypes") ?: ""
|
||||
|
||||
MfaScreen(navController, allowedAuthTypes, mfaTicket)
|
||||
}
|
||||
|
||||
composable("register/greeting") { RegisterGreetingScreen(navController) }
|
||||
composable("register/details") { RegisterDetailsScreen(navController) }
|
||||
composable("register/verify/{email}") { backStackEntry ->
|
||||
val email = backStackEntry.arguments?.getString("email") ?: ""
|
||||
|
||||
RegisterVerifyScreen(navController, email)
|
||||
}
|
||||
composable("register/onboarding") {
|
||||
OnboardingScreen(
|
||||
navController,
|
||||
onOnboardingComplete = {
|
||||
onUpdateNextDestination("chat")
|
||||
navController.popBackStack(
|
||||
navController.graph.startDestinationRoute!!,
|
||||
inclusive = true
|
||||
)
|
||||
navController.navigate("default")
|
||||
if (couldNotLogIn) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
// no-op
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.could_not_log_in_heading))
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.could_not_log_in_body))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismissLoginError()
|
||||
onRetryConnection()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.could_not_log_in_cta_try_again))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismissLoginError()
|
||||
onLogout()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.could_not_log_in_cta_logout))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable("login2/init") { InitScreen(navController, windowSizeClass) }
|
||||
|
||||
// This is only used outside of Polar mode
|
||||
// Otherwise you may be looking for "main" right below
|
||||
composable(
|
||||
"chat",
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Up,
|
||||
animationSpec = tween(
|
||||
400,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
),
|
||||
initialOffset = { it / 3 }
|
||||
) + fadeIn(animationSpec = RevoltTweenFloat)
|
||||
}
|
||||
) {
|
||||
ChatRouterScreen(
|
||||
navController,
|
||||
windowSizeClass,
|
||||
onNullifiedUser = {
|
||||
onRetryConnection()
|
||||
navController.popBackStack(
|
||||
navController.graph.startDestinationRoute!!,
|
||||
inclusive = true
|
||||
)
|
||||
navController.navigate("default")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// This is only the main screen in Polar mode
|
||||
// Otherwise you may be looking for "chat" right above
|
||||
composable(
|
||||
"main",
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Up,
|
||||
animationSpec = tween(
|
||||
400,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
),
|
||||
initialOffset = { it / 3 }
|
||||
) + fadeIn(animationSpec = RevoltTweenFloat) + scaleIn(
|
||||
animationSpec = tween(
|
||||
400,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
),
|
||||
initialScale = 0.8f,
|
||||
transformOrigin = TransformOrigin.Center
|
||||
)
|
||||
}
|
||||
) {
|
||||
MainScreen(navController)
|
||||
}
|
||||
composable(
|
||||
"main/conversation/{channelId}",
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "default",
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = tween(
|
||||
600,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
),
|
||||
initialOffset = { it }
|
||||
) + fadeIn(animationSpec = RevoltTweenFloat)
|
||||
animationSpec = NavTweenInt,
|
||||
initialOffset = { it / 3 }
|
||||
) + fadeIn(animationSpec = NavTweenFloat)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = NavTweenInt,
|
||||
targetOffset = { it / 3 }
|
||||
) + fadeOut(animationSpec = NavTweenFloat)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = tween(
|
||||
600,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
),
|
||||
targetOffset = { it }
|
||||
) + fadeOut(animationSpec = RevoltTweenFloat)
|
||||
animationSpec = NavTweenInt,
|
||||
initialOffset = { it / 3 }
|
||||
) + fadeIn(animationSpec = NavTweenFloat)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = NavTweenInt,
|
||||
targetOffset = { it / 2 }
|
||||
) + fadeOut(animationSpec = NavTweenFloat)
|
||||
}
|
||||
) { backStackEntry ->
|
||||
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
|
||||
ChannelScreen(
|
||||
channelId = channelId,
|
||||
onToggleDrawer = {},
|
||||
useDrawer = false,
|
||||
useBackButton = true,
|
||||
backButtonAction = {
|
||||
navController.popBackStack()
|
||||
) {
|
||||
composable("default") {
|
||||
DefaultDestinationScreen(
|
||||
navController,
|
||||
nextDestination,
|
||||
isConnected,
|
||||
onRetryConnection
|
||||
)
|
||||
}
|
||||
|
||||
composable("login/greeting") { LoginGreetingScreen(navController) }
|
||||
composable("login/login") { LoginScreen(navController) }
|
||||
composable("login/mfa/{mfaTicket}/{allowedAuthTypes}") { backStackEntry ->
|
||||
val mfaTicket = backStackEntry.arguments?.getString("mfaTicket") ?: ""
|
||||
val allowedAuthTypes =
|
||||
backStackEntry.arguments?.getString("allowedAuthTypes") ?: ""
|
||||
|
||||
MfaScreen(navController, allowedAuthTypes, mfaTicket)
|
||||
}
|
||||
|
||||
composable("register/greeting") { RegisterGreetingScreen(navController) }
|
||||
composable("register/details") { RegisterDetailsScreen(navController) }
|
||||
composable("register/verify/{email}") { backStackEntry ->
|
||||
val email = backStackEntry.arguments?.getString("email") ?: ""
|
||||
|
||||
RegisterVerifyScreen(navController, email)
|
||||
}
|
||||
composable("register/onboarding") {
|
||||
OnboardingScreen(
|
||||
navController,
|
||||
onOnboardingComplete = {
|
||||
onUpdateNextDestination("chat")
|
||||
navController.popBackStack(
|
||||
navController.graph.startDestinationRoute!!,
|
||||
inclusive = true
|
||||
)
|
||||
navController.navigate("default")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable("login2/init") { InitScreen(navController, windowSizeClass) }
|
||||
|
||||
// This is only used outside of Polar mode
|
||||
// Otherwise you may be looking for "main" right below
|
||||
composable(
|
||||
"chat",
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Up,
|
||||
animationSpec = tween(
|
||||
400,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
),
|
||||
initialOffset = { it / 3 }
|
||||
) + fadeIn(animationSpec = RevoltTweenFloat)
|
||||
}
|
||||
) {
|
||||
ChatRouterScreen(
|
||||
navController,
|
||||
windowSizeClass,
|
||||
disableBackHandler = showVoiceUI,
|
||||
onNullifiedUser = {
|
||||
onRetryConnection()
|
||||
navController.popBackStack(
|
||||
navController.graph.startDestinationRoute!!,
|
||||
inclusive = true
|
||||
)
|
||||
navController.navigate("default")
|
||||
},
|
||||
onEnterVoiceUI = { channelId ->
|
||||
showVoiceUI = true
|
||||
voiceChannelId = channelId
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// This is only the main screen in Polar mode
|
||||
// Otherwise you may be looking for "chat" right above
|
||||
composable(
|
||||
"main",
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Up,
|
||||
animationSpec = tween(
|
||||
400,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
),
|
||||
initialOffset = { it / 3 }
|
||||
) + fadeIn(animationSpec = RevoltTweenFloat) + scaleIn(
|
||||
animationSpec = tween(
|
||||
400,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
),
|
||||
initialScale = 0.8f,
|
||||
transformOrigin = TransformOrigin.Center
|
||||
)
|
||||
}
|
||||
) {
|
||||
MainScreen(navController)
|
||||
}
|
||||
composable(
|
||||
"main/conversation/{channelId}",
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||||
animationSpec = tween(
|
||||
600,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
),
|
||||
initialOffset = { it }
|
||||
) + fadeIn(animationSpec = RevoltTweenFloat)
|
||||
},
|
||||
useChatUI = true
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||||
animationSpec = tween(
|
||||
600,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
),
|
||||
targetOffset = { it }
|
||||
) + fadeOut(animationSpec = RevoltTweenFloat)
|
||||
}
|
||||
) { backStackEntry ->
|
||||
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
|
||||
ChannelScreen(
|
||||
channelId = channelId,
|
||||
onToggleDrawer = {},
|
||||
useDrawer = false,
|
||||
useBackButton = true,
|
||||
backButtonAction = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
useChatUI = true
|
||||
)
|
||||
}
|
||||
|
||||
composable("create/group") { CreateGroupScreen(navController) }
|
||||
|
||||
composable("discover") { DiscoverScreen(navController) }
|
||||
|
||||
composable("settings") { SettingsScreen(navController) }
|
||||
composable("settings/profile") { ProfileSettingsScreen(navController) }
|
||||
composable("settings/sessions") { SessionSettingsScreen(navController) }
|
||||
composable("settings/appearance") { AppearanceSettingsScreen(navController) }
|
||||
composable("settings/chat") { ChatSettingsScreen(navController) }
|
||||
composable("settings/debug") { DebugSettingsScreen(navController) }
|
||||
composable("settings/experiments") { ExperimentsSettingsScreen(navController) }
|
||||
composable("settings/changelogs") { ChangelogsSettingsScreen(navController) }
|
||||
composable("settings/language") { LanguagePickerSettingsScreen(navController) }
|
||||
|
||||
composable("settings/channel/{channelId}") { backStackEntry ->
|
||||
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
|
||||
ChannelSettingsHome(navController, channelId)
|
||||
}
|
||||
composable("settings/channel/{channelId}/overview") { backStackEntry ->
|
||||
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
|
||||
ChannelSettingsOverview(navController, channelId)
|
||||
}
|
||||
composable("settings/channel/{channelId}/permissions") { backStackEntry ->
|
||||
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
|
||||
ChannelSettingsPermissions(navController, channelId)
|
||||
}
|
||||
|
||||
composable("about") { AboutScreen(navController) }
|
||||
composable("about/oss") { AttributionScreen(navController) }
|
||||
|
||||
composable("labs") { LabsRootScreen(navController) }
|
||||
}
|
||||
}
|
||||
|
||||
if (showVoiceUI) { // if tapped outside the voice UI, close it
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
showVoiceUI = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showVoiceUI,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
enter = slideInVertically(
|
||||
initialOffsetY = { it -> it },
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
)
|
||||
),
|
||||
exit = slideOutVertically(
|
||||
targetOffsetY = { it -> it },
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = EasingTokens.EmphasizedDecelerate
|
||||
)
|
||||
)
|
||||
) {
|
||||
// We need a box as applying the padding elsewhere leads to either
|
||||
// janky animation or layout
|
||||
Box(Modifier.safeDrawingPadding()) {
|
||||
Card(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 600.dp)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
VoicePermissionSwitch(
|
||||
onCancel = {
|
||||
showVoiceUI = false
|
||||
}
|
||||
) {
|
||||
voiceChannelId?.let {
|
||||
VoiceSheet(
|
||||
it,
|
||||
onDisconnect = {
|
||||
showVoiceUI = false
|
||||
voiceChannelId = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composable("create/group") { CreateGroupScreen(navController) }
|
||||
|
||||
composable("discover") { DiscoverScreen(navController) }
|
||||
|
||||
composable("settings") { SettingsScreen(navController) }
|
||||
composable("settings/profile") { ProfileSettingsScreen(navController) }
|
||||
composable("settings/sessions") { SessionSettingsScreen(navController) }
|
||||
composable("settings/appearance") { AppearanceSettingsScreen(navController) }
|
||||
composable("settings/chat") { ChatSettingsScreen(navController) }
|
||||
composable("settings/debug") { DebugSettingsScreen(navController) }
|
||||
composable("settings/experiments") { ExperimentsSettingsScreen(navController) }
|
||||
composable("settings/changelogs") { ChangelogsSettingsScreen(navController) }
|
||||
composable("settings/language") { LanguagePickerSettingsScreen(navController) }
|
||||
|
||||
composable("settings/channel/{channelId}") { backStackEntry ->
|
||||
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
|
||||
ChannelSettingsHome(navController, channelId)
|
||||
}
|
||||
composable("settings/channel/{channelId}/overview") { backStackEntry ->
|
||||
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
|
||||
ChannelSettingsOverview(navController, channelId)
|
||||
}
|
||||
composable("settings/channel/{channelId}/permissions") { backStackEntry ->
|
||||
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
|
||||
ChannelSettingsPermissions(navController, channelId)
|
||||
}
|
||||
|
||||
composable("about") { AboutScreen(navController) }
|
||||
composable("about/oss") { AttributionScreen(navController) }
|
||||
|
||||
composable("labs") { LabsRootScreen(navController) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,14 +53,20 @@ import kotlinx.serialization.json.Json
|
|||
import java.net.SocketException
|
||||
import chat.revolt.api.schemas.Channel as ChannelSchema
|
||||
|
||||
const val REVOLT_BASE = "https://api.revolt.chat/0.8"
|
||||
private const val USE_ALPHA_API = false
|
||||
|
||||
val REVOLT_BASE =
|
||||
if (USE_ALPHA_API) "https://alpha.revolt.chat/api" else "https://api.revolt.chat/0.8"
|
||||
const val REVOLT_SUPPORT = "https://support.revolt.chat"
|
||||
const val REVOLT_MARKETING = "https://revolt.chat"
|
||||
const val REVOLT_FILES = "https://cdn.revoltusercontent.com"
|
||||
const val REVOLT_JANUARY = "https://jan.revolt.chat"
|
||||
val REVOLT_FILES =
|
||||
if (USE_ALPHA_API) "https://alpha.revolt.chat/autumn" else "https://cdn.revoltusercontent.com"
|
||||
val REVOLT_JANUARY =
|
||||
if (USE_ALPHA_API) "https://alpha.revolt.chat/january" else "https://jan.revolt.chat"
|
||||
const val REVOLT_APP = "https://app.revolt.chat"
|
||||
const val REVOLT_INVITES = "https://rvlt.gg"
|
||||
const val REVOLT_WEBSOCKET = "wss://ws.revolt.chat"
|
||||
val REVOLT_WEBSOCKET =
|
||||
if (USE_ALPHA_API) "wss://alpha.revolt.chat/ws" else "wss://ws.revolt.chat"
|
||||
const val REVOLT_KJBOOK = "https://revoltchat.github.io/android"
|
||||
|
||||
fun String.api(): String {
|
||||
|
|
@ -68,7 +74,9 @@ fun String.api(): String {
|
|||
}
|
||||
|
||||
fun buildUserAgent(accessMethod: String = "Ktor"): String {
|
||||
return "$accessMethod RevoltAndroid/${BuildConfig.VERSION_NAME} ${BuildConfig.APPLICATION_ID} (Android ${android.os.Build.VERSION.SDK_INT}; ${android.os.Build.MANUFACTURER} ${android.os.Build.DEVICE}; (Kotlin ${KotlinVersion.CURRENT})"
|
||||
return "$accessMethod RevoltAndroid/${BuildConfig.VERSION_NAME} " +
|
||||
"${BuildConfig.APPLICATION_ID} Android/${android.os.Build.VERSION.SDK_INT} " +
|
||||
"(${android.os.Build.MANUFACTURER} ${android.os.Build.DEVICE}) Kotlin/${KotlinVersion.CURRENT}"
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ enum class PermissionBit(val value: Long) {
|
|||
MuteMembers(1L shl 33),
|
||||
DeafenMembers(1L shl 34),
|
||||
MoveMembers(1L shl 35),
|
||||
Listen(1L shl 36),
|
||||
|
||||
// % 1 bit reserved
|
||||
|
||||
|
|
@ -91,7 +92,8 @@ object BitDefaults {
|
|||
PermissionBit.SendEmbeds +
|
||||
PermissionBit.UploadFiles +
|
||||
PermissionBit.Connect +
|
||||
PermissionBit.Speak
|
||||
PermissionBit.Speak +
|
||||
PermissionBit.Listen
|
||||
|
||||
val SavedMessages =
|
||||
PermissionBit.GrantAllSafe.value
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import io.ktor.websocket.readText
|
|||
import io.ktor.websocket.send
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.serialization.SerializationException
|
||||
import logcat.logcat
|
||||
|
||||
enum class DisconnectionState {
|
||||
Disconnected,
|
||||
|
|
@ -149,10 +150,14 @@ object RealtimeSocket {
|
|||
|
||||
"Ready" -> {
|
||||
val readyFrame = RevoltJson.decodeFromString(ReadyFrame.serializer(), rawFrame)
|
||||
Log.d(
|
||||
"RealtimeSocket",
|
||||
"Received ready frame with ${readyFrame.users.size} users, ${readyFrame.servers.size} servers, ${readyFrame.channels.size} channels, and ${readyFrame.emojis.size} emojis."
|
||||
)
|
||||
|
||||
logcat {
|
||||
"Received ready frame with ${readyFrame.users.size} users, " +
|
||||
"${readyFrame.servers.size} servers, " +
|
||||
"${readyFrame.channels.size} channels, " +
|
||||
"${readyFrame.emojis.size} emojis, " +
|
||||
"and ${readyFrame.voiceStates.size} voice states."
|
||||
}
|
||||
|
||||
Log.d("RealtimeSocket", "Adding users to cache.")
|
||||
val userMap = readyFrame.users.associateBy { it.id!! }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package chat.revolt.api.realtime.frames.receivable
|
||||
|
||||
import chat.revolt.api.schemas.Channel
|
||||
import chat.revolt.api.schemas.ChannelVoiceState
|
||||
import chat.revolt.api.schemas.Embed
|
||||
import chat.revolt.api.schemas.Emoji
|
||||
import chat.revolt.api.schemas.Member
|
||||
|
|
@ -42,7 +43,8 @@ data class ReadyFrame(
|
|||
val users: List<User>,
|
||||
val servers: List<Server>,
|
||||
val channels: List<Channel>,
|
||||
val emojis: List<Emoji>
|
||||
val emojis: List<Emoji>,
|
||||
@SerialName("voice_states") val voiceStates: List<ChannelVoiceState> = listOf(),
|
||||
)
|
||||
|
||||
typealias MessageFrame = Message
|
||||
|
|
|
|||
|
|
@ -18,36 +18,48 @@ data class Root(
|
|||
|
||||
@Serializable
|
||||
data class Features(
|
||||
val captcha: CAPTCHA,
|
||||
val captcha: CAPTCHAFeature,
|
||||
val email: Boolean,
|
||||
|
||||
@SerialName("invite_only")
|
||||
val inviteOnly: Boolean,
|
||||
|
||||
val autumn: Autumn,
|
||||
val january: Autumn,
|
||||
val voso: Voso
|
||||
@SerialName("invite_only") val inviteOnly: Boolean,
|
||||
val autumn: AutumnJanuaryFeature,
|
||||
val january: AutumnJanuaryFeature,
|
||||
val voso: LegacyVoiceFeature? = null,
|
||||
val livekit: LiveKitFeature? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Autumn(
|
||||
data class AutumnJanuaryFeature(
|
||||
val enabled: Boolean,
|
||||
val url: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CAPTCHA(
|
||||
data class CAPTCHAFeature(
|
||||
val enabled: Boolean,
|
||||
val key: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Voso(
|
||||
data class LegacyVoiceFeature(
|
||||
val enabled: Boolean,
|
||||
val url: String,
|
||||
val ws: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LiveKitFeature(
|
||||
val enabled: Boolean,
|
||||
val nodes: List<LiveKitNode>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LiveKitNode(
|
||||
val name: String,
|
||||
val lat: Double,
|
||||
val lon: Double,
|
||||
@SerialName("public_url") val publicUrl: String,
|
||||
)
|
||||
|
||||
suspend fun getRootRoute(): Root {
|
||||
return RevoltHttp.get("/".api()).body()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.revolt.R
|
||||
|
|
@ -29,10 +30,16 @@ import chat.revolt.api.schemas.User
|
|||
import chat.revolt.composables.generic.UserAvatar
|
||||
|
||||
@Composable
|
||||
fun StackedUserAvatars(users: List<String>, amount: Int = 3, serverId: String?) {
|
||||
fun StackedUserAvatars(
|
||||
users: List<String>,
|
||||
amount: Int = 3,
|
||||
size: Dp = 16.dp,
|
||||
offset: Dp = 8.dp,
|
||||
serverId: String?
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp + (8.dp * minOf(users.size, amount)), 16.dp)
|
||||
.size(size + (offset * minOf(users.size, amount)), size)
|
||||
) {
|
||||
users.take(amount).forEachIndexed { index, userId ->
|
||||
val user = RevoltAPI.userCache[userId]
|
||||
|
|
@ -44,10 +51,10 @@ fun StackedUserAvatars(users: List<String>, amount: Int = 3, serverId: String?)
|
|||
username = user?.let { User.resolveDefaultName(it) }
|
||||
?: stringResource(id = R.string.unknown),
|
||||
rawUrl = maybeMember?.avatar?.let { "$REVOLT_FILES/avatars/${it.id}" },
|
||||
size = 16.dp,
|
||||
size = size,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = (index * 8).dp
|
||||
x = (index * offset.value).dp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -91,7 +98,7 @@ fun TypingIndicator(users: List<String>, serverId: String?) {
|
|||
RevoltAPI.userCache[userId]?.let { u ->
|
||||
val maybeMember =
|
||||
serverId?.let { RevoltAPI.members.getMember(serverId, userId) }
|
||||
|
||||
|
||||
maybeMember?.nickname ?: User.resolveDefaultName(u)
|
||||
} ?: userId
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package chat.revolt.composables.screens.chat.drawer
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
|
|
@ -28,6 +30,7 @@ import androidx.compose.foundation.layout.offset
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
|
|
@ -71,6 +74,7 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
|
|
@ -96,6 +100,7 @@ import chat.revolt.composables.generic.UserAvatar
|
|||
import chat.revolt.composables.generic.presenceFromStatus
|
||||
import chat.revolt.composables.screens.chat.ChannelIcon
|
||||
import chat.revolt.screens.chat.ChatRouterDestination
|
||||
import chat.revolt.screens.chat.LocalIsConnected
|
||||
import chat.revolt.sheets.ChannelContextSheet
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -154,6 +159,17 @@ fun ChannelSideDrawer(
|
|||
), label = "Server banner height"
|
||||
)
|
||||
|
||||
val serverInfoOffset by animateDpAsState(
|
||||
if (LocalIsConnected.current)
|
||||
WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
else
|
||||
0.dp,
|
||||
animationSpec = spring(
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
visibilityThreshold = Dp.VisibilityThreshold
|
||||
)
|
||||
)
|
||||
|
||||
// - Take the list of servers and filter them by the ones that are in the ordering.
|
||||
// - Sort the servers that are in the ordering using the ordering.
|
||||
// - Add the servers that aren't in the ordering to the end of the list.
|
||||
|
|
@ -202,30 +218,40 @@ fun ChannelSideDrawer(
|
|||
)
|
||||
) {
|
||||
stickyHeader(key = "self") {
|
||||
UserAvatar(
|
||||
username = RevoltAPI.userCache[RevoltAPI.selfId]?.let {
|
||||
User.resolveDefaultName(
|
||||
it
|
||||
Column(Modifier.background(MaterialTheme.colorScheme.background)) {
|
||||
AnimatedVisibility(LocalIsConnected.current) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.height(
|
||||
WindowInsets.statusBars.asPaddingValues()
|
||||
.calculateTopPadding()
|
||||
)
|
||||
)
|
||||
}
|
||||
?: "",
|
||||
presence = presenceFromStatus(
|
||||
RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence,
|
||||
RevoltAPI.userCache[RevoltAPI.selfId]?.online ?: false
|
||||
),
|
||||
userId = RevoltAPI.selfId ?: "",
|
||||
avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar,
|
||||
size = 48.dp,
|
||||
presenceSize = 16.dp,
|
||||
onClick = {
|
||||
onDestinationChanged(ChatRouterDestination.defaultForDMList)
|
||||
},
|
||||
onLongClick = onLongPressAvatar,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(8.dp)
|
||||
.size(48.dp)
|
||||
)
|
||||
UserAvatar(
|
||||
username = RevoltAPI.userCache[RevoltAPI.selfId]?.let {
|
||||
User.resolveDefaultName(
|
||||
it
|
||||
)
|
||||
}
|
||||
?: "",
|
||||
presence = presenceFromStatus(
|
||||
RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence,
|
||||
RevoltAPI.userCache[RevoltAPI.selfId]?.online ?: false
|
||||
),
|
||||
userId = RevoltAPI.selfId ?: "",
|
||||
avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar,
|
||||
size = 48.dp,
|
||||
presenceSize = 16.dp,
|
||||
onClick = {
|
||||
onDestinationChanged(ChatRouterDestination.defaultForDMList)
|
||||
},
|
||||
onLongClick = onLongPressAvatar,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
|
|
@ -314,12 +340,12 @@ fun ChannelSideDrawer(
|
|||
)
|
||||
val leftIndicatorColour = animateColorAsState(
|
||||
targetValue =
|
||||
if (serverInList.id == currentServer)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else if (serverHasUnread)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else
|
||||
Color.Transparent,
|
||||
if (serverInList.id == currentServer)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else if (serverHasUnread)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else
|
||||
Color.Transparent,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
|
|
@ -433,27 +459,30 @@ fun ChannelSideDrawer(
|
|||
}
|
||||
Column(
|
||||
Modifier
|
||||
.clip(
|
||||
MaterialTheme.shapes.extraLarge.copy(
|
||||
bottomEnd = CornerSize(0)
|
||||
)
|
||||
)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Box(Modifier.height(serverBannerHeight)) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(
|
||||
MaterialTheme.shapes.medium.copy(
|
||||
topStart = CornerSize(0.dp),
|
||||
topEnd = CornerSize(0.dp)
|
||||
)
|
||||
)
|
||||
.height(
|
||||
serverBannerHeight + WindowInsets.statusBars.asPaddingValues()
|
||||
.calculateTopPadding()
|
||||
)
|
||||
//.offset(y = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
|
||||
) {
|
||||
if (server?.banner != null) {
|
||||
RemoteImage(
|
||||
url = "$REVOLT_FILES/banners/${server.banner.id}",
|
||||
description = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.clip(
|
||||
MaterialTheme.shapes.medium.copy(
|
||||
topStart = CornerSize(0), topEnd = CornerSize(0)
|
||||
)
|
||||
)
|
||||
.fillMaxSize()
|
||||
)
|
||||
|
||||
|
|
@ -465,7 +494,7 @@ fun ChannelSideDrawer(
|
|||
drawRect(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
surfaceContainer.copy(alpha = 0.8f),
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
Color.Transparent
|
||||
),
|
||||
Offset.Zero,
|
||||
|
|
@ -477,63 +506,72 @@ fun ChannelSideDrawer(
|
|||
}
|
||||
|
||||
Row(
|
||||
Modifier.padding(16.dp),
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.offset(y = serverInfoOffset),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides
|
||||
if (server?.banner != null) Color.White
|
||||
else LocalContentColor.current
|
||||
) {
|
||||
if (server?.flags has ServerFlags.Official) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = R.drawable.ic_revolt_decagram_24dp
|
||||
),
|
||||
contentDescription = stringResource(
|
||||
R.string.server_flag_official
|
||||
),
|
||||
tint = LocalContentColor.current,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
if (server?.flags has ServerFlags.Verified) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = R.drawable.ic_check_decagram_24dp
|
||||
),
|
||||
contentDescription = stringResource(
|
||||
R.string.server_flag_verified
|
||||
),
|
||||
tint = LocalContentColor.current,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
Row(
|
||||
Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (server?.flags has ServerFlags.Official) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = R.drawable.ic_revolt_decagram_24dp
|
||||
),
|
||||
contentDescription = stringResource(
|
||||
R.string.server_flag_official
|
||||
),
|
||||
tint = LocalContentColor.current,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
if (server?.flags has ServerFlags.Verified) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = R.drawable.ic_check_decagram_24dp
|
||||
),
|
||||
contentDescription = stringResource(
|
||||
R.string.server_flag_verified
|
||||
),
|
||||
tint = LocalContentColor.current,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = when (currentServer) {
|
||||
null -> stringResource(R.string.direct_messages)
|
||||
else -> server?.name ?: stringResource(R.string.unknown)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = when (currentServer) {
|
||||
null -> stringResource(R.string.direct_messages)
|
||||
else -> server?.name ?: stringResource(R.string.unknown)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
if (currentServer != null) {
|
||||
IconButton(onClick = {
|
||||
server?.id?.let { srvId -> onShowServerContextSheet(srvId) }
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.menu)
|
||||
)
|
||||
if (currentServer != null) {
|
||||
IconButton(onClick = {
|
||||
server?.id?.let { srvId -> onShowServerContextSheet(srvId) }
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.menu),
|
||||
tint = LocalContentColor.current
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(64.dp))
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(64.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -762,7 +800,8 @@ fun ColumnScope.ServerChannelListRenderer(
|
|||
items(categorisedChannels?.size ?: 0) {
|
||||
when (val channelOrCat = categorisedChannels?.get(it)) {
|
||||
is CategorisedChannelList.Channel -> {
|
||||
ChannelItem(channel = channelOrCat.channel,
|
||||
ChannelItem(
|
||||
channel = channelOrCat.channel,
|
||||
isCurrent = when (currentDestination) {
|
||||
is ChatRouterDestination.Channel -> {
|
||||
currentDestination.channelId == channelOrCat.channel.id
|
||||
|
|
@ -840,7 +879,8 @@ fun ChannelItem(
|
|||
}
|
||||
}
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically,
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 8.dp)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,15 +10,17 @@ import androidx.activity.compose.BackHandler
|
|||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
|
|
@ -38,15 +40,20 @@ import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
|
|||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.structuralEqualityPolicy
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
|
|
@ -70,7 +77,6 @@ import chat.revolt.callbacks.Action
|
|||
import chat.revolt.callbacks.ActionChannel
|
||||
import chat.revolt.composables.chat.DisconnectedNotice
|
||||
import chat.revolt.composables.screens.chat.drawer.ChannelSideDrawer
|
||||
import chat.revolt.composables.screens.voice.VoiceChannelOverlay
|
||||
import chat.revolt.dialogs.NotificationRationaleDialog
|
||||
import chat.revolt.internals.Changelogs
|
||||
import chat.revolt.internals.extensions.zero
|
||||
|
|
@ -261,12 +267,16 @@ class ChatRouterViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
val LocalIsConnected = compositionLocalOf(structuralEqualityPolicy()) { false }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatRouterScreen(
|
||||
topNav: NavController,
|
||||
windowSizeClass: WindowSizeClass,
|
||||
disableBackHandler: Boolean,
|
||||
onNullifiedUser: () -> Unit,
|
||||
onEnterVoiceUI: (String) -> Unit,
|
||||
viewModel: ChatRouterViewModel = hiltViewModel()
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
|
|
@ -274,6 +284,8 @@ fun ChatRouterScreen(
|
|||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
|
||||
var drawerWidth by remember { mutableFloatStateOf(0.0f) }
|
||||
|
||||
var showPlatformModDMHint by remember { mutableStateOf(false) }
|
||||
|
||||
var showStatusSheet by remember { mutableStateOf(false) }
|
||||
|
|
@ -302,9 +314,6 @@ fun ChatRouterScreen(
|
|||
|
||||
var useTabletAwareUI by remember { mutableStateOf(false) }
|
||||
|
||||
var voiceChannelOverlay by remember { mutableStateOf(false) }
|
||||
var voiceChannelOverlayChannelId by remember { mutableStateOf("") }
|
||||
|
||||
var showReportUser by remember { mutableStateOf(false) }
|
||||
var reportUserTarget by remember { mutableStateOf("") }
|
||||
|
||||
|
|
@ -444,8 +453,7 @@ fun ChatRouterScreen(
|
|||
}
|
||||
|
||||
is Action.OpenVoiceChannelOverlay -> {
|
||||
voiceChannelOverlayChannelId = action.channelId
|
||||
voiceChannelOverlay = true
|
||||
onEnterVoiceUI(action.channelId)
|
||||
}
|
||||
|
||||
is Action.OpenWebhookSheet -> {
|
||||
|
|
@ -712,12 +720,6 @@ fun ChatRouterScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (voiceChannelOverlay) {
|
||||
VoiceChannelOverlay(voiceChannelOverlayChannelId) {
|
||||
voiceChannelOverlay = false
|
||||
}
|
||||
}
|
||||
|
||||
val askNotificationsPermission =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||
if (isGranted) {
|
||||
|
|
@ -812,53 +814,11 @@ fun ChatRouterScreen(
|
|||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = RealtimeSocket.disconnectionState == DisconnectionState.Connected
|
||||
CompositionLocalProvider(
|
||||
LocalIsConnected provides (RealtimeSocket.disconnectionState == DisconnectionState.Connected)
|
||||
) {
|
||||
Spacer(Modifier.windowInsetsPadding(WindowInsets.statusBars))
|
||||
}
|
||||
|
||||
if (useTabletAwareUI) {
|
||||
Row {
|
||||
DismissibleDrawerSheet(
|
||||
drawerContainerColor = Color.Transparent,
|
||||
windowInsets = WindowInsets.zero
|
||||
) {
|
||||
Sidebar(
|
||||
viewModel = viewModel,
|
||||
topNav = topNav,
|
||||
currentServer = currentServer,
|
||||
onShowStatusSheet = {
|
||||
showStatusSheet = true
|
||||
},
|
||||
onShowServerContextSheet = {
|
||||
serverContextSheetTarget = it
|
||||
showServerContextSheet = true
|
||||
},
|
||||
onShowAddServerSheet = {
|
||||
showAddServerSheet = true
|
||||
},
|
||||
showSettingsButton = isTouchExplorationEnabled,
|
||||
onOpenSettings = {
|
||||
topNav.navigate("settings")
|
||||
},
|
||||
)
|
||||
}
|
||||
ChannelNavigator(
|
||||
dest = viewModel.currentDestination,
|
||||
topNav = topNav,
|
||||
useDrawer = false,
|
||||
toggleDrawer = {
|
||||
toggleDrawerLambda()
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
var useSidebarGesture by remember { mutableStateOf(true) }
|
||||
DismissibleNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = useSidebarGesture,
|
||||
drawerContent = {
|
||||
if (useTabletAwareUI) {
|
||||
Row {
|
||||
DismissibleDrawerSheet(
|
||||
drawerContainerColor = Color.Transparent,
|
||||
windowInsets = WindowInsets.zero
|
||||
|
|
@ -881,28 +841,104 @@ fun ChatRouterScreen(
|
|||
onOpenSettings = {
|
||||
topNav.navigate("settings")
|
||||
},
|
||||
drawerState = drawerState
|
||||
)
|
||||
}
|
||||
},
|
||||
content = {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
ChannelNavigator(
|
||||
dest = viewModel.currentDestination,
|
||||
topNav = topNav,
|
||||
useDrawer = true,
|
||||
toggleDrawer = {
|
||||
toggleDrawerLambda()
|
||||
},
|
||||
drawerState = drawerState,
|
||||
drawerGestureEnabled = useSidebarGesture,
|
||||
setDrawerGestureEnabled = {
|
||||
useSidebarGesture = it
|
||||
}
|
||||
)
|
||||
}
|
||||
ChannelNavigator(
|
||||
dest = viewModel.currentDestination,
|
||||
topNav = topNav,
|
||||
useDrawer = false,
|
||||
disableBackHandler = disableBackHandler,
|
||||
toggleDrawer = {
|
||||
toggleDrawerLambda()
|
||||
},
|
||||
onEnterVoiceUI = onEnterVoiceUI,
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
var useSidebarGesture by remember { mutableStateOf(true) }
|
||||
DismissibleNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = useSidebarGesture,
|
||||
drawerContent = {
|
||||
DismissibleDrawerSheet(
|
||||
drawerContainerColor = Color.Transparent,
|
||||
windowInsets = WindowInsets.zero,
|
||||
modifier = Modifier.onSizeChanged {
|
||||
drawerWidth = it.width.toFloat()
|
||||
}
|
||||
) {
|
||||
Sidebar(
|
||||
viewModel = viewModel,
|
||||
topNav = topNav,
|
||||
currentServer = currentServer,
|
||||
onShowStatusSheet = {
|
||||
showStatusSheet = true
|
||||
},
|
||||
onShowServerContextSheet = {
|
||||
serverContextSheetTarget = it
|
||||
showServerContextSheet = true
|
||||
},
|
||||
onShowAddServerSheet = {
|
||||
showAddServerSheet = true
|
||||
},
|
||||
showSettingsButton = isTouchExplorationEnabled,
|
||||
onOpenSettings = {
|
||||
topNav.navigate("settings")
|
||||
},
|
||||
drawerState = drawerState
|
||||
)
|
||||
}
|
||||
},
|
||||
content = {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
ChannelNavigator(
|
||||
dest = viewModel.currentDestination,
|
||||
topNav = topNav,
|
||||
useDrawer = true,
|
||||
disableBackHandler = disableBackHandler,
|
||||
toggleDrawer = {
|
||||
toggleDrawerLambda()
|
||||
},
|
||||
drawerState = drawerState,
|
||||
drawerGestureEnabled = useSidebarGesture,
|
||||
setDrawerGestureEnabled = {
|
||||
useSidebarGesture = it
|
||||
},
|
||||
onEnterVoiceUI = onEnterVoiceUI,
|
||||
)
|
||||
|
||||
// This is the overlay on the main content when the drawer is open
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
Modifier
|
||||
.then(
|
||||
if (drawerState.isOpen) {
|
||||
Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
enabled = drawerState.isOpen,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
drawerState.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
MaterialTheme
|
||||
.colorScheme
|
||||
.surfaceContainerLowest
|
||||
.copy(
|
||||
alpha = (1.0f + (drawerState.currentOffset / drawerWidth)) * 0.7f
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -942,11 +978,13 @@ fun ChannelNavigator(
|
|||
toggleDrawer: () -> Unit,
|
||||
drawerState: DrawerState? = null,
|
||||
drawerGestureEnabled: Boolean = true,
|
||||
disableBackHandler: Boolean = false,
|
||||
onEnterVoiceUI: (String) -> Unit = {},
|
||||
setDrawerGestureEnabled: (Boolean) -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BackHandler(enabled = useDrawer) {
|
||||
BackHandler(useDrawer && !disableBackHandler) {
|
||||
toggleDrawer()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,104 +1,47 @@
|
|||
package chat.revolt.screens.chat.views
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FloatingActionButtonMenu
|
||||
import androidx.compose.material3.FloatingActionButtonMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.ToggleFloatingActionButton
|
||||
import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.animateFloatingActionButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.isTraversalGroup
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.traversalIndex
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import chat.revolt.R
|
||||
import chat.revolt.api.internals.FriendRequests
|
||||
import chat.revolt.api.internals.UserQR
|
||||
import chat.revolt.api.internals.UserQRContents
|
||||
import chat.revolt.api.routes.user.friendUser
|
||||
import chat.revolt.api.routes.user.unfriendUser
|
||||
import chat.revolt.api.schemas.AutumnResource
|
||||
import chat.revolt.api.schemas.Metadata
|
||||
import chat.revolt.api.settings.LoadedSettings
|
||||
import chat.revolt.callbacks.Action
|
||||
import chat.revolt.callbacks.ActionChannel
|
||||
import chat.revolt.components.vectorassets.HL_TAG
|
||||
import chat.revolt.components.vectorassets.HL_USERNAME
|
||||
import chat.revolt.components.vectorassets.RevoltTagIntro
|
||||
import chat.revolt.composables.chat.MemberListItem
|
||||
import chat.revolt.composables.generic.CountableListHeader
|
||||
import chat.revolt.composables.generic.UserAvatar
|
||||
import chat.revolt.internals.extensions.zero
|
||||
import chat.revolt.markdown.jbm.asHexString
|
||||
import io.github.g00fy2.quickie.QRResult
|
||||
import io.github.g00fy2.quickie.ScanQRCode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -470,6 +413,16 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: ()
|
|||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Column {
|
||||
AnimatedVisibility(LocalIsConnected.current) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.height(
|
||||
WindowInsets.statusBars.asPaddingValues()
|
||||
.calculateTopPadding()
|
||||
)
|
||||
)
|
||||
}
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
|
|
@ -525,14 +478,15 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: ()
|
|||
},
|
||||
windowInsets = WindowInsets.zero
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { pv ->
|
||||
Box(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(pv)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
LazyColumn(state = listState) {
|
||||
LazyColumn {
|
||||
stickyHeader(key = "incoming") {
|
||||
CountableListHeader(
|
||||
text = stringResource(id = R.string.friends_incoming_requests),
|
||||
|
|
@ -648,7 +602,7 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: ()
|
|||
items(FriendRequests.getBlocked().size) {
|
||||
val item = FriendRequests.getBlocked().getOrNull(it)
|
||||
if (item == null) return@items
|
||||
|
||||
|
||||
MemberListItem(
|
||||
member = null,
|
||||
user = item,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
package chat.revolt.screens.chat.views
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
|
|
@ -23,28 +28,40 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import chat.revolt.R
|
||||
import chat.revolt.internals.extensions.zero
|
||||
import chat.revolt.screens.chat.LocalIsConnected
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NoCurrentChannelScreen(useDrawer: Boolean, onDrawerClicked: () -> Unit) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
if (useDrawer) {
|
||||
IconButton(onClick = {
|
||||
onDrawerClicked()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(id = R.string.menu)
|
||||
Column {
|
||||
AnimatedVisibility(LocalIsConnected.current) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.height(
|
||||
WindowInsets.statusBars.asPaddingValues()
|
||||
.calculateTopPadding()
|
||||
)
|
||||
)
|
||||
}
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
if (useDrawer) {
|
||||
IconButton(onClick = {
|
||||
onDrawerClicked()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(id = R.string.menu)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.zero
|
||||
)
|
||||
},
|
||||
windowInsets = WindowInsets.zero
|
||||
)
|
||||
}
|
||||
},
|
||||
) { pv ->
|
||||
Column(
|
||||
|
|
|
|||
|
|
@ -13,11 +13,13 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.exclude
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
|
@ -62,6 +64,7 @@ import chat.revolt.composables.generic.NonIdealState
|
|||
import chat.revolt.composables.screens.settings.UserOverview
|
||||
import chat.revolt.composables.skeletons.UserOverviewSkeleton
|
||||
import chat.revolt.internals.extensions.zero
|
||||
import chat.revolt.screens.chat.LocalIsConnected
|
||||
import chat.revolt.sheets.UserCardSheet
|
||||
import io.sentry.Sentry
|
||||
|
||||
|
|
@ -110,22 +113,33 @@ fun OverviewScreen(
|
|||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text(stringResource(R.string.overview_screen_title)) },
|
||||
navigationIcon = {
|
||||
if (useDrawer) {
|
||||
IconButton(onClick = {
|
||||
onDrawerClicked()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(id = R.string.menu)
|
||||
Column {
|
||||
AnimatedVisibility(LocalIsConnected.current) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.height(
|
||||
WindowInsets.statusBars.asPaddingValues()
|
||||
.calculateTopPadding()
|
||||
)
|
||||
)
|
||||
}
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text(stringResource(R.string.overview_screen_title)) },
|
||||
navigationIcon = {
|
||||
if (useDrawer) {
|
||||
IconButton(onClick = {
|
||||
onDrawerClicked()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(id = R.string.menu)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.zero
|
||||
)
|
||||
},
|
||||
windowInsets = WindowInsets.zero
|
||||
)
|
||||
}
|
||||
},
|
||||
contentWindowInsets = if (includePadding) ScaffoldDefaults.contentWindowInsets else ScaffoldDefaults.contentWindowInsets.exclude(
|
||||
NavigationBarDefaults.windowInsets
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
|
|
@ -91,6 +92,7 @@ import androidx.compose.runtime.snapshotFlow
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
|
@ -140,10 +142,12 @@ import chat.revolt.composables.screens.chat.ChannelIcon
|
|||
import chat.revolt.composables.screens.chat.ReplyManager
|
||||
import chat.revolt.composables.screens.chat.TypingIndicator
|
||||
import chat.revolt.composables.screens.chat.atoms.RegularMessage
|
||||
import chat.revolt.composables.screens.chat.molecules.JoinVoiceChannelButton
|
||||
import chat.revolt.composables.skeletons.MessageSkeleton
|
||||
import chat.revolt.composables.skeletons.MessageSkeletonVariant
|
||||
import chat.revolt.internals.extensions.rememberChannelPermissions
|
||||
import chat.revolt.internals.extensions.zero
|
||||
import chat.revolt.screens.chat.LocalIsConnected
|
||||
import chat.revolt.sheets.ChannelInfoSheet
|
||||
import chat.revolt.sheets.MessageContextSheet
|
||||
import chat.revolt.sheets.ReactSheet
|
||||
|
|
@ -512,126 +516,137 @@ fun ChannelScreen(
|
|||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.zero,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
modifier = Modifier.clickable {
|
||||
channelInfoSheetShown = true
|
||||
},
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
viewModel.channel?.let {
|
||||
when (it.channelType) {
|
||||
ChannelType.DirectMessage -> {
|
||||
Column {
|
||||
AnimatedVisibility(LocalIsConnected.current) {
|
||||
Spacer(
|
||||
Modifier
|
||||
.height(
|
||||
WindowInsets.statusBars.asPaddingValues()
|
||||
.calculateTopPadding()
|
||||
)
|
||||
)
|
||||
}
|
||||
TopAppBar(
|
||||
modifier = Modifier.clickable {
|
||||
channelInfoSheetShown = true
|
||||
},
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
viewModel.channel?.let {
|
||||
when (it.channelType) {
|
||||
ChannelType.DirectMessage -> {
|
||||
val partner =
|
||||
RevoltAPI.userCache[ChannelUtils.resolveDMPartner(it)]
|
||||
UserAvatar(
|
||||
username = it.name ?: stringResource(R.string.unknown),
|
||||
userId = ChannelUtils.resolveDMPartner(it) ?: "",
|
||||
size = 24.dp,
|
||||
presenceSize = 12.dp,
|
||||
avatar = partner?.avatar
|
||||
)
|
||||
}
|
||||
|
||||
ChannelType.Group -> {
|
||||
GroupIcon(
|
||||
name = it.name ?: stringResource(R.string.unknown),
|
||||
size = 24.dp,
|
||||
icon = it.icon
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ChannelIcon(
|
||||
channelType = it.channelType ?: ChannelType.TextChannel,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.alpha(0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalTextStyle provides LocalTextStyle.current.copy(
|
||||
fontSize = 20.sp,
|
||||
lineHeightStyle = LineHeightStyle(
|
||||
alignment = LineHeightStyle.Alignment.Bottom,
|
||||
trim = LineHeightStyle.Trim.LastLineBottom
|
||||
)
|
||||
)
|
||||
) {
|
||||
when (it.channelType) {
|
||||
ChannelType.TextChannel, ChannelType.VoiceChannel, ChannelType.Group -> Text(
|
||||
it.name ?: stringResource(R.string.unknown),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
ChannelType.SavedMessages -> Text(
|
||||
stringResource(R.string.channel_notes),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
ChannelType.DirectMessage -> Text(
|
||||
ChannelUtils.resolveName(it)
|
||||
?: stringResource(R.string.unknown),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
else -> Text(
|
||||
stringResource(R.string.unknown),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (it.channelType == ChannelType.DirectMessage) {
|
||||
val partner =
|
||||
RevoltAPI.userCache[ChannelUtils.resolveDMPartner(it)]
|
||||
UserAvatar(
|
||||
username = it.name ?: stringResource(R.string.unknown),
|
||||
userId = ChannelUtils.resolveDMPartner(it) ?: "",
|
||||
size = 24.dp,
|
||||
presenceSize = 12.dp,
|
||||
avatar = partner?.avatar
|
||||
PresenceBadge(
|
||||
presence = presenceFromStatus(
|
||||
partner?.status?.presence,
|
||||
online = partner?.online == true
|
||||
),
|
||||
size = 12.dp
|
||||
)
|
||||
}
|
||||
|
||||
ChannelType.Group -> {
|
||||
GroupIcon(
|
||||
name = it.name ?: stringResource(R.string.unknown),
|
||||
size = 24.dp,
|
||||
icon = it.icon
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ChannelIcon(
|
||||
channelType = it.channelType ?: ChannelType.TextChannel,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.alpha(0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalTextStyle provides LocalTextStyle.current.copy(
|
||||
fontSize = 20.sp,
|
||||
lineHeightStyle = LineHeightStyle(
|
||||
alignment = LineHeightStyle.Alignment.Bottom,
|
||||
trim = LineHeightStyle.Trim.LastLineBottom
|
||||
)
|
||||
)
|
||||
) {
|
||||
when (it.channelType) {
|
||||
ChannelType.TextChannel, ChannelType.VoiceChannel, ChannelType.Group -> Text(
|
||||
it.name ?: stringResource(R.string.unknown),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
ChannelType.SavedMessages -> Text(
|
||||
stringResource(R.string.channel_notes),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
ChannelType.DirectMessage -> Text(
|
||||
ChannelUtils.resolveName(it)
|
||||
?: stringResource(R.string.unknown),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
else -> Text(
|
||||
stringResource(R.string.unknown),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (it.channelType == ChannelType.DirectMessage) {
|
||||
val partner =
|
||||
RevoltAPI.userCache[ChannelUtils.resolveDMPartner(it)]
|
||||
PresenceBadge(
|
||||
presence = presenceFromStatus(
|
||||
partner?.status?.presence,
|
||||
online = partner?.online == true
|
||||
),
|
||||
size = 12.dp
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.alpha(0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
windowInsets = if (useChatUI) WindowInsets.statusBars else WindowInsets.zero,
|
||||
navigationIcon = {
|
||||
if (useDrawer) {
|
||||
IconButton(onClick = onToggleDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(id = R.string.menu)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (useBackButton) {
|
||||
IconButton(onClick = backButtonAction ?: {}) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.ArrowBack,
|
||||
contentDescription = stringResource(id = R.string.back)
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.alpha(0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
windowInsets = if (useChatUI) WindowInsets.statusBars else WindowInsets.zero,
|
||||
navigationIcon = {
|
||||
if (useDrawer) {
|
||||
IconButton(onClick = onToggleDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(id = R.string.menu)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (useBackButton) {
|
||||
IconButton(onClick = backButtonAction ?: {}) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.ArrowBack,
|
||||
contentDescription = stringResource(id = R.string.back)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
) { pv ->
|
||||
Crossfade(
|
||||
|
|
@ -847,74 +862,83 @@ fun ChannelScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (viewModel.showPhysicalKeyboardSpark) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.spark_keyboard_shortcuts),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
val raw =
|
||||
stringResource(R.string.spark_keyboard_shortcuts_description)
|
||||
val before = raw.substringBefore("%1\$s")
|
||||
val after = raw.substringAfter("%1\$s")
|
||||
|
||||
append(before)
|
||||
appendInlineContent("metaKey", "Meta")
|
||||
append(" + /")
|
||||
append(after)
|
||||
},
|
||||
inlineContent = mapOf(
|
||||
"metaKey" to InlineTextContent(
|
||||
placeholder = Placeholder(
|
||||
width = 1.em,
|
||||
height = 1.em,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
with(LocalDensity.current) {
|
||||
Image(
|
||||
painterResource(R.drawable.ic_meta_key_24dp),
|
||||
contentDescription = null,
|
||||
/*modifier = Modifier.size(1.em.toDp())*/
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
if (viewModel.showPhysicalKeyboardSpark) {
|
||||
Card {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.dismissPhysicalKeyboardSpark()
|
||||
Text(
|
||||
stringResource(R.string.spark_keyboard_shortcuts),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
val raw =
|
||||
stringResource(R.string.spark_keyboard_shortcuts_description)
|
||||
val before = raw.substringBefore("%1\$s")
|
||||
val after = raw.substringAfter("%1\$s")
|
||||
|
||||
append(before)
|
||||
appendInlineContent("metaKey", "Meta")
|
||||
append(" + /")
|
||||
append(after)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
inlineContent = mapOf(
|
||||
"metaKey" to InlineTextContent(
|
||||
placeholder = Placeholder(
|
||||
width = 1.em,
|
||||
height = 1.em,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
with(LocalDensity.current) {
|
||||
Image(
|
||||
painterResource(R.drawable.ic_meta_key_24dp),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(
|
||||
LocalContentColor.current
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.spark_keyboard_shortcuts_dismiss))
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
(context as Activity).requestShowKeyboardShortcuts()
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(stringResource(R.string.spark_keyboard_shortcuts_cta))
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.dismissPhysicalKeyboardSpark()
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(stringResource(R.string.spark_keyboard_shortcuts_dismiss))
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
(context as Activity).requestShowKeyboardShortcuts()
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(stringResource(R.string.spark_keyboard_shortcuts_cta))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.channel?.channelType == ChannelType.VoiceChannel) {
|
||||
JoinVoiceChannelButton(channelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateListOf
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.util.fastDistinctBy
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import chat.revolt.R
|
||||
|
|
@ -876,7 +877,14 @@ class ChannelScreenViewModel @Inject constructor(
|
|||
|
||||
withContext(Dispatchers.Main) {
|
||||
items.clear()
|
||||
items.addAll(groupedItems)
|
||||
items.addAll(groupedItems.fastDistinctBy {
|
||||
when (it) {
|
||||
is ChannelScreenItem.RegularMessage -> it.message.id
|
||||
is ChannelScreenItem.SystemMessage -> it.message.id
|
||||
is ChannelScreenItem.DateDivider -> it.instant.toString()
|
||||
else -> it.toString() // Fallback for other item types
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -610,6 +610,21 @@
|
|||
<string name="emoji_picker_search_results_header">Search results</string>
|
||||
<string name="emoji_picker_clear_search">Clear search</string>
|
||||
|
||||
<string name="voice_join_offering">Join the voice channel</string>
|
||||
<string name="voice_join_offering_description_zero">Start the call</string>
|
||||
<string name="voice_join_offering_description_one">with %1$s</string>
|
||||
<string name="voice_join_offering_description_other">with %1$d others</string>
|
||||
<string name="voice_join_permission_rationale_heading">We need your permission</string>
|
||||
<string name="voice_join_permission_rationale_description">To join a voice channel, you need to grant the following permissions:</string>
|
||||
<string name="voice_join_permission_rationale_permission_mic">Your microphone, so others can hear you when unmuted</string>
|
||||
<string name="voice_join_permission_rationale_permission_camera">Your camera, so others can see you if you want to share video</string>
|
||||
<string name="voice_join_permission_rationale_assurance">Don\'t worry, we won\'t use your microphone or camera without your permission.</string>
|
||||
<string name="voice_join_permission_rationale_cta">Grant permissions</string>
|
||||
|
||||
<string name="voice_error_not_supported">Voice channels are not available at the moment.</string>
|
||||
<string name="voice_error_no_nodes">All voice nodes are unavailable at the moment. Please try again later.</string>
|
||||
<string name="voice_error_generic">An error occurred. Please try again later.</string>
|
||||
|
||||
<string name="spark_notifications_rationale">Stay in the loop</string>
|
||||
<string name="spark_notifications_rationale_description">Enable notifications to be kept up to date with messages and mentions.</string>
|
||||
<string name="spark_notifications_rationale_cta">Enable notifications</string>
|
||||
|
|
@ -769,7 +784,7 @@
|
|||
<string name="share_target_invalid_intent">This is not a valid share intent.</string>
|
||||
<string name="share_target_attachment_too_large">This attachment is too large for Revolt (max. $1$s).</string>
|
||||
<string name="share_target_search_channels">Search channels</string>
|
||||
<string name="share_target_select_channel">m select a channel to share to.</string>
|
||||
<string name="share_target_select_channel">Select a channel to share to.</string>
|
||||
|
||||
<string name="notification_channel_group_conversations">Conversations</string>
|
||||
<string name="notification_channel_group_social">Friends and Social</string>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
sentry.dsn=
|
||||
sentry.upload_mappings=true
|
||||
build.debug.app_name=
|
||||
build.flavour_id=ZZUU
|
||||
build.flavour_id=ZZUU
|
||||
dev.use_alpha_api=false
|
||||
Loading…
Reference in New Issue