Merge branch 'revoltchat:dev' into feature-add-server-context-menu-long-press

This commit is contained in:
Alex Yong 2025-07-23 12:59:43 -04:00 committed by GitHub
commit 579d3c7306
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1627 additions and 784 deletions

View File

@ -27,10 +27,14 @@ 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 androidXTestVersion = "1.6.1"
object LivekitVersion {
val core = "2.16.0"
val componentsCompose = "1.3.1"
}
fun property(fileName: String, propertyName: String, fallbackEnv: String? = null): String? {
val propsFile = rootProject.file(fileName)
if (propsFile.exists()) {
@ -294,9 +298,9 @@ dependencies {
implementation("org.jetbrains:markdown:0.7.3")
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"
// Livekit - Commented out for now to not inflate the app size.
/*implementation("io.livekit:livekit-android:${LivekitVersion.core}")
implementation("io.livekit:livekit-android-compose-components:${LivekitVersion.componentsCompose}")*/
// Firebase - Cloud Messaging
implementation(platform("com.google.firebase:firebase-bom:33.15.0"))

View File

@ -13,18 +13,32 @@ import android.view.Menu
import android.view.View
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.EaseInOutExpo
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -33,14 +47,25 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -61,6 +86,8 @@ import chat.revolt.api.settings.Experiments
import chat.revolt.api.settings.LoadedSettings
import chat.revolt.api.settings.SyncedSettings
import chat.revolt.composables.generic.HealthAlert
import chat.revolt.composables.voice.VoicePermissionSwitch
import chat.revolt.composables.voice.VoiceSheet
import chat.revolt.material.EasingTokens
import chat.revolt.ndk.NativeLibraries
import chat.revolt.persistence.KVStorage
@ -394,6 +421,8 @@ val RevoltTweenColour: FiniteAnimationSpec<Color> = tween(400, easing = EaseInOu
val NavTweenInt: FiniteAnimationSpec<IntOffset> = tween(350, easing = EaseInOutExpo)
val NavTweenFloat: FiniteAnimationSpec<Float> = tween(350, easing = EaseInOutExpo)
// This composable handles the main compose entrypoint of the app, provides the main navigation
// graph, and handles the animation and layout for the voice chat UI.
@Composable
fun AppEntrypoint(
windowSizeClass: WindowSizeClass,
@ -408,251 +437,350 @@ fun AppEntrypoint(
onRetryConnection: () -> Unit,
onUpdateNextDestination: (String) -> Unit = {}
) {
var showVoiceUI by rememberSaveable { mutableStateOf(false) }
var voiceChannelId by rememberSaveable { mutableStateOf<String?>(null) }
val chatUIScale by animateFloatAsState(
if (showVoiceUI) 0.8f else 1.0f,
animationSpec = tween(
durationMillis = 300,
easing = EasingTokens.EmphasizedDecelerate
)
)
val chatUIOpacity by animateFloatAsState(
if (showVoiceUI) 0.8f else 1.0f,
animationSpec = tween(
durationMillis = 300,
easing = EasingTokens.EmphasizedDecelerate
)
)
BackHandler(showVoiceUI) {
showVoiceUI = false
}
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(showVoiceUI) {
if (showVoiceUI) keyboardController?.hide()
}
val navController = rememberNavController()
RevoltTheme(
requestedTheme = LoadedSettings.theme,
colourOverrides = SyncedSettings.android.colourOverrides
) {
Surface(
modifier = Modifier
.fillMaxSize(),
color = MaterialTheme.colorScheme.background
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
) {
if (isHealthAlertActive) {
healthNotice?.let {
HealthAlert(notice = healthNotice, onDismiss = onDismissHealthAlert)
}
}
if (couldNotLogIn) {
AlertDialog(
onDismissRequest = {
// no-op
},
title = {
Text(stringResource(R.string.could_not_log_in_heading))
},
text = {
Text(stringResource(R.string.could_not_log_in_body))
},
confirmButton = {
TextButton(
onClick = {
onDismissLoginError()
onRetryConnection()
}
) {
Text(stringResource(R.string.could_not_log_in_cta_try_again))
}
},
dismissButton = {
TextButton(
onClick = {
onDismissLoginError()
onLogout()
}
) {
Text(stringResource(R.string.could_not_log_in_cta_logout))
}
}
)
}
NavHost(
navController = navController,
startDestination = "default",
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = NavTweenInt,
initialOffset = { it / 3 }
) + fadeIn(animationSpec = NavTweenFloat)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = NavTweenInt,
targetOffset = { it / 3 }
) + fadeOut(animationSpec = NavTweenFloat)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = NavTweenInt,
initialOffset = { it / 3 }
) + fadeIn(animationSpec = NavTweenFloat)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = NavTweenInt,
targetOffset = { it / 2 }
) + fadeOut(animationSpec = NavTweenFloat)
}
Surface(
modifier = Modifier
.fillMaxSize()
.scale(chatUIScale)
.alpha(chatUIOpacity),
color = MaterialTheme.colorScheme.background
) {
composable("default") {
DefaultDestinationScreen(
navController,
nextDestination,
isConnected,
onRetryConnection
)
if (isHealthAlertActive) {
healthNotice?.let {
HealthAlert(notice = healthNotice, onDismiss = onDismissHealthAlert)
}
}
composable("login/greeting") { LoginGreetingScreen(navController) }
composable("login/login") { LoginScreen(navController) }
composable("login/mfa/{mfaTicket}/{allowedAuthTypes}") { backStackEntry ->
val mfaTicket = backStackEntry.arguments?.getString("mfaTicket") ?: ""
val allowedAuthTypes =
backStackEntry.arguments?.getString("allowedAuthTypes") ?: ""
MfaScreen(navController, allowedAuthTypes, mfaTicket)
}
composable("register/greeting") { RegisterGreetingScreen(navController) }
composable("register/details") { RegisterDetailsScreen(navController) }
composable("register/verify/{email}") { backStackEntry ->
val email = backStackEntry.arguments?.getString("email") ?: ""
RegisterVerifyScreen(navController, email)
}
composable("register/onboarding") {
OnboardingScreen(
navController,
onOnboardingComplete = {
onUpdateNextDestination("chat")
navController.popBackStack(
navController.graph.startDestinationRoute!!,
inclusive = true
)
navController.navigate("default")
if (couldNotLogIn) {
AlertDialog(
onDismissRequest = {
// no-op
},
title = {
Text(stringResource(R.string.could_not_log_in_heading))
},
text = {
Text(stringResource(R.string.could_not_log_in_body))
},
confirmButton = {
TextButton(
onClick = {
onDismissLoginError()
onRetryConnection()
}
) {
Text(stringResource(R.string.could_not_log_in_cta_try_again))
}
},
dismissButton = {
TextButton(
onClick = {
onDismissLoginError()
onLogout()
}
) {
Text(stringResource(R.string.could_not_log_in_cta_logout))
}
}
)
}
composable("login2/init") { InitScreen(navController, windowSizeClass) }
// This is only used outside of Polar mode
// Otherwise you may be looking for "main" right below
composable(
"chat",
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Up,
animationSpec = tween(
400,
easing = EasingTokens.EmphasizedDecelerate
),
initialOffset = { it / 3 }
) + fadeIn(animationSpec = RevoltTweenFloat)
}
) {
ChatRouterScreen(
navController,
windowSizeClass,
onNullifiedUser = {
onRetryConnection()
navController.popBackStack(
navController.graph.startDestinationRoute!!,
inclusive = true
)
navController.navigate("default")
}
)
}
// This is only the main screen in Polar mode
// Otherwise you may be looking for "chat" right above
composable(
"main",
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Up,
animationSpec = tween(
400,
easing = EasingTokens.EmphasizedDecelerate
),
initialOffset = { it / 3 }
) + fadeIn(animationSpec = RevoltTweenFloat) + scaleIn(
animationSpec = tween(
400,
easing = EasingTokens.EmphasizedDecelerate
),
initialScale = 0.8f,
transformOrigin = TransformOrigin.Center
)
}
) {
MainScreen(navController)
}
composable(
"main/conversation/{channelId}",
NavHost(
navController = navController,
startDestination = "default",
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(
600,
easing = EasingTokens.EmphasizedDecelerate
),
initialOffset = { it }
) + fadeIn(animationSpec = RevoltTweenFloat)
animationSpec = NavTweenInt,
initialOffset = { it / 3 }
) + fadeIn(animationSpec = NavTweenFloat)
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = NavTweenInt,
targetOffset = { it / 3 }
) + fadeOut(animationSpec = NavTweenFloat)
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(
600,
easing = EasingTokens.EmphasizedDecelerate
),
targetOffset = { it }
) + fadeOut(animationSpec = RevoltTweenFloat)
animationSpec = NavTweenInt,
initialOffset = { it / 3 }
) + fadeIn(animationSpec = NavTweenFloat)
},
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = NavTweenInt,
targetOffset = { it / 2 }
) + fadeOut(animationSpec = NavTweenFloat)
}
) { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelScreen(
channelId = channelId,
onToggleDrawer = {},
useDrawer = false,
useBackButton = true,
backButtonAction = {
navController.popBackStack()
) {
composable("default") {
DefaultDestinationScreen(
navController,
nextDestination,
isConnected,
onRetryConnection
)
}
composable("login/greeting") { LoginGreetingScreen(navController) }
composable("login/login") { LoginScreen(navController) }
composable("login/mfa/{mfaTicket}/{allowedAuthTypes}") { backStackEntry ->
val mfaTicket = backStackEntry.arguments?.getString("mfaTicket") ?: ""
val allowedAuthTypes =
backStackEntry.arguments?.getString("allowedAuthTypes") ?: ""
MfaScreen(navController, allowedAuthTypes, mfaTicket)
}
composable("register/greeting") { RegisterGreetingScreen(navController) }
composable("register/details") { RegisterDetailsScreen(navController) }
composable("register/verify/{email}") { backStackEntry ->
val email = backStackEntry.arguments?.getString("email") ?: ""
RegisterVerifyScreen(navController, email)
}
composable("register/onboarding") {
OnboardingScreen(
navController,
onOnboardingComplete = {
onUpdateNextDestination("chat")
navController.popBackStack(
navController.graph.startDestinationRoute!!,
inclusive = true
)
navController.navigate("default")
}
)
}
composable("login2/init") { InitScreen(navController, windowSizeClass) }
// This is only used outside of Polar mode
// Otherwise you may be looking for "main" right below
composable(
"chat",
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Up,
animationSpec = tween(
400,
easing = EasingTokens.EmphasizedDecelerate
),
initialOffset = { it / 3 }
) + fadeIn(animationSpec = RevoltTweenFloat)
}
) {
ChatRouterScreen(
navController,
windowSizeClass,
disableBackHandler = showVoiceUI,
onNullifiedUser = {
onRetryConnection()
navController.popBackStack(
navController.graph.startDestinationRoute!!,
inclusive = true
)
navController.navigate("default")
},
onEnterVoiceUI = { channelId ->
showVoiceUI = true
voiceChannelId = channelId
},
)
}
// This is only the main screen in Polar mode
// Otherwise you may be looking for "chat" right above
composable(
"main",
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Up,
animationSpec = tween(
400,
easing = EasingTokens.EmphasizedDecelerate
),
initialOffset = { it / 3 }
) + fadeIn(animationSpec = RevoltTweenFloat) + scaleIn(
animationSpec = tween(
400,
easing = EasingTokens.EmphasizedDecelerate
),
initialScale = 0.8f,
transformOrigin = TransformOrigin.Center
)
}
) {
MainScreen(navController)
}
composable(
"main/conversation/{channelId}",
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(
600,
easing = EasingTokens.EmphasizedDecelerate
),
initialOffset = { it }
) + fadeIn(animationSpec = RevoltTweenFloat)
},
useChatUI = true
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(
600,
easing = EasingTokens.EmphasizedDecelerate
),
targetOffset = { it }
) + fadeOut(animationSpec = RevoltTweenFloat)
}
) { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelScreen(
channelId = channelId,
onToggleDrawer = {},
useDrawer = false,
useBackButton = true,
backButtonAction = {
navController.popBackStack()
},
useChatUI = true
)
}
composable("create/group") { CreateGroupScreen(navController) }
composable("discover") { DiscoverScreen(navController) }
composable("settings") { SettingsScreen(navController) }
composable("settings/profile") { ProfileSettingsScreen(navController) }
composable("settings/sessions") { SessionSettingsScreen(navController) }
composable("settings/appearance") { AppearanceSettingsScreen(navController) }
composable("settings/chat") { ChatSettingsScreen(navController) }
composable("settings/debug") { DebugSettingsScreen(navController) }
composable("settings/experiments") { ExperimentsSettingsScreen(navController) }
composable("settings/changelogs") { ChangelogsSettingsScreen(navController) }
composable("settings/language") { LanguagePickerSettingsScreen(navController) }
composable("settings/channel/{channelId}") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelSettingsHome(navController, channelId)
}
composable("settings/channel/{channelId}/overview") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelSettingsOverview(navController, channelId)
}
composable("settings/channel/{channelId}/permissions") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelSettingsPermissions(navController, channelId)
}
composable("about") { AboutScreen(navController) }
composable("about/oss") { AttributionScreen(navController) }
composable("labs") { LabsRootScreen(navController) }
}
}
if (showVoiceUI) { // if tapped outside the voice UI, close it
Box(
Modifier
.fillMaxSize()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
showVoiceUI = false
}
)
}
AnimatedVisibility(
visible = showVoiceUI,
modifier = Modifier.align(Alignment.BottomCenter),
enter = slideInVertically(
initialOffsetY = { it -> it },
animationSpec = tween(
durationMillis = 300,
easing = EasingTokens.EmphasizedDecelerate
)
),
exit = slideOutVertically(
targetOffsetY = { it -> it },
animationSpec = tween(
durationMillis = 300,
easing = EasingTokens.EmphasizedDecelerate
)
)
) {
// We need a box as applying the padding elsewhere leads to either
// janky animation or layout
Box(Modifier.safeDrawingPadding()) {
Card(
Modifier
.fillMaxWidth()
.widthIn(max = 600.dp)
.padding(8.dp)
) {
VoicePermissionSwitch(
onCancel = {
showVoiceUI = false
}
) {
voiceChannelId?.let {
VoiceSheet(
it,
onDisconnect = {
showVoiceUI = false
voiceChannelId = null
}
)
}
}
}
}
composable("create/group") { CreateGroupScreen(navController) }
composable("discover") { DiscoverScreen(navController) }
composable("settings") { SettingsScreen(navController) }
composable("settings/profile") { ProfileSettingsScreen(navController) }
composable("settings/sessions") { SessionSettingsScreen(navController) }
composable("settings/appearance") { AppearanceSettingsScreen(navController) }
composable("settings/chat") { ChatSettingsScreen(navController) }
composable("settings/debug") { DebugSettingsScreen(navController) }
composable("settings/experiments") { ExperimentsSettingsScreen(navController) }
composable("settings/changelogs") { ChangelogsSettingsScreen(navController) }
composable("settings/language") { LanguagePickerSettingsScreen(navController) }
composable("settings/channel/{channelId}") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelSettingsHome(navController, channelId)
}
composable("settings/channel/{channelId}/overview") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelSettingsOverview(navController, channelId)
}
composable("settings/channel/{channelId}/permissions") { backStackEntry ->
val channelId = backStackEntry.arguments?.getString("channelId") ?: ""
ChannelSettingsPermissions(navController, channelId)
}
composable("about") { AboutScreen(navController) }
composable("about/oss") { AttributionScreen(navController) }
composable("labs") { LabsRootScreen(navController) }
}
}
}

View File

@ -53,14 +53,20 @@ import kotlinx.serialization.json.Json
import java.net.SocketException
import chat.revolt.api.schemas.Channel as ChannelSchema
const val REVOLT_BASE = "https://api.revolt.chat/0.8"
private const val USE_ALPHA_API = false
val REVOLT_BASE =
if (USE_ALPHA_API) "https://alpha.revolt.chat/api" else "https://api.revolt.chat/0.8"
const val REVOLT_SUPPORT = "https://support.revolt.chat"
const val REVOLT_MARKETING = "https://revolt.chat"
const val REVOLT_FILES = "https://cdn.revoltusercontent.com"
const val REVOLT_JANUARY = "https://jan.revolt.chat"
val REVOLT_FILES =
if (USE_ALPHA_API) "https://alpha.revolt.chat/autumn" else "https://cdn.revoltusercontent.com"
val REVOLT_JANUARY =
if (USE_ALPHA_API) "https://alpha.revolt.chat/january" else "https://jan.revolt.chat"
const val REVOLT_APP = "https://app.revolt.chat"
const val REVOLT_INVITES = "https://rvlt.gg"
const val REVOLT_WEBSOCKET = "wss://ws.revolt.chat"
val REVOLT_WEBSOCKET =
if (USE_ALPHA_API) "wss://alpha.revolt.chat/ws" else "wss://ws.revolt.chat"
const val REVOLT_KJBOOK = "https://revoltchat.github.io/android"
fun String.api(): String {
@ -68,7 +74,9 @@ fun String.api(): String {
}
fun buildUserAgent(accessMethod: String = "Ktor"): String {
return "$accessMethod RevoltAndroid/${BuildConfig.VERSION_NAME} ${BuildConfig.APPLICATION_ID} (Android ${android.os.Build.VERSION.SDK_INT}; ${android.os.Build.MANUFACTURER} ${android.os.Build.DEVICE}; (Kotlin ${KotlinVersion.CURRENT})"
return "$accessMethod RevoltAndroid/${BuildConfig.VERSION_NAME} " +
"${BuildConfig.APPLICATION_ID} Android/${android.os.Build.VERSION.SDK_INT} " +
"(${android.os.Build.MANUFACTURER} ${android.os.Build.DEVICE}) Kotlin/${KotlinVersion.CURRENT}"
}
@OptIn(ExperimentalSerializationApi::class)

View File

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

View File

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

View File

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

View File

@ -18,36 +18,48 @@ data class Root(
@Serializable
data class Features(
val captcha: CAPTCHA,
val captcha: CAPTCHAFeature,
val email: Boolean,
@SerialName("invite_only")
val inviteOnly: Boolean,
val autumn: Autumn,
val january: Autumn,
val voso: Voso
@SerialName("invite_only") val inviteOnly: Boolean,
val autumn: AutumnJanuaryFeature,
val january: AutumnJanuaryFeature,
val voso: LegacyVoiceFeature? = null,
val livekit: LiveKitFeature? = null,
)
@Serializable
data class Autumn(
data class AutumnJanuaryFeature(
val enabled: Boolean,
val url: String
)
@Serializable
data class CAPTCHA(
data class CAPTCHAFeature(
val enabled: Boolean,
val key: String
)
@Serializable
data class Voso(
data class LegacyVoiceFeature(
val enabled: Boolean,
val url: String,
val ws: String
)
@Serializable
data class LiveKitFeature(
val enabled: Boolean,
val nodes: List<LiveKitNode>
)
@Serializable
data class LiveKitNode(
val name: String,
val lat: Double,
val lon: Double,
@SerialName("public_url") val publicUrl: String,
)
suspend fun getRootRoute(): Root {
return RevoltHttp.get("/".api()).body()
}

View File

@ -0,0 +1,35 @@
package chat.revolt.api.routes.voice
import chat.revolt.api.RevoltError
import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson
import chat.revolt.api.api
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
@Serializable
data class JoinCallResponse(
val token: String,
val url: String,
)
suspend fun joinCall(channelId: String, nodeName: String): JoinCallResponse {
val response = RevoltHttp.post("/channels/$channelId/join_call".api()) {
contentType(ContentType.Application.Json)
setBody(mapOf("node" to nodeName))
}.bodyAsText()
try {
val error = RevoltJson.decodeFromString(RevoltError.serializer(), response)
throw Exception(error.type)
} catch (e: SerializationException) {
// Not an error
}
return RevoltJson.decodeFromString(JoinCallResponse.serializer(), response)
}

View File

@ -0,0 +1,20 @@
package chat.revolt.api.schemas
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ChannelVoiceState(
val id: String,
val participants: List<UserVoiceState>,
val node: String,
)
@Serializable
data class UserVoiceState(
val id: String,
@SerialName("is_receiving") val isReceiving: Boolean,
@SerialName("is_publishing") val isPublishing: Boolean,
val screensharing: Boolean,
val camera: Boolean,
)

View File

@ -17,19 +17,6 @@ sealed class LabsAccessControlVariates {
data class Restricted(val predicate: () -> Boolean) : LabsAccessControlVariates()
}
@FeatureFlag("MediaConversations")
sealed class MediaConversationsVariates {
@Treatment(
"Enable voice, video and screen conversations for all users"
)
object Enabled : MediaConversationsVariates()
@Treatment(
"Enable voice, video and screen conversations for users that meet certain or all criteria (implementation-specific)"
)
data class Restricted(val predicate: () -> Boolean) : MediaConversationsVariates()
}
@FeatureFlag("UserCards")
sealed class UserCardsVariates {
@Treatment(
@ -56,6 +43,19 @@ sealed class MassMentionsVariates {
object Disabled : MassMentionsVariates()
}
@FeatureFlag("VoiceChannels2_0")
sealed class VoiceChannels2_0Variates {
@Treatment(
"Enable the new voice channels 2.0 for all users"
)
object Enabled : VoiceChannels2_0Variates()
@Treatment(
"Disable the new voice channels 2.0 for all users"
)
object Disabled : VoiceChannels2_0Variates()
}
object FeatureFlags {
@FeatureFlag("LabsAccessControl")
var labsAccessControl by mutableStateOf<LabsAccessControlVariates>(
@ -69,19 +69,6 @@ object FeatureFlags {
is LabsAccessControlVariates.Restricted -> (labsAccessControl as LabsAccessControlVariates.Restricted).predicate()
}
@FeatureFlag("MediaConversations")
var mediaConversations by mutableStateOf<MediaConversationsVariates>(
MediaConversationsVariates.Restricted {
RevoltAPI.selfId == SpecialUsers.JENNIFER
}
)
val mediaConversationsGranted: Boolean
get() = when (mediaConversations) {
is MediaConversationsVariates.Enabled -> true
is MediaConversationsVariates.Restricted -> (mediaConversations as MediaConversationsVariates.Restricted).predicate()
}
@FeatureFlag("UserCards")
var userCards by mutableStateOf<UserCardsVariates>(
UserCardsVariates.Restricted {
@ -97,11 +84,21 @@ object FeatureFlags {
@FeatureFlag("MassMentions")
var massMentions by mutableStateOf<MassMentionsVariates>(
MassMentionsVariates.Enabled
MassMentionsVariates.Disabled
)
val massMentionsGranted: Boolean
get() = when (massMentions) {
is MassMentionsVariates.Enabled -> true
is MassMentionsVariates.Disabled -> false
}
@FeatureFlag("VoiceChannels2_0")
var voiceChannels2_0 by mutableStateOf<VoiceChannels2_0Variates>(
VoiceChannels2_0Variates.Disabled
)
val voiceChannels2_0Granted: Boolean
get() = when (voiceChannels2_0) {
is VoiceChannels2_0Variates.Enabled -> true
is VoiceChannels2_0Variates.Disabled -> false
}
}

View File

@ -8,19 +8,21 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -184,7 +186,7 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
when (type) {
SystemMessageType.CHANNEL_OWNERSHIP_CHANGED -> {
Icon(
painter = painterResource(R.drawable.ic_key_arrow_right_24dp),
painter = painterResource(R.drawable.icn_key_24dp),
contentDescription = stringResource(R.string.system_message_ownership_changed_alt),
tint = LocalContentColor.current,
modifier = modifier.size(size)
@ -193,7 +195,7 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
SystemMessageType.CHANNEL_ICON_CHANGED -> {
Icon(
painter = painterResource(R.drawable.ic_image_multiple_24dp),
painter = painterResource(R.drawable.icn_landscape_2_edit_24dp),
contentDescription = stringResource(
R.string.system_message_channel_icon_changed_alt
),
@ -204,7 +206,7 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
SystemMessageType.CHANNEL_DESCRIPTION_CHANGED -> {
Icon(
painter = painterResource(R.drawable.ic_text_box_multiple_24dp),
painter = painterResource(R.drawable.icn_contract_edit_24dp),
contentDescription = stringResource(
R.string.system_message_channel_description_changed_alt
),
@ -215,7 +217,7 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
SystemMessageType.CHANNEL_RENAMED -> {
Icon(
painter = painterResource(R.drawable.ic_cursor_text_24dp),
painter = painterResource(R.drawable.icn_ink_highlighter_move_24dp),
contentDescription = stringResource(R.string.system_message_channel_renamed_alt),
tint = LocalContentColor.current,
modifier = modifier.size(size)
@ -224,7 +226,7 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
SystemMessageType.USER_REMOVE -> {
Icon(
painter = painterResource(R.drawable.ic_account_cancel_24dp),
painter = painterResource(R.drawable.icn_group_remove_24dp),
contentDescription = stringResource(R.string.system_message_user_removed_alt),
tint = LocalContentColor.current,
modifier = modifier.size(size)
@ -233,7 +235,7 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
SystemMessageType.USER_ADDED -> {
Icon(
painter = painterResource(R.drawable.ic_account_plus_24dp),
painter = painterResource(R.drawable.icn_group_add_24dp),
contentDescription = stringResource(R.string.system_message_user_added_alt),
tint = LocalContentColor.current,
modifier = modifier.size(size)
@ -242,7 +244,7 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
SystemMessageType.USER_BANNED -> {
Icon(
painter = painterResource(R.drawable.ic_gavel_24dp),
painter = painterResource(R.drawable.icn_gavel_24dp),
contentDescription = stringResource(R.string.system_message_user_banned_alt),
tint = LocalContentColor.current,
modifier = modifier.size(size)
@ -251,7 +253,7 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
SystemMessageType.USER_KICKED -> {
Icon(
painter = painterResource(R.drawable.ic_shield_24dp),
painter = painterResource(R.drawable.icn_sports_and_outdoors_24dp),
contentDescription = stringResource(R.string.system_message_user_kicked_alt),
tint = LocalContentColor.current,
modifier = modifier.size(size)
@ -260,7 +262,7 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
SystemMessageType.USER_LEFT -> {
Icon(
painter = painterResource(R.drawable.ic_account_arrow_left_24dp),
painter = painterResource(R.drawable.icn_door_open_24dp),
contentDescription = stringResource(R.string.system_message_user_left_alt),
tint = LocalContentColor.current,
modifier = modifier.size(size)
@ -269,16 +271,16 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
SystemMessageType.USER_JOINED -> {
Icon(
painter = painterResource(R.drawable.ic_account_arrow_right_24dp),
painter = painterResource(R.drawable.icn_waving_hand_24dp),
contentDescription = stringResource(R.string.system_message_user_joined_alt),
tint = LocalContentColor.current,
modifier = modifier.size(size)
modifier = modifier.size(size),
)
}
SystemMessageType.TEXT -> {
Icon(
imageVector = Icons.Default.Info,
painter = painterResource(R.drawable.icn_info_24dp),
contentDescription = stringResource(R.string.system_message_text_alt),
tint = LocalContentColor.current,
modifier = modifier.size(size)
@ -287,6 +289,36 @@ fun SystemMessageIcon(type: SystemMessageType, modifier: Modifier = Modifier, si
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun shapeForType(type: SystemMessageType): Shape {
return when (type) {
SystemMessageType.CHANNEL_OWNERSHIP_CHANGED -> MaterialShapes.Slanted
SystemMessageType.CHANNEL_ICON_CHANGED -> MaterialShapes.Pentagon
SystemMessageType.CHANNEL_DESCRIPTION_CHANGED -> MaterialShapes.Gem
SystemMessageType.CHANNEL_RENAMED -> MaterialShapes.Pill
SystemMessageType.USER_REMOVE -> MaterialShapes.Flower
SystemMessageType.USER_ADDED -> MaterialShapes.Sunny
SystemMessageType.USER_BANNED -> MaterialShapes.Burst
SystemMessageType.USER_KICKED -> MaterialShapes.SoftBurst
SystemMessageType.USER_LEFT -> MaterialShapes.Cookie4Sided
SystemMessageType.USER_JOINED -> MaterialShapes.Cookie9Sided
SystemMessageType.TEXT -> MaterialShapes.Square
}.toShape()
}
// TODO - find the best colours for each type
@Composable
private fun backgroundColourForType(type: SystemMessageType): Color {
return MaterialTheme.colorScheme.primaryContainer
}
@Composable
private fun contentColourForType(type: SystemMessageType): Color {
return MaterialTheme.colorScheme.onPrimaryContainer
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SystemMessageIconWithBackground(
type: SystemMessageType,
@ -296,10 +328,16 @@ fun SystemMessageIconWithBackground(
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.3f))
.clip(shapeForType(type))
.background(
color = backgroundColourForType(type),
)
.size(size)
) {
SystemMessageIcon(type = type)
CompositionLocalProvider(
LocalContentColor provides contentColourForType(type),
) {
SystemMessageIcon(type = type)
}
}
}

View File

@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.revolt.R
@ -29,10 +30,16 @@ import chat.revolt.api.schemas.User
import chat.revolt.composables.generic.UserAvatar
@Composable
fun StackedUserAvatars(users: List<String>, amount: Int = 3, serverId: String?) {
fun StackedUserAvatars(
users: List<String>,
amount: Int = 3,
size: Dp = 16.dp,
offset: Dp = 8.dp,
serverId: String?
) {
Box(
modifier = Modifier
.size(16.dp + (8.dp * minOf(users.size, amount)), 16.dp)
.size(size + (offset * minOf(users.size, amount)), size)
) {
users.take(amount).forEachIndexed { index, userId ->
val user = RevoltAPI.userCache[userId]
@ -44,10 +51,10 @@ fun StackedUserAvatars(users: List<String>, amount: Int = 3, serverId: String?)
username = user?.let { User.resolveDefaultName(it) }
?: stringResource(id = R.string.unknown),
rawUrl = maybeMember?.avatar?.let { "$REVOLT_FILES/avatars/${it.id}" },
size = 16.dp,
size = size,
modifier = Modifier
.offset(
x = (index * 8).dp
x = (index * offset.value).dp
)
)
}
@ -91,7 +98,7 @@ fun TypingIndicator(users: List<String>, serverId: String?) {
RevoltAPI.userCache[userId]?.let { u ->
val maybeMember =
serverId?.let { RevoltAPI.members.getMember(serverId, userId) }
maybeMember?.nickname ?: User.resolveDefaultName(u)
} ?: userId
}

View File

@ -1,7 +1,9 @@
package chat.revolt.composables.screens.chat.drawer
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
@ -28,6 +30,7 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@ -71,6 +74,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
@ -96,6 +100,7 @@ import chat.revolt.composables.generic.UserAvatar
import chat.revolt.composables.generic.presenceFromStatus
import chat.revolt.composables.screens.chat.ChannelIcon
import chat.revolt.screens.chat.ChatRouterDestination
import chat.revolt.screens.chat.LocalIsConnected
import chat.revolt.sheets.ChannelContextSheet
import kotlinx.coroutines.launch
@ -154,6 +159,17 @@ fun ChannelSideDrawer(
), label = "Server banner height"
)
val serverInfoOffset by animateDpAsState(
if (LocalIsConnected.current)
WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
else
0.dp,
animationSpec = spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Dp.VisibilityThreshold
)
)
// - Take the list of servers and filter them by the ones that are in the ordering.
// - Sort the servers that are in the ordering using the ordering.
// - Add the servers that aren't in the ordering to the end of the list.
@ -202,30 +218,40 @@ fun ChannelSideDrawer(
)
) {
stickyHeader(key = "self") {
UserAvatar(
username = RevoltAPI.userCache[RevoltAPI.selfId]?.let {
User.resolveDefaultName(
it
Column(Modifier.background(MaterialTheme.colorScheme.background)) {
AnimatedVisibility(LocalIsConnected.current) {
Spacer(
Modifier
.height(
WindowInsets.statusBars.asPaddingValues()
.calculateTopPadding()
)
)
}
?: "",
presence = presenceFromStatus(
RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence,
RevoltAPI.userCache[RevoltAPI.selfId]?.online ?: false
),
userId = RevoltAPI.selfId ?: "",
avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar,
size = 48.dp,
presenceSize = 16.dp,
onClick = {
onDestinationChanged(ChatRouterDestination.defaultForDMList)
},
onLongClick = onLongPressAvatar,
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.padding(8.dp)
.size(48.dp)
)
UserAvatar(
username = RevoltAPI.userCache[RevoltAPI.selfId]?.let {
User.resolveDefaultName(
it
)
}
?: "",
presence = presenceFromStatus(
RevoltAPI.userCache[RevoltAPI.selfId]?.status?.presence,
RevoltAPI.userCache[RevoltAPI.selfId]?.online ?: false
),
userId = RevoltAPI.selfId ?: "",
avatar = RevoltAPI.userCache[RevoltAPI.selfId]?.avatar,
size = 48.dp,
presenceSize = 16.dp,
onClick = {
onDestinationChanged(ChatRouterDestination.defaultForDMList)
},
onLongClick = onLongPressAvatar,
modifier = Modifier
.padding(8.dp)
.size(48.dp)
)
}
}
items(
@ -314,12 +340,12 @@ fun ChannelSideDrawer(
)
val leftIndicatorColour = animateColorAsState(
targetValue =
if (serverInList.id == currentServer)
MaterialTheme.colorScheme.primary
else if (serverHasUnread)
MaterialTheme.colorScheme.onSurfaceVariant
else
Color.Transparent,
if (serverInList.id == currentServer)
MaterialTheme.colorScheme.primary
else if (serverHasUnread)
MaterialTheme.colorScheme.onSurfaceVariant
else
Color.Transparent,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
@ -438,27 +464,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()
)
@ -470,7 +499,7 @@ fun ChannelSideDrawer(
drawRect(
Brush.linearGradient(
listOf(
surfaceContainer.copy(alpha = 0.8f),
Color.Black.copy(alpha = 0.6f),
Color.Transparent
),
Offset.Zero,
@ -482,63 +511,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))
}
}
}
@ -767,7 +805,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
@ -845,7 +884,8 @@ fun ChannelItem(
}
}
) {
Row(verticalAlignment = Alignment.CenterVertically,
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
modifier = Modifier
.padding(start = 8.dp, end = 8.dp)

View File

@ -0,0 +1,61 @@
package chat.revolt.composables.screens.chat.molecules
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import chat.revolt.R
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.composables.screens.chat.StackedUserAvatars
import kotlinx.coroutines.launch
@Composable
fun JoinVoiceChannelButton(channelId: String, modifier: Modifier = Modifier) {
val scope = rememberCoroutineScope()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.clip(MaterialTheme.shapes.large)
.clickable {
scope.launch { ActionChannel.send(Action.OpenVoiceChannelOverlay(channelId)) }
}
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(16.dp)
.fillMaxWidth()
) {
StackedUserAvatars(
listOf(
"01FHGJ3NPP7XANQQH8C2BE44ZY",
"01F1WKM5TK2V6KCZWR6DGBJDTZ",
"01EX2NCWQ0CHS3QJF0FEQS1GR4"
),
size = 24.dp,
offset = 12.dp,
amount = 3,
serverId = null
)
Text(
stringResource(R.string.voice_join_offering),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center
)
Text(
stringResource(R.string.voice_join_offering_description_other, Integer.MAX_VALUE),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
}

View File

@ -1,64 +0,0 @@
package chat.revolt.composables.screens.voice
import android.content.Context
import android.util.DisplayMetrics
import android.view.WindowManager
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@Composable
fun VoiceChannelOverlay(channelId: String, onCollapse: () -> Unit) {
val context = LocalContext.current
val windowManager =
remember { context.getSystemService(Context.WINDOW_SERVICE) as WindowManager }
val metrics = DisplayMetrics().apply {
@Suppress("DEPRECATION") // We *need* the real metrics here, not the window metrics
windowManager.defaultDisplay.getRealMetrics(this)
}
val (width, height) = with(LocalDensity.current) {
Pair(metrics.widthPixels.toDp(), metrics.heightPixels.toDp())
}
Dialog(
onDismissRequest = {
onCollapse()
},
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = true,
dismissOnClickOutside = false,
dismissOnBackPress = true
)
) {
Column(
Modifier
.requiredSize(width, height)
.background(MaterialTheme.colorScheme.background),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically)
) {
Text(text = "Voice channel overlay for $channelId", textAlign = TextAlign.Center)
Button(onClick = {
onCollapse()
}) {
Text("Close")
}
}
}
}

View File

@ -0,0 +1,144 @@
package chat.revolt.composables.voice
import android.Manifest
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import chat.revolt.R
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.shouldShowRationale
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun VoicePermissionSwitch(onCancel: () -> Unit, onPermissionGranted: @Composable () -> Unit) {
val context = LocalContext.current
var permissionState = rememberMultiplePermissionsState(
listOf(
Manifest.permission.RECORD_AUDIO,
Manifest.permission.CAMERA
),
onPermissionsResult = {}
)
var fullyRevoked = permissionState
.revokedPermissions
.any { it.status is PermissionStatus.Denied && !it.status.shouldShowRationale }
if (permissionState.allPermissionsGranted) {
onPermissionGranted()
} else if (permissionState.shouldShowRationale || fullyRevoked) {
ModalBottomSheet(
onDismissRequest = onCancel,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
sheetGesturesEnabled = false,
dragHandle = {}
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 32.dp, vertical = 16.dp)
) {
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.voice_join_permission_rationale_heading),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(R.string.voice_join_permission_rationale_description),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.icn_mic_24dp),
contentDescription = null
)
Text(
text = stringResource(R.string.voice_join_permission_rationale_permission_mic),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Start,
modifier = Modifier.weight(1f)
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.icn_videocam_24dp),
contentDescription = null
)
Text(
text = stringResource(R.string.voice_join_permission_rationale_permission_camera),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Start,
modifier = Modifier.weight(1f)
)
}
Text(
text = stringResource(R.string.voice_join_permission_rationale_assurance),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
if (fullyRevoked) {
// Launch settings to allow the user to manually enable permissions
val intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:${context.packageName}".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} else {
permissionState.launchMultiplePermissionRequest()
}
},
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(R.string.voice_join_permission_rationale_cta))
}
}
}
} else {
LaunchedEffect(Unit) {
permissionState.launchMultiplePermissionRequest()
}
}
}

View File

@ -0,0 +1,137 @@
package chat.revolt.composables.voice
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.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 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()
}
// TODO - Voice channels are not supported yet
LaunchedEffect(Unit) { onDisconnect() }
/*RoomScope(
url = viewModel.voiceLkNode,
token = viewModel.voiceToken,
audio = true,
video = false,
connect = true,
) {
val room = RoomLocal.current
val trackRefs = rememberTracks()
Column {
LazyColumn(modifier = Modifier.animateContentSize()) {
items(trackRefs.size) { index ->
VideoTrackView(
trackReference = trackRefs[index],
modifier = Modifier.fillParentMaxHeight(0.5f)
)
}
item(key = "stats") {
Text("status = ${room.state.name}")
}
item(key = "controls") {
Button(onClick = {
room.setMicrophoneMute(true)
}) {
Text("mute 🎤🫷😤")
}
Button(onClick = {
room.setMicrophoneMute(false)
}) {
Text("unmute 🗣️📢🔥")
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(
colors = ButtonDefaults.buttonColors().copy(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
disabledContainerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f),
disabledContentColor = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.5f)
),
onClick = {
room.disconnect()
onDisconnect()
},
) {
Text("disconnect")
}
}
}
}*/
}

View File

@ -10,15 +10,17 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.AlertDialog
@ -38,15 +40,20 @@ import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
@ -70,7 +77,6 @@ import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.composables.chat.DisconnectedNotice
import chat.revolt.composables.screens.chat.drawer.ChannelSideDrawer
import chat.revolt.composables.screens.voice.VoiceChannelOverlay
import chat.revolt.dialogs.NotificationRationaleDialog
import chat.revolt.internals.Changelogs
import chat.revolt.internals.extensions.zero
@ -261,12 +267,16 @@ class ChatRouterViewModel @Inject constructor(
}
}
val LocalIsConnected = compositionLocalOf(structuralEqualityPolicy()) { false }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatRouterScreen(
topNav: NavController,
windowSizeClass: WindowSizeClass,
disableBackHandler: Boolean,
onNullifiedUser: () -> Unit,
onEnterVoiceUI: (String) -> Unit,
viewModel: ChatRouterViewModel = hiltViewModel()
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
@ -274,6 +284,8 @@ fun ChatRouterScreen(
val context = LocalContext.current
val view = LocalView.current
var drawerWidth by remember { mutableFloatStateOf(0.0f) }
var showPlatformModDMHint by remember { mutableStateOf(false) }
var showStatusSheet by remember { mutableStateOf(false) }
@ -302,9 +314,6 @@ fun ChatRouterScreen(
var useTabletAwareUI by remember { mutableStateOf(false) }
var voiceChannelOverlay by remember { mutableStateOf(false) }
var voiceChannelOverlayChannelId by remember { mutableStateOf("") }
var showReportUser by remember { mutableStateOf(false) }
var reportUserTarget by remember { mutableStateOf("") }
@ -444,8 +453,7 @@ fun ChatRouterScreen(
}
is Action.OpenVoiceChannelOverlay -> {
voiceChannelOverlayChannelId = action.channelId
voiceChannelOverlay = true
onEnterVoiceUI(action.channelId)
}
is Action.OpenWebhookSheet -> {
@ -712,12 +720,6 @@ fun ChatRouterScreen(
}
}
if (voiceChannelOverlay) {
VoiceChannelOverlay(voiceChannelOverlayChannelId) {
voiceChannelOverlay = false
}
}
val askNotificationsPermission =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
@ -812,53 +814,11 @@ fun ChatRouterScreen(
)
}
AnimatedVisibility(
visible = RealtimeSocket.disconnectionState == DisconnectionState.Connected
CompositionLocalProvider(
LocalIsConnected provides (RealtimeSocket.disconnectionState == DisconnectionState.Connected)
) {
Spacer(Modifier.windowInsetsPadding(WindowInsets.statusBars))
}
if (useTabletAwareUI) {
Row {
DismissibleDrawerSheet(
drawerContainerColor = Color.Transparent,
windowInsets = WindowInsets.zero
) {
Sidebar(
viewModel = viewModel,
topNav = topNav,
currentServer = currentServer,
onShowStatusSheet = {
showStatusSheet = true
},
onShowServerContextSheet = {
serverContextSheetTarget = it
showServerContextSheet = true
},
onShowAddServerSheet = {
showAddServerSheet = true
},
showSettingsButton = isTouchExplorationEnabled,
onOpenSettings = {
topNav.navigate("settings")
},
)
}
ChannelNavigator(
dest = viewModel.currentDestination,
topNav = topNav,
useDrawer = false,
toggleDrawer = {
toggleDrawerLambda()
}
)
}
} else {
var useSidebarGesture by remember { mutableStateOf(true) }
DismissibleNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = useSidebarGesture,
drawerContent = {
if (useTabletAwareUI) {
Row {
DismissibleDrawerSheet(
drawerContainerColor = Color.Transparent,
windowInsets = WindowInsets.zero
@ -881,28 +841,104 @@ fun ChatRouterScreen(
onOpenSettings = {
topNav.navigate("settings")
},
drawerState = drawerState
)
}
},
content = {
Row(Modifier.fillMaxSize()) {
ChannelNavigator(
dest = viewModel.currentDestination,
topNav = topNav,
useDrawer = true,
toggleDrawer = {
toggleDrawerLambda()
},
drawerState = drawerState,
drawerGestureEnabled = useSidebarGesture,
setDrawerGestureEnabled = {
useSidebarGesture = it
}
)
}
ChannelNavigator(
dest = viewModel.currentDestination,
topNav = topNav,
useDrawer = false,
disableBackHandler = disableBackHandler,
toggleDrawer = {
toggleDrawerLambda()
},
onEnterVoiceUI = onEnterVoiceUI,
)
}
)
} else {
var useSidebarGesture by remember { mutableStateOf(true) }
DismissibleNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = useSidebarGesture,
drawerContent = {
DismissibleDrawerSheet(
drawerContainerColor = Color.Transparent,
windowInsets = WindowInsets.zero,
modifier = Modifier.onSizeChanged {
drawerWidth = it.width.toFloat()
}
) {
Sidebar(
viewModel = viewModel,
topNav = topNav,
currentServer = currentServer,
onShowStatusSheet = {
showStatusSheet = true
},
onShowServerContextSheet = {
serverContextSheetTarget = it
showServerContextSheet = true
},
onShowAddServerSheet = {
showAddServerSheet = true
},
showSettingsButton = isTouchExplorationEnabled,
onOpenSettings = {
topNav.navigate("settings")
},
drawerState = drawerState
)
}
},
content = {
Box(Modifier.fillMaxSize()) {
ChannelNavigator(
dest = viewModel.currentDestination,
topNav = topNav,
useDrawer = true,
disableBackHandler = disableBackHandler,
toggleDrawer = {
toggleDrawerLambda()
},
drawerState = drawerState,
drawerGestureEnabled = useSidebarGesture,
setDrawerGestureEnabled = {
useSidebarGesture = it
},
onEnterVoiceUI = onEnterVoiceUI,
)
// This is the overlay on the main content when the drawer is open
val interactionSource = remember { MutableInteractionSource() }
Box(
Modifier
.then(
if (drawerState.isOpen) {
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
enabled = drawerState.isOpen,
onClick = {
scope.launch {
drawerState.close()
}
}
)
} else Modifier
)
.fillMaxSize()
.background(
MaterialTheme
.colorScheme
.surfaceContainerLowest
.copy(
alpha = (1.0f + (drawerState.currentOffset / drawerWidth)) * 0.7f
)
)
)
}
}
)
}
}
}
}
@ -942,11 +978,13 @@ fun ChannelNavigator(
toggleDrawer: () -> Unit,
drawerState: DrawerState? = null,
drawerGestureEnabled: Boolean = true,
disableBackHandler: Boolean = false,
onEnterVoiceUI: (String) -> Unit = {},
setDrawerGestureEnabled: (Boolean) -> Unit = {},
) {
val scope = rememberCoroutineScope()
BackHandler(enabled = useDrawer) {
BackHandler(useDrawer && !disableBackHandler) {
toggleDrawer()
}

View File

@ -4,6 +4,7 @@ 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
@ -11,10 +12,14 @@ 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.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
@ -97,6 +102,7 @@ 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 chat.revolt.screens.chat.LocalIsConnected
import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanQRCode
import kotlinx.coroutines.Dispatchers
@ -470,69 +476,80 @@ fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: ()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.friends),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
Column {
AnimatedVisibility(LocalIsConnected.current) {
Spacer(
Modifier
.height(
WindowInsets.statusBars.asPaddingValues()
.calculateTopPadding()
)
)
},
navigationIcon = {
if (useDrawer) {
}
TopAppBar(
title = {
Text(
text = stringResource(R.string.friends),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
if (useDrawer) {
IconButton(onClick = {
onDrawerClicked()
}) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(id = R.string.menu)
)
}
}
},
actions = {
IconButton(onClick = {
onDrawerClicked()
overflowMenuShown = true
}) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(id = R.string.menu)
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.menu)
)
}
}
},
actions = {
IconButton(onClick = {
overflowMenuShown = true
}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.menu)
)
}
DropdownMenu(
expanded = overflowMenuShown,
onDismissRequest = {
overflowMenuShown = false
}
) {
DropdownMenuItem(
text = {
Text(stringResource(R.string.friends_deny_all_incoming))
},
onClick = {
scope.launch {
overflowMenuShown = false
}
with(Dispatchers.IO) {
DropdownMenu(
expanded = overflowMenuShown,
onDismissRequest = {
overflowMenuShown = false
}
) {
DropdownMenuItem(
text = {
Text(stringResource(R.string.friends_deny_all_incoming))
},
onClick = {
scope.launch {
FriendRequests.getIncoming()
.forEach { it.id?.let { id -> unfriendUser(id) } }
overflowMenuShown = false
}
with(Dispatchers.IO) {
scope.launch {
FriendRequests.getIncoming()
.forEach { it.id?.let { id -> unfriendUser(id) } }
}
}
}
}
)
}
},
windowInsets = WindowInsets.zero
)
}
)
}
},
windowInsets = WindowInsets.zero
)
}
},
) { pv ->
Box(
modifier = Modifier
.padding(pv)
.fillMaxHeight()
) {
LazyColumn(state = listState) {
LazyColumn {
stickyHeader(key = "incoming") {
CountableListHeader(
text = stringResource(id = R.string.friends_incoming_requests),

View File

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

View File

@ -13,11 +13,13 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.material.icons.Icons
@ -62,6 +64,7 @@ import chat.revolt.composables.generic.NonIdealState
import chat.revolt.composables.screens.settings.UserOverview
import chat.revolt.composables.skeletons.UserOverviewSkeleton
import chat.revolt.internals.extensions.zero
import chat.revolt.screens.chat.LocalIsConnected
import chat.revolt.sheets.UserCardSheet
import io.sentry.Sentry
@ -110,22 +113,33 @@ fun OverviewScreen(
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text(stringResource(R.string.overview_screen_title)) },
navigationIcon = {
if (useDrawer) {
IconButton(onClick = {
onDrawerClicked()
}) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(id = R.string.menu)
Column {
AnimatedVisibility(LocalIsConnected.current) {
Spacer(
Modifier
.height(
WindowInsets.statusBars.asPaddingValues()
.calculateTopPadding()
)
)
}
CenterAlignedTopAppBar(
title = { Text(stringResource(R.string.overview_screen_title)) },
navigationIcon = {
if (useDrawer) {
IconButton(onClick = {
onDrawerClicked()
}) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(id = R.string.menu)
)
}
}
}
},
windowInsets = WindowInsets.zero
)
},
windowInsets = WindowInsets.zero
)
}
},
contentWindowInsets = if (includePadding) ScaffoldDefaults.contentWindowInsets else ScaffoldDefaults.contentWindowInsets.exclude(
NavigationBarDefaults.windowInsets

View File

@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -91,6 +92,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@ -122,6 +124,7 @@ import chat.revolt.api.routes.channel.react
import chat.revolt.api.routes.microservices.autumn.FileArgs
import chat.revolt.api.schemas.ChannelType
import chat.revolt.api.schemas.Message
import chat.revolt.api.settings.FeatureFlags
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.composables.chat.DateDivider
@ -140,10 +143,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
@ -153,7 +158,6 @@ import com.valentinilk.shimmer.shimmer
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
import logcat.logcat
import java.io.File
import kotlin.math.max
@ -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,85 @@ 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
&& FeatureFlags.voiceChannels2_0Granted
) {
JoinVoiceChannelButton(channelId)
}
}
}
@ -960,7 +986,7 @@ fun ChannelScreen(
onToggleSpoiler = {
val index = viewModel.draftAttachments
.indexOfFirst { a -> a.pickerIdentifier == it.pickerIdentifier }
if (index != -1) {
val attachment =
viewModel.draftAttachments[index]

View File

@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.util.fastDistinctBy
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import chat.revolt.R
@ -876,7 +877,14 @@ class ChannelScreenViewModel @Inject constructor(
withContext(Dispatchers.Main) {
items.clear()
items.addAll(groupedItems)
items.addAll(groupedItems.fastDistinctBy {
when (it) {
is ChannelScreenItem.RegularMessage -> it.message.id
is ChannelScreenItem.SystemMessage -> it.message.id
is ChannelScreenItem.DateDivider -> it.instant.toString()
else -> it.toString() // Fallback for other item types
}
})
}
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M360,360L360,280L720,280L720,360L360,360ZM360,480L360,400L720,400L720,480L360,480ZM480,800L240,800Q223,800 211.5,800Q200,800 200,800L200,800L480,800L480,800ZM480,880L240,880Q190,880 155,845Q120,810 120,760L120,640L240,640L240,80L840,80L840,441Q820,439 799.5,442.5Q779,446 760,455L760,160L320,160L320,640L560,640L480,720L200,720L200,760Q200,777 211.5,788.5Q223,800 240,800L480,800L480,880ZM560,880L560,757L781,537Q790,528 801,524Q812,520 823,520Q835,520 846,524.5Q857,529 866,538L903,575Q911,584 915.5,595Q920,606 920,617Q920,628 916,639.5Q912,651 903,660L683,880L560,880ZM860,617L860,617L823,580L823,580L860,617ZM620,820L658,820L779,698L761,679L742,661L620,782L620,820ZM761,679L742,661L742,661L779,698L779,698L761,679Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M440,520Q457,520 468.5,508.5Q480,497 480,480Q480,463 468.5,451.5Q457,440 440,440Q423,440 411.5,451.5Q400,463 400,480Q400,497 411.5,508.5Q423,520 440,520ZM280,840L280,760L520,720L520,275Q520,260 511,248Q502,236 488,234L280,200L280,120L500,156Q544,164 572,197Q600,230 600,274L600,786L280,840ZM120,840L120,760L200,760L200,200Q200,166 223.5,143Q247,120 280,120L680,120Q714,120 737,143Q760,166 760,200L760,760L840,760L840,840L120,840ZM280,760L680,760L680,200Q680,200 680,200Q680,200 680,200L280,200Q280,200 280,200Q280,200 280,200L280,760Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M160,840L160,760L640,760L640,840L160,840ZM386,646L160,420L244,334L472,560L386,646ZM640,392L414,164L500,80L726,306L640,392ZM824,800L302,278L358,222L880,744L824,800Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M500,478Q529,446 544.5,405Q560,364 560,320Q560,276 544.5,235Q529,194 500,162Q500,162 500,162Q500,162 500,162Q560,170 600,215Q640,260 640,320Q640,380 600,425Q560,470 500,478Q500,478 500,478Q500,478 500,478ZM720,800L720,680Q720,644 704,611.5Q688,579 662,554Q713,572 756.5,600.5Q800,629 800,680L800,800L720,800ZM800,520L800,440L720,440L720,360L800,360L800,280L880,280L880,360L960,360L960,440L880,440L880,520L800,520ZM320,480Q254,480 207,433Q160,386 160,320Q160,254 207,207Q254,160 320,160Q386,160 433,207Q480,254 480,320Q480,386 433,433Q386,480 320,480ZM0,800L0,688Q0,654 17.5,625.5Q35,597 64,582Q126,551 190,535.5Q254,520 320,520Q386,520 450,535.5Q514,551 576,582Q605,597 622.5,625.5Q640,654 640,688L640,800L0,800ZM320,400Q353,400 376.5,376.5Q400,353 400,320Q400,287 376.5,263.5Q353,240 320,240Q287,240 263.5,263.5Q240,287 240,320Q240,353 263.5,376.5Q287,400 320,400ZM80,720L560,720L560,688Q560,677 554.5,668Q549,659 540,654Q486,627 431,613.5Q376,600 320,600Q264,600 209,613.5Q154,627 100,654Q91,659 85.5,668Q80,677 80,688L80,720ZM320,320Q320,320 320,320Q320,320 320,320Q320,320 320,320Q320,320 320,320Q320,320 320,320Q320,320 320,320Q320,320 320,320Q320,320 320,320ZM320,720L320,720Q320,720 320,720Q320,720 320,720Q320,720 320,720Q320,720 320,720Q320,720 320,720Q320,720 320,720Q320,720 320,720Q320,720 320,720L320,720Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M500,478Q529,446 544.5,405Q560,364 560,320Q560,276 544.5,235Q529,194 500,162Q500,162 500,162Q500,162 500,162Q560,170 600,215Q640,260 640,320Q640,380 600,425Q560,470 500,478Q500,478 500,478Q500,478 500,478ZM720,800L720,680Q720,644 704,611.5Q688,579 662,554Q713,572 756.5,600.5Q800,629 800,680L800,800L720,800ZM960,440L720,440L720,360L960,360L960,440ZM320,480Q254,480 207,433Q160,386 160,320Q160,254 207,207Q254,160 320,160Q386,160 433,207Q480,254 480,320Q480,386 433,433Q386,480 320,480ZM0,800L0,688Q0,654 17.5,625.5Q35,597 64,582Q126,551 190,535.5Q254,520 320,520Q386,520 450,535.5Q514,551 576,582Q605,597 622.5,625.5Q640,654 640,688L640,800L0,800ZM320,400Q353,400 376.5,376.5Q400,353 400,320Q400,287 376.5,263.5Q353,240 320,240Q287,240 263.5,263.5Q240,287 240,320Q240,353 263.5,376.5Q287,400 320,400ZM80,720L560,720L560,688Q560,677 554.5,668Q549,659 540,654Q486,627 431,613.5Q376,600 320,600Q264,600 209,613.5Q154,627 100,654Q91,659 85.5,668Q80,677 80,688L80,720ZM320,320Q320,320 320,320Q320,320 320,320Q320,320 320,320Q320,320 320,320Q320,320 320,320Q320,320 320,320Q320,320 320,320Q320,320 320,320ZM320,720L320,720Q320,720 320,720Q320,720 320,720Q320,720 320,720Q320,720 320,720Q320,720 320,720Q320,720 320,720Q320,720 320,720Q320,720 320,720L320,720Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M551,160L280,160L280,80L631,80L551,160ZM391,320L160,320L160,240L471,240L391,320ZM231,480L40,480L40,400L311,400L231,480ZM584,560L480,456L280,656Q280,656 280,656Q280,656 280,656L384,760Q384,760 384,760Q384,760 384,760L584,560ZM537,399L641,503L840,304Q840,304 840,304Q840,304 840,304L736,200Q736,200 736,200Q736,200 736,200L537,399ZM453,371L669,587L440,816Q416,840 384,840Q352,840 328,816L326,814L300,840L100,840L226,714L224,712Q200,688 200,656Q200,624 224,600L453,371ZM453,371L680,144Q704,120 736,120Q768,120 792,144L896,248Q920,272 920,304Q920,336 896,360L669,587L453,371Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M280,560Q247,560 223.5,536.5Q200,513 200,480Q200,447 223.5,423.5Q247,400 280,400Q313,400 336.5,423.5Q360,447 360,480Q360,513 336.5,536.5Q313,560 280,560ZM280,720Q180,720 110,650Q40,580 40,480Q40,380 110,310Q180,240 280,240Q347,240 401.5,273Q456,306 488,360L840,360L960,480L780,660L700,600L620,660L535,600L488,600Q456,654 401.5,687Q347,720 280,720ZM280,640Q336,640 378.5,606Q421,572 435,520L560,520L618,561L700,500L771,555L846,480L806,440L435,440Q421,388 378.5,354Q336,320 280,320Q214,320 167,367Q120,414 120,480Q120,546 167,593Q214,640 280,640Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M363,680L363,680L363,680Q363,680 363,680Q363,680 363,680L363,680Q363,680 363,680Q363,680 363,680Q363,680 363,680Q363,680 363,680L363,680Q363,680 363,680Q363,680 363,680Q363,680 363,680Q363,680 363,680L363,680Q363,680 363,680Q363,680 363,680L363,680ZM46,800L184,524Q194,504 212.5,492Q231,480 254,480Q278,480 298,492.5Q318,505 327,528L354,594Q356,600 363,599.5Q370,599 372,593L458,306Q472,258 511.5,229Q551,200 601,200Q650,200 688.5,228.5Q727,257 742,303L778,407Q763,412 749.5,420Q736,428 724,440L710,455L666,328Q658,305 641,292.5Q624,280 601,280Q578,280 560.5,293Q543,306 535,329L449,616Q440,644 416.5,662Q393,680 363,680Q336,680 313,665.5Q290,651 280,625L253,559Q253,559 253,559Q253,559 253,559L135,800L46,800ZM560,840L560,717L781,497Q790,488 801,484Q812,480 823,480Q835,480 846,484.5Q857,489 866,498L903,535Q911,544 915.5,555Q920,566 920,577Q920,588 916,599.5Q912,611 903,620L683,840L560,840ZM860,577L860,577L823,540L823,540L860,577ZM620,780L658,780L779,658L761,639L742,621L620,742L620,780ZM761,639L742,621L742,621L779,658L779,658L761,639ZM240,400Q190,400 155,364.5Q120,329 120,280Q120,230 155,195Q190,160 240,160Q290,160 325,195Q360,230 360,280Q360,329 325,364.5Q290,400 240,400ZM240,320Q257,320 268.5,308.5Q280,297 280,280Q280,263 268.5,251.5Q257,240 240,240Q223,240 211.5,251.5Q200,263 200,280Q200,297 211.5,308.5Q223,320 240,320ZM240,280Q240,280 240,280Q240,280 240,280Q240,280 240,280Q240,280 240,280Q240,280 240,280Q240,280 240,280Q240,280 240,280Q240,280 240,280Z"/>
</vector>

View File

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

View File

@ -0,0 +1,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="M414,792L426,736Q429,723 438.5,714.5Q448,706 462,704L586,694Q599,692 610,699Q621,706 626,718L642,756Q681,733 712,700.5Q743,668 764,628L752,622Q741,614 736,602.5Q731,591 734,578L762,456Q765,444 774.5,436Q784,428 796,426Q791,401 783.5,377.5Q776,354 764,332Q755,337 744.5,336.5Q734,336 726,330L620,266Q609,259 604,247Q599,235 602,222L610,188Q579,174 546.5,167Q514,160 480,160Q466,160 451,161.5Q436,163 422,166L452,234Q457,246 454.5,259Q452,272 442,280L348,362Q338,371 324.5,372Q311,373 300,366L208,310Q185,348 172.5,391.5Q160,435 160,480Q160,496 164,532L252,524Q266,522 277.5,528.5Q289,535 294,548L342,662Q347,674 344.5,687Q342,700 332,708L294,740Q321,760 351.5,773Q382,786 414,792ZM486,620Q473,622 462,615Q451,608 446,596L392,472Q387,460 390.5,447Q394,434 404,426L506,340Q515,331 528,330Q541,329 552,336L664,402Q675,409 681,421Q687,433 684,446L652,576Q649,589 640,597.5Q631,606 618,608L486,620ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z"/>
</vector>

View File

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

View File

@ -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="M430,460L713,177Q725,165 741,165Q757,165 769,177Q781,189 781,205Q781,221 769,233L487,516L430,460ZM529,559L783,304Q795,292 811.5,292Q828,292 840,304Q852,316 852,332.5Q852,349 840,361L586,615L529,559ZM211,749Q120,658 120,530Q120,402 211,311L331,191L390,250Q397,257 402,264.5Q407,272 412,280L560,131Q572,119 588.5,119Q605,119 617,131Q629,143 629,159.5Q629,176 617,188L444,361L444,361L359,445L378,464Q424,510 422,574Q420,638 373,685L373,685L316,629L316,629Q339,606 341.5,574.5Q344,543 321,520L274,474Q262,462 262,445.5Q262,429 274,417L331,361Q343,349 343,332.5Q343,316 331,304L331,304L267,368Q199,436 199,530.5Q199,625 267,693Q335,761 430,761Q525,761 593,693L832,453Q844,441 860.5,441Q877,441 889,453Q901,465 901,481.5Q901,498 889,510L649,749Q558,840 430,840Q302,840 211,749ZM430,530Q430,530 430,530Q430,530 430,530L430,530L430,530L430,530Q430,530 430,530Q430,530 430,530L430,530L430,530L430,530Q430,530 430,530Q430,530 430,530L430,530Q430,530 430,530Q430,530 430,530ZM680,921L680,840Q746,840 793,793Q840,746 840,680L921,680Q921,780 850.5,850.5Q780,921 680,921ZM39,280Q39,180 109.5,109.5Q180,39 280,39L280,120Q214,120 167,167Q120,214 120,280L39,280Z"/>
</vector>

View File

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