mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fix UI issues, fix big memory leak
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
Binary file not shown.
@@ -246,9 +246,11 @@ private object DesktopProtocolRegistration {
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
// Separate AWT windows for Popup/Dialog so menus can draw past the app window edge
|
||||
// (closer to Android Popup behavior). Must be set before any Compose UI runs.
|
||||
System.setProperty("compose.layers.type", "WINDOW")
|
||||
// Do NOT set compose.layers.type=WINDOW.
|
||||
// On macOS Metal that mode creates a JWindow + MetalRedrawer per Popup/Dialog; native
|
||||
// IOAccelerator surfaces are retained after dismiss and grew to multi-GB (Activity Monitor
|
||||
// ~7–8 GB) while the Java heap stayed small. Default COMPONENT layers avoid that leak;
|
||||
// context menus already use PopupProperties(clippingEnabled = false).
|
||||
if (isMacOs()) {
|
||||
// Must be set before AWT Toolkit init (do not call getString / Compose here).
|
||||
// Matches Res.string.app_name / app_name_beta.
|
||||
@@ -302,9 +304,15 @@ fun main(args: Array<String>) {
|
||||
val quit = stringResource(Res.string.desktop_quit)
|
||||
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
||||
var wsLinked by remember { mutableStateOf(WebSocketManager.isConnected) }
|
||||
// ApiClient.token is not a Compose state; poll so menu items update on login/logout.
|
||||
// Only write when the value changes — avoids MenuBar/Tray recomposition churn every tick.
|
||||
var isLoggedIn by remember { mutableStateOf(!ApiClient.token.isNullOrBlank()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
wsLinked = WebSocketManager.isConnected
|
||||
val linked = WebSocketManager.isConnected
|
||||
if (wsLinked != linked) wsLinked = linked
|
||||
val loggedIn = !ApiClient.token.isNullOrBlank()
|
||||
if (isLoggedIn != loggedIn) isLoggedIn = loggedIn
|
||||
delay(400.milliseconds)
|
||||
}
|
||||
}
|
||||
@@ -422,11 +430,13 @@ fun main(args: Array<String>) {
|
||||
}
|
||||
when {
|
||||
!event.isShiftPressed && event.key == Key.N -> {
|
||||
if (!isLoggedIn) return@Window false
|
||||
showMainWindow()
|
||||
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
|
||||
true
|
||||
}
|
||||
!event.isShiftPressed && event.key == Key.F -> {
|
||||
if (!isLoggedIn) return@Window false
|
||||
showMainWindow()
|
||||
DesktopMenuCommands.emit(DesktopMenuCommand.SearchConversations)
|
||||
true
|
||||
@@ -477,6 +487,7 @@ fun main(args: Array<String>) {
|
||||
|
||||
if (mac) {
|
||||
FromChatMenuBar(
|
||||
isLoggedIn = isLoggedIn,
|
||||
onNewChat = {
|
||||
showMainWindow()
|
||||
DesktopMenuCommands.emit(DesktopMenuCommand.NewChat)
|
||||
@@ -520,6 +531,7 @@ fun main(args: Array<String>) {
|
||||
|
||||
@Composable
|
||||
private fun FrameWindowScope.FromChatMenuBar(
|
||||
isLoggedIn: Boolean,
|
||||
onNewChat: () -> Unit,
|
||||
onSearchConversations: () -> Unit,
|
||||
onSelectChats: () -> Unit,
|
||||
@@ -545,19 +557,21 @@ private fun FrameWindowScope.FromChatMenuBar(
|
||||
|
||||
MenuBar {
|
||||
Menu(file, mnemonic = 'F') {
|
||||
Item(
|
||||
newChat,
|
||||
shortcut = KeyShortcut(Key.N, meta = true),
|
||||
onClick = onNewChat,
|
||||
)
|
||||
if (isLoggedIn) {
|
||||
Item(
|
||||
newChat,
|
||||
shortcut = KeyShortcut(Key.N, meta = true),
|
||||
onClick = onNewChat,
|
||||
)
|
||||
|
||||
Item(
|
||||
searchConversations,
|
||||
shortcut = KeyShortcut(Key.F, meta = true),
|
||||
onClick = onSearchConversations,
|
||||
)
|
||||
Item(
|
||||
searchConversations,
|
||||
shortcut = KeyShortcut(Key.F, meta = true),
|
||||
onClick = onSearchConversations,
|
||||
)
|
||||
|
||||
Separator()
|
||||
Separator()
|
||||
}
|
||||
|
||||
Item(
|
||||
quit,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 102 KiB |
@@ -358,6 +358,7 @@ fun App(
|
||||
val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass
|
||||
val isDesktopListDetail = widthSizeClass != WindowWidthSizeClass.COMPACT
|
||||
var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) }
|
||||
var pendingPreAuthSettingsDetailRoute by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Handle startup/deep-link navigation targets (profile links)
|
||||
LaunchedEffect(
|
||||
@@ -505,9 +506,19 @@ fun App(
|
||||
if (command != DesktopMenuCommand.OpenAbout) return@collect
|
||||
pendingMainTab = MAIN_PAGE_SETTINGS
|
||||
if (isDesktopListDetail) {
|
||||
settingsDetailNavController.navigateReplacingMainDetail(
|
||||
route = SettingsRoutes.About,
|
||||
)
|
||||
if (ApiClient.token.isNullOrBlank()) {
|
||||
// Pre-auth: the settings detail NavHost isn't mounted until the root
|
||||
// shell is visible, so we defer settings navigation until `currentRoute`
|
||||
// becomes `chat`.
|
||||
pendingPreAuthSettingsDetailRoute = SettingsRoutes.About
|
||||
navController.navigate("chat") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
} else {
|
||||
settingsDetailNavController.navigateReplacingMainDetail(
|
||||
route = SettingsRoutes.About,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
navController.navigateReplacingMainDetail(
|
||||
route = SettingsRoutes.About,
|
||||
@@ -516,6 +527,14 @@ fun App(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentRoute, pendingPreAuthSettingsDetailRoute) {
|
||||
val route = pendingPreAuthSettingsDetailRoute ?: return@LaunchedEffect
|
||||
if (!isDesktopListDetail) return@LaunchedEffect
|
||||
if (currentRoute != "chat") return@LaunchedEffect
|
||||
settingsDetailNavController.navigateReplacingMainDetail(route = route)
|
||||
pendingPreAuthSettingsDetailRoute = null
|
||||
}
|
||||
|
||||
ScreenSurface {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@Composable
|
||||
|
||||
@@ -2,7 +2,9 @@ package ru.fromchat.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -11,6 +13,8 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
@@ -48,6 +52,9 @@ import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
|
||||
private val AUTH_CONTENT_MAX_WIDTH = 600.dp
|
||||
private val AUTH_CONTENT_MAX_HEIGHT = 800.dp
|
||||
|
||||
@Composable
|
||||
fun WelcomeScreen(
|
||||
onGetStarted: () -> Unit,
|
||||
@@ -60,7 +67,7 @@ fun WelcomeScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
contentWindowInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars),
|
||||
) { innerPadding ->
|
||||
val navController = LocalNavController.current
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
@@ -104,45 +111,60 @@ fun WelcomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = Res.getUri("drawable/logo_square.svg"),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.clip(MaterialTheme.shapes.extraLarge),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val constrainWide = maxWidth > AUTH_CONTENT_MAX_WIDTH
|
||||
Box(
|
||||
modifier =
|
||||
if (constrainWide) {
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.widthIn(max = AUTH_CONTENT_MAX_WIDTH)
|
||||
.heightIn(max = AUTH_CONTENT_MAX_HEIGHT)
|
||||
} else {
|
||||
Modifier.fillMaxSize()
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = SettingsStepHorizontalPadding)
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = Res.getUri("drawable/logo_square.png"),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.clip(MaterialTheme.shapes.extraLarge),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.auth_welcome_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.auth_welcome_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.auth_welcome_tagline),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.auth_welcome_tagline),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
ActionButton(onClick = onGetStarted) {
|
||||
Text(stringResource(Res.string.auth_get_started))
|
||||
ActionButton(onClick = onGetStarted) {
|
||||
Text(stringResource(Res.string.auth_get_started))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package ru.fromchat.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
@@ -13,7 +18,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
@@ -78,6 +85,9 @@ internal sealed interface RegisterResult {
|
||||
|
||||
private const val AUTH_SERVER_PROBE_TIMEOUT_MS = 5_000L
|
||||
|
||||
private val AUTH_CONTENT_MAX_WIDTH = 600.dp
|
||||
private val AUTH_CONTENT_MAX_HEIGHT = 800.dp
|
||||
|
||||
internal suspend fun probeCurrentServer() = runCatching {
|
||||
withTimeout(AUTH_SERVER_PROBE_TIMEOUT_MS.milliseconds) {
|
||||
probeServer(Settings.readServerConfig()) is ServerProbeResult.Supported
|
||||
@@ -433,108 +443,130 @@ fun AuthScreen(
|
||||
}
|
||||
|
||||
val yandexStep = yandexParams
|
||||
ExpressiveStepFlowScaffold(
|
||||
flowState = flowState,
|
||||
pages = listOf(
|
||||
usernameStepPage(
|
||||
username = username,
|
||||
onUsernameChange = { username = it },
|
||||
onContinue = {
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Password.ordinal)
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val constrainWide = maxWidth > AUTH_CONTENT_MAX_WIDTH
|
||||
Box(
|
||||
modifier =
|
||||
if (constrainWide) {
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.widthIn(max = AUTH_CONTENT_MAX_WIDTH)
|
||||
.heightIn(max = AUTH_CONTENT_MAX_HEIGHT)
|
||||
} else {
|
||||
Modifier.fillMaxSize()
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
passwordStepPage(
|
||||
username = username,
|
||||
password = password,
|
||||
onPasswordChange = { password = it },
|
||||
onLoginSuccess = wrappedAuthSuccess,
|
||||
onNeedsRegister = { required, params, captchaReq, captcha ->
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " +
|
||||
"clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}",
|
||||
)
|
||||
yandexRequired = required
|
||||
yandexParams = params
|
||||
captchaRequired = captchaReq
|
||||
captchaParams = captcha
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
confirmPasswordStepPage(
|
||||
confirmPassword = confirmPassword,
|
||||
onConfirmPasswordChange = { confirmPassword = it },
|
||||
password = password,
|
||||
onContinue = {
|
||||
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||
when {
|
||||
yandexRequired && yandexParams != null -> {
|
||||
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
||||
}
|
||||
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
|
||||
openCaptchaRoute(captchaKey)
|
||||
}
|
||||
else -> {
|
||||
) {
|
||||
ExpressiveStepFlowScaffold(
|
||||
flowState = flowState,
|
||||
pages = listOf(
|
||||
usernameStepPage(
|
||||
username = username,
|
||||
onUsernameChange = { username = it },
|
||||
onContinue = {
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Password.ordinal)
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
passwordStepPage(
|
||||
username = username,
|
||||
password = password,
|
||||
onPasswordChange = { password = it },
|
||||
onLoginSuccess = wrappedAuthSuccess,
|
||||
onNeedsRegister = { required, params, captchaReq, captcha ->
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"confirm → Profile captchaRequired=$captchaRequired " +
|
||||
"hasToken=${!captchaToken.isNullOrBlank()}",
|
||||
"onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " +
|
||||
"clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}",
|
||||
)
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
if (yandexStep != null) {
|
||||
yandexIdStepPage(
|
||||
yandex = yandexStep,
|
||||
onProof = { proof ->
|
||||
registrationProof = proof
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
)
|
||||
} else {
|
||||
confirmPasswordStepPage(
|
||||
confirmPassword = confirmPassword,
|
||||
onConfirmPasswordChange = { confirmPassword = it },
|
||||
password = password,
|
||||
onContinue = {
|
||||
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||
when {
|
||||
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
|
||||
openCaptchaRoute(captchaKey)
|
||||
yandexRequired = required
|
||||
yandexParams = params
|
||||
captchaRequired = captchaReq
|
||||
captchaParams = captcha
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
confirmPasswordStepPage(
|
||||
confirmPassword = confirmPassword,
|
||||
onConfirmPasswordChange = { confirmPassword = it },
|
||||
password = password,
|
||||
onContinue = {
|
||||
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||
when {
|
||||
yandexRequired && yandexParams != null -> {
|
||||
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
||||
}
|
||||
captchaRequired &&
|
||||
captchaToken.isNullOrBlank() &&
|
||||
captchaKey.isNotEmpty() -> {
|
||||
openCaptchaRoute(captchaKey)
|
||||
}
|
||||
else -> {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"confirm → Profile captchaRequired=$captchaRequired " +
|
||||
"hasToken=${!captchaToken.isNullOrBlank()}",
|
||||
)
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Logger.i(SmartCaptchaLog.TAG, "yandex-placeholder confirm → Profile")
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
if (yandexStep != null) {
|
||||
yandexIdStepPage(
|
||||
yandex = yandexStep,
|
||||
onProof = { proof ->
|
||||
registrationProof = proof
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
)
|
||||
} else {
|
||||
confirmPasswordStepPage(
|
||||
confirmPassword = confirmPassword,
|
||||
onConfirmPasswordChange = { confirmPassword = it },
|
||||
password = password,
|
||||
onContinue = {
|
||||
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||
when {
|
||||
captchaRequired &&
|
||||
captchaToken.isNullOrBlank() &&
|
||||
captchaKey.isNotEmpty() -> {
|
||||
openCaptchaRoute(captchaKey)
|
||||
}
|
||||
else -> {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"yandex-placeholder confirm → Profile",
|
||||
)
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
)
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
)
|
||||
},
|
||||
profileStepPage(
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
onDisplayNameChange = { displayName = it },
|
||||
bio = bio,
|
||||
onBioChange = { bio = it },
|
||||
password = password,
|
||||
registrationProof = registrationProof,
|
||||
captchaToken = captchaToken,
|
||||
onRegisterSuccess = wrappedAuthSuccess,
|
||||
onUsernameTaken = resetToUsername,
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
),
|
||||
snackbarHostState = snackbarHostState,
|
||||
onBackAtFirstPage = wrappedBackToWelcome,
|
||||
)
|
||||
profileStepPage(
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
onDisplayNameChange = { displayName = it },
|
||||
bio = bio,
|
||||
onBioChange = { bio = it },
|
||||
password = password,
|
||||
registrationProof = registrationProof,
|
||||
captchaToken = captchaToken,
|
||||
onRegisterSuccess = wrappedAuthSuccess,
|
||||
onUsernameTaken = resetToUsername,
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
),
|
||||
snackbarHostState = snackbarHostState,
|
||||
onBackAtFirstPage = wrappedBackToWelcome,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
@@ -96,6 +96,7 @@ import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.ui.extraStatusBars
|
||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -550,6 +551,7 @@ fun ExpressiveStepFlowScaffold(
|
||||
fun HazeTopBar(hazeState: HazeState) {
|
||||
val topBarHazeStyle = HazeMaterials.thin()
|
||||
TopAppBar(
|
||||
windowInsets = WindowInsets.extraStatusBars,
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = navigateBack) {
|
||||
@@ -583,7 +585,7 @@ fun ExpressiveStepFlowScaffold(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.windowInsetsPadding(WindowInsets.extraStatusBars)
|
||||
.padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -100,8 +101,11 @@ fun DesktopSettingsDetailNavHost(
|
||||
|
||||
Box(modifier.fillMaxSize()) {
|
||||
if (showPanel) {
|
||||
// Match Profile / ConversationListDetailShell (`background`), not the
|
||||
// default AppPanel `surfaceContainerLowest` (visible mismatch in two-pane).
|
||||
AppPanel(
|
||||
Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ fun AboutScreen() {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
SettingsDetailTopBar(
|
||||
title = {
|
||||
|
||||
@@ -67,7 +67,7 @@ fun AppearanceScreen(onBack: () -> Unit) {
|
||||
var themeChipIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
SettingsDetailTopBar(
|
||||
title = { Text(stringResource(Res.string.settings_category_appearance)) },
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ fun NotificationsScreen(onBack: () -> Unit) {
|
||||
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
|
||||
topBar = {
|
||||
SettingsDetailTopBar(
|
||||
|
||||
+4
-4
@@ -81,10 +81,10 @@ fun SettingsDetailTopBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Must match settings-detail Scaffold.containerColor and AppPanel
|
||||
// (`surfaceContainerLowest`). Scaffold defaults to `background`, which is a
|
||||
// different token (e.g. light Neutral98 vs Neutral100) — that was the stripe.
|
||||
val paneColor = MaterialTheme.colorScheme.surfaceContainerLowest
|
||||
// Match Profile / shell (`background`), not AppPanel's default
|
||||
// `surfaceContainerLowest` — that token reads as a separate pane fill in
|
||||
// two-pane against the list–detail shell.
|
||||
val paneColor = MaterialTheme.colorScheme.background
|
||||
if (settingsDetailUseCollapsingTopBar()) {
|
||||
MediumTopAppBar(
|
||||
title = title,
|
||||
|
||||
+2
-2
@@ -75,8 +75,8 @@ fun AccountScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
// Match AppPanel / SettingsDetailTopBar — default `background` is a darker stripe under the bar.
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
// Match Profile / shell — `surfaceContainerLowest` mismatches the pane behind in two-pane.
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
SettingsDetailTopBar(
|
||||
title = { Text(stringResource(Res.string.settings_account_title)) },
|
||||
|
||||
+9
-25
@@ -16,9 +16,9 @@ import javafx.scene.web.WebEngine
|
||||
import javafx.scene.web.WebView
|
||||
import netscape.javascript.JSObject
|
||||
import ru.fromchat.Logger
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import ru.fromchat.ui.jvm.ensureJavaFxStarted
|
||||
import ru.fromchat.ui.jvm.releaseJavaFxWebView
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview"
|
||||
@@ -58,6 +58,8 @@ actual fun SmartCaptchaWebView(
|
||||
deliverError = { message -> onErrorState.value(message) },
|
||||
)
|
||||
}
|
||||
val panelRef = remember { AtomicReference<JFXPanel?>(null) }
|
||||
val engineRef = remember { AtomicReference<WebEngine?>(null) }
|
||||
|
||||
DisposableEffect(instanceId) {
|
||||
Logger.i(
|
||||
@@ -66,15 +68,17 @@ actual fun SmartCaptchaWebView(
|
||||
"sitekey=${SmartCaptchaLog.redactKey(sitekey)} lang=$lang " +
|
||||
"url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
|
||||
)
|
||||
ensureJavaFxStarted()
|
||||
ensureJavaFxStarted(SmartCaptchaLog.TAG)
|
||||
onDispose {
|
||||
Logger.i(SmartCaptchaLog.TAG, "JavaFX WebView compose dispose id=$instanceId")
|
||||
releaseJavaFxWebView(panelRef, engineRef)
|
||||
}
|
||||
}
|
||||
|
||||
SwingPanel(
|
||||
factory = {
|
||||
JFXPanel().also { panel ->
|
||||
panelRef.set(panel)
|
||||
panel.background = java.awt.Color(
|
||||
surfaceContainer.red,
|
||||
surfaceContainer.green,
|
||||
@@ -86,6 +90,7 @@ actual fun SmartCaptchaWebView(
|
||||
val webView = WebView()
|
||||
val engine = webView.engine
|
||||
engine.isJavaScriptEnabled = true
|
||||
engineRef.set(engine)
|
||||
engine.loadWorker.stateProperty().addListener { _, _, newState ->
|
||||
when (newState) {
|
||||
Worker.State.SUCCEEDED -> {
|
||||
@@ -169,24 +174,3 @@ private fun injectNativeClient(engine: WebEngine, bridge: SmartCaptchaJsBridge)
|
||||
Logger.w(SmartCaptchaLog.TAG, "inject NativeClient failed: ${it.message}", it)
|
||||
}
|
||||
}
|
||||
|
||||
private val javaFxStarted = AtomicBoolean(false)
|
||||
|
||||
private fun ensureJavaFxStarted() {
|
||||
if (javaFxStarted.get()) return
|
||||
synchronized(javaFxStarted) {
|
||||
if (javaFxStarted.get()) return
|
||||
runCatching {
|
||||
Platform.setImplicitExit(false)
|
||||
val latch = CountDownLatch(1)
|
||||
Platform.startup { latch.countDown() }
|
||||
if (!latch.await(5, TimeUnit.SECONDS)) {
|
||||
Logger.w(SmartCaptchaLog.TAG, "JavaFX Platform.startup timed out")
|
||||
}
|
||||
}.onFailure { error ->
|
||||
// Toolkit may already be running via JFXPanel.
|
||||
Logger.d(SmartCaptchaLog.TAG, "JavaFX startup: ${error.message}")
|
||||
}
|
||||
javaFxStarted.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,8 @@ import netscape.javascript.JSObject
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.api.calls.CallStore
|
||||
import ru.fromchat.api.calls.LiveKitConnectSession
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import ru.fromchat.ui.jvm.ensureJavaFxStarted
|
||||
import ru.fromchat.ui.jvm.releaseJavaFxWebView
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
@@ -73,6 +72,7 @@ private fun LiveKitJavaFxCall(
|
||||
) {
|
||||
var micOn by remember(session.roomName) { mutableStateOf(true) }
|
||||
var camOn by remember(session.roomName) { mutableStateOf(true) }
|
||||
val panelRef = remember { AtomicReference<JFXPanel?>(null) }
|
||||
val engineRef = remember { AtomicReference<WebEngine?>(null) }
|
||||
val sessionState = rememberUpdatedState(session)
|
||||
val surface = MaterialTheme.colorScheme.surfaceContainerLowest
|
||||
@@ -89,7 +89,7 @@ private fun LiveKitJavaFxCall(
|
||||
}
|
||||
|
||||
DisposableEffect(session.roomName) {
|
||||
ensureJavaFxStarted()
|
||||
ensureJavaFxStarted(TAG)
|
||||
onDispose {
|
||||
val engine = engineRef.get()
|
||||
if (engine != null) {
|
||||
@@ -97,7 +97,7 @@ private fun LiveKitJavaFxCall(
|
||||
runCatching { engine.executeScript(LiveKitCallWebPage.disconnectScript()) }
|
||||
}
|
||||
}
|
||||
engineRef.set(null)
|
||||
releaseJavaFxWebView(panelRef, engineRef)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ private fun LiveKitJavaFxCall(
|
||||
SwingPanel(
|
||||
factory = {
|
||||
JFXPanel().also { panel ->
|
||||
panelRef.set(panel)
|
||||
panel.background = java.awt.Color(
|
||||
surface.red,
|
||||
surface.green,
|
||||
@@ -224,23 +225,3 @@ private fun injectNativeBridge(engine: WebEngine, bridge: LiveKitCallJsBridge) {
|
||||
Logger.w(TAG, "inject FromChatNative failed: ${it.message}", it)
|
||||
}
|
||||
}
|
||||
|
||||
private val javaFxStarted = AtomicBoolean(false)
|
||||
|
||||
private fun ensureJavaFxStarted() {
|
||||
if (javaFxStarted.get()) return
|
||||
synchronized(javaFxStarted) {
|
||||
if (javaFxStarted.get()) return
|
||||
runCatching {
|
||||
Platform.setImplicitExit(false)
|
||||
val latch = CountDownLatch(1)
|
||||
Platform.startup { latch.countDown() }
|
||||
if (!latch.await(5, TimeUnit.SECONDS)) {
|
||||
Logger.w(TAG, "JavaFX Platform.startup timed out")
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Logger.d(TAG, "JavaFX startup: ${error.message}")
|
||||
}
|
||||
javaFxStarted.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package ru.fromchat.ui.jvm
|
||||
|
||||
import javafx.application.Platform
|
||||
import javafx.embed.swing.JFXPanel
|
||||
import javafx.scene.web.WebEngine
|
||||
import ru.fromchat.Logger
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private val javaFxStarted = AtomicBoolean(false)
|
||||
|
||||
/** Starts the JavaFX toolkit once (safe if already running via [JFXPanel]). */
|
||||
fun ensureJavaFxStarted(logTag: String) {
|
||||
if (javaFxStarted.get()) return
|
||||
synchronized(javaFxStarted) {
|
||||
if (javaFxStarted.get()) return
|
||||
runCatching {
|
||||
Platform.setImplicitExit(false)
|
||||
val latch = CountDownLatch(1)
|
||||
Platform.startup { latch.countDown() }
|
||||
if (!latch.await(5, TimeUnit.SECONDS)) {
|
||||
Logger.w(logTag, "JavaFX Platform.startup timed out")
|
||||
}
|
||||
}.onFailure { error ->
|
||||
// Toolkit may already be running via JFXPanel.
|
||||
Logger.d(logTag, "JavaFX startup: ${error.message}")
|
||||
}
|
||||
javaFxStarted.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down an OpenJFX [WebView] hosted in a Compose [JFXPanel].
|
||||
*
|
||||
* Without this, [WebEngine]'s pulse timer and WebKit textures stay alive after the Swing panel
|
||||
* leaves composition — observed as multi-GB macOS IOAccelerator (graphics) growth while the
|
||||
* Java heap stays small.
|
||||
*/
|
||||
fun releaseJavaFxWebView(
|
||||
panelRef: AtomicReference<JFXPanel?>,
|
||||
engineRef: AtomicReference<WebEngine?>,
|
||||
) {
|
||||
val panel = panelRef.getAndSet(null)
|
||||
val engine = engineRef.getAndSet(null)
|
||||
if (panel == null && engine == null) return
|
||||
Platform.runLater {
|
||||
runCatching {
|
||||
engine?.loadWorker?.cancel()
|
||||
engine?.load(null)
|
||||
panel?.scene = null
|
||||
}.onFailure { error ->
|
||||
Logger.w("JavaFxWebView", "release failed: ${error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user