Implement deep links

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-04-14 23:41:33 +03:00
Unverified
parent bb391858a9
commit ecfb97907a
19 changed files with 730 additions and 284 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="shared">
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
<root id="archive" name="shared.jar" />
<root id="archive" name="shared.jar">
<element id="module-output" name="FromChat.utils.shared.androidMain" />
</root>
</artifact>
</component>
Generated
+1
View File
@@ -8,5 +8,6 @@
</component>
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/.cursor/skills/material-3-skill" vcs="Git" />
</component>
</project>
+9
View File
@@ -26,6 +26,15 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="fromchat"
android:host="u"
android:pathPrefix="/" />
</intent-filter>
</activity>
<service
android:name=".fcm.FromChatFirebaseMessagingService"
@@ -1,6 +1,7 @@
package ru.fromchat
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
@@ -12,6 +13,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import io.ktor.client.call.body
@@ -20,12 +22,15 @@ import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
import ru.fromchat.api.ApiClient
import ru.fromchat.api.MessagesResponse
import ru.fromchat.core.config.Config
import ru.fromchat.core.Logger
import ru.fromchat.ui.App
import ru.fromchat.ui.isPublicChatVisible
@@ -35,30 +40,150 @@ private const val EXTRA_MARK_MESSAGE_READ = "mark_message_read"
private const val EXTRA_MESSAGE_ID = "scroll_to_message_id"
private const val CHAT_TYPE_PUBLIC = "public"
private const val CHAT_TYPE_DM = "dm"
private const val INVALID_PROFILE_DEEP_LINK_MESSAGE = "Could not open this profile link. Use fromchat://u/<idOrUsername>."
private const val PROFILE_NOT_FOUND_MESSAGE = "This profile could not be found"
private const val PROFILE_OPEN_FAILED_MESSAGE = "Could not open this profile. Please try again."
private data class ProfileDeepLinkResolution(
val scrollToMessageId: Int? = null,
val startAtPublicChat: Boolean = false,
val startAtDmConversationUserId: Int? = null,
val startAtProfileUserId: Int? = null,
val startAtProfileUsername: String? = null,
val profileLookupErrorMessage: String? = null
)
private fun getProfileLookupFailureMessage(error: Throwable): String =
if (error is ClientRequestException && error.response.status.value == 404) {
PROFILE_NOT_FOUND_MESSAGE
} else {
PROFILE_OPEN_FAILED_MESSAGE
}
private data class ProfileDeepLinkTarget(
val userId: Int? = null,
val username: String? = null,
val parseError: String? = null
)
class MainActivity : ComponentActivity() {
private var scrollToMessageId by mutableStateOf<Int?>(null)
private var startAtPublicChat by mutableStateOf(false)
private var startAtDmConversationUserId by mutableStateOf<Int?>(null)
private var startAtProfileUserId by mutableStateOf<Int?>(null)
private var startAtProfileUsername by mutableStateOf<String?>(null)
private var profileLookupErrorMessage by mutableStateOf<String?>(null)
private var prevIsPublicChatVisible: Boolean? = null
private val requestNotificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) {}
private fun handleIntent(intent: Intent?) {
private suspend fun buildLaunchState(intent: Intent?): ProfileDeepLinkResolution {
Logger.d(
"ProfileDeepLink",
"handleIntent: action=${intent?.action}, data=${intent?.dataString}, messageId=${intent?.getIntExtra(EXTRA_MESSAGE_ID, -1)}, " +
"chatType=${intent?.getStringExtra(EXTRA_NOTIFICATION_CHAT_TYPE)}, openDmUserId=${intent?.getIntExtra(EXTRA_OPEN_DM_USER_ID, -1)}"
)
val messageId = intent?.getIntExtra(EXTRA_MESSAGE_ID, -1) ?: -1
val chatType = intent?.getStringExtra(EXTRA_NOTIFICATION_CHAT_TYPE) ?: CHAT_TYPE_PUBLIC
val dmConversationUserId = intent?.getIntExtra(EXTRA_OPEN_DM_USER_ID, -1) ?: -1
val profileTarget = parseProfileDeepLink(intent)
Logger.d(
"ProfileDeepLink",
"handleIntent parsedProfileTarget: userId=${profileTarget?.userId}, username=${profileTarget?.username}, parseError=${profileTarget?.parseError}"
)
scrollToMessageId = if (messageId != -1) messageId else null
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM
startAtDmConversationUserId = if (chatType == CHAT_TYPE_DM && dmConversationUserId > 0) {
dmConversationUserId
val baseState = ProfileDeepLinkResolution(
scrollToMessageId = if (messageId != -1) messageId else null,
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM,
startAtDmConversationUserId = if (chatType == CHAT_TYPE_DM && dmConversationUserId > 0) {
dmConversationUserId
} else {
null
}
)
if (profileTarget?.parseError != null) {
return baseState.copy(profileLookupErrorMessage = profileTarget.parseError)
}
if (profileTarget == null) {
return baseState
}
var lookupFailureMessage: String? = null
val resolvedProfileId = if (profileTarget.userId != null) {
runCatching {
ApiClient.getProfileById(profileTarget.userId).id
}.onFailure { err ->
Logger.d("ProfileDeepLink", "profile deep link lookup failed by id: ${err.message}")
lookupFailureMessage = getProfileLookupFailureMessage(err)
}.getOrNull()
} else if (!profileTarget.username.isNullOrBlank()) {
runCatching {
ApiClient.getProfileByUsername(profileTarget.username).id
}.onFailure { err ->
Logger.d("ProfileDeepLink", "profile deep link lookup failed by username: ${err.message}")
lookupFailureMessage = getProfileLookupFailureMessage(err)
}.getOrNull()
} else {
null
}
// Mark messages as read if clicked from notification
if (resolvedProfileId == null) {
return baseState.copy(profileLookupErrorMessage = lookupFailureMessage ?: PROFILE_OPEN_FAILED_MESSAGE)
}
if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) {
markMessagesAsRead()
}
return baseState.copy(
startAtProfileUserId = resolvedProfileId,
startAtProfileUsername = null,
startAtDmConversationUserId = null,
startAtPublicChat = false
)
}
private fun applyLaunchState(launchState: ProfileDeepLinkResolution) {
scrollToMessageId = launchState.scrollToMessageId
startAtPublicChat = launchState.startAtPublicChat
startAtDmConversationUserId = launchState.startAtDmConversationUserId
startAtProfileUserId = launchState.startAtProfileUserId
startAtProfileUsername = launchState.startAtProfileUsername
profileLookupErrorMessage = launchState.profileLookupErrorMessage
}
private fun parseProfileDeepLink(intent: Intent?): ProfileDeepLinkTarget? {
val data: Uri = intent?.data ?: return null
Logger.d("ProfileDeepLink", "parseProfileDeepLink intentData=${data.toString()}")
if (!data.scheme.equals("fromchat", ignoreCase = true)) return null
if (data.host != "u") return null
val segments = data.pathSegments.filter { it.isNotBlank() }
Logger.d("ProfileDeepLink", "parseProfileDeepLink segments=${segments.joinToString(",")}")
if (segments.size != 1) {
return ProfileDeepLinkTarget(parseError = INVALID_PROFILE_DEEP_LINK_MESSAGE)
}
val segment = segments[0].let { Uri.decode(it) }
val trimmed = segment.trim()
if (trimmed.isBlank()) {
return ProfileDeepLinkTarget(parseError = INVALID_PROFILE_DEEP_LINK_MESSAGE)
}
return trimmed.toLongOrNull()?.let { idLong ->
if (idLong in 1L..Int.MAX_VALUE.toLong()) {
Logger.d("ProfileDeepLink", "parseProfileDeepLink resolved as userId=$idLong")
ProfileDeepLinkTarget(userId = idLong.toInt())
} else {
Logger.d("ProfileDeepLink", "parseProfileDeepLink large numeric treated as username=$trimmed")
ProfileDeepLinkTarget(username = trimmed)
}
} ?: run {
Logger.d("ProfileDeepLink", "parseProfileDeepLink resolved as username=$trimmed")
ProfileDeepLinkTarget(username = trimmed)
}
}
@OptIn(DelicateCoroutinesApi::class)
@@ -110,30 +235,38 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
enableEdgeToEdge()
// Handle initial intent
handleIntent(intent)
lifecycleScope.launch {
val launchState = buildLaunchState(intent)
applyLaunchState(launchState)
setContent {
App(
scrollToMessageId = scrollToMessageId,
startAtPublicChat = startAtPublicChat,
startAtDmConversationUserId = startAtDmConversationUserId,
startAtProfileUserId = startAtProfileUserId,
startAtProfileUsername = startAtProfileUsername,
profileLookupErrorMessage = profileLookupErrorMessage,
onProfileLookupErrorMessageConsumed = {
profileLookupErrorMessage = null
}
)
}
setContent {
App(
scrollToMessageId = scrollToMessageId,
startAtPublicChat = startAtPublicChat,
startAtDmConversationUserId = startAtDmConversationUserId
)
checkGooglePlayServices()
}
// Request POST_NOTIFICATIONS permission on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) {}.launch(Manifest.permission.POST_NOTIFICATIONS)
requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
checkGooglePlayServices()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
lifecycleScope.launch {
val launchState = buildLaunchState(intent)
applyLaunchState(launchState)
}
}
override fun onPause() {
+84 -3
View File
@@ -1,18 +1,99 @@
import UIKit
import Foundation
import SwiftUI
import ComposeApp
struct ComposeView: UIViewControllerRepresentable {
let startAtProfileUserId: Int?
let startAtProfileUsername: String?
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
MainViewControllerKt.MainViewController(
startAtProfileUserId = startAtProfileUserId,
startAtProfileUsername = startAtProfileUsername
)
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
struct ContentView: View {
@State private var startAtProfileUserId: Int?
@State private var startAtProfileUsername: String?
@State private var profileNonce = UUID()
@State private var showProfileLinkError = false
@State private var profileLinkErrorMessage: String? = nil
var body: some View {
ComposeView()
.ignoresSafeArea(.all) // Compose has own keyboard handler
ComposeView(
startAtProfileUserId: startAtProfileUserId,
startAtProfileUsername: startAtProfileUsername
)
.id(profileNonce)
.ignoresSafeArea(.all)
.onOpenURL { url in
print("[ProfileLink:iOS] onOpenURL url=\(url.absoluteString)")
let target = Self.parseProfileDeepLink(url: url)
print("[ProfileLink:iOS] parse result userId=\(String(describing: target?.userId)) username=\(String(describing: target?.username)) error=\(String(describing: target?.error))")
guard let parsed = target else { return }
if let error = parsed.error {
profileLinkErrorMessage = error
showProfileLinkError = true
return
}
startAtProfileUserId = parsed.userId
startAtProfileUsername = parsed.username
profileNonce = UUID()
}
.alert("Profile link", isPresented: $showProfileLinkError) {
Button("OK", role: .cancel) {
showProfileLinkError = false
}
} message: {
Text(profileLinkErrorMessage ?? "Invalid profile link.")
}
}
struct ParsedProfileDeepLink {
let userId: Int?
let username: String?
let error: String?
}
static func parseProfileDeepLink(url: URL) -> ParsedProfileDeepLink? {
print("[ProfileLink:iOS] parseProfileDeepLink start url=\(url)")
guard url.scheme?.lowercased() == "fromchat",
url.host == "u" else {
return nil
}
let parts = url.path.split(separator: "/").filter { !$0.isEmpty }
print("[ProfileLink:iOS] scheme=\(url.scheme ?? "nil") host=\(url.host ?? "nil") path=\(url.path) parts=\(parts.map(String.init))")
guard parts.count == 1, let rawSegment = parts.first else {
return ParsedProfileDeepLink(
userId: nil,
username: nil,
error: "Invalid profile link. Use fromchat://u/<id-or-username>."
)
}
let segment = String(rawSegment).removingPercentEncoding?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !segment.isEmpty else {
return ParsedProfileDeepLink(
userId: nil,
username: nil,
error: "Invalid profile link. Use fromchat://u/<id-or-username>."
)
}
print("[ProfileLink:iOS] parsed segment=\(segment)")
if let id = Int64(segment), id >= 1 && id <= Int64(Int.max) {
print("[ProfileLink:iOS] resolved as userId=\(id)")
return ParsedProfileDeepLink(userId: Int(id), username: nil, error: nil)
}
print("[ProfileLink:iOS] resolved as username=\(segment)")
return ParsedProfileDeepLink(userId: nil, username: segment, error: nil)
}
}
+13
View File
@@ -32,6 +32,19 @@
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>fromchat</string>
<key>CFBundleURLSchemes</key>
<array>
<string>fromchat</string>
</array>
</dict>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
+1
View File
@@ -50,6 +50,7 @@ kotlin {
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
// Serialization
implementation(libs.kotlinx.serialization.json)
@@ -0,0 +1,9 @@
package ru.fromchat
import android.widget.Toast
import com.pr0gramm3r101.utils.UtilsLibrary
actual fun showProfileLoadErrorMessage(message: String) {
Toast.makeText(UtilsLibrary.context, message, Toast.LENGTH_SHORT).show()
}
@@ -62,6 +62,8 @@
<string name="cd_send">Отправить</string>
<string name="profile_title">Профиль</string>
<string name="profile_load_failed">Не получилось загрузить профиль</string>
<string name="profile_not_found">Профиль не найден</string>
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
<string name="action_open_settings">Настройки</string>
<string name="action_chat">Написать</string>
<string name="action_copy_link">Скопировать ссылку</string>
@@ -81,6 +81,8 @@
<!-- Profile -->
<string name="profile_title">Profile</string>
<string name="profile_load_failed">Couldnt load this profile</string>
<string name="profile_not_found">This profile could not be found</string>
<string name="profile_open_failed">Could not open this profile. Please try again.</string>
<string name="action_open_settings">Settings</string>
<string name="action_chat">Chat</string>
<string name="action_copy_link">Copy link</string>
@@ -0,0 +1,4 @@
package ru.fromchat
expect fun showProfileLoadErrorMessage(message: String)
@@ -307,6 +307,13 @@ object ApiClient {
}
.body()
suspend fun getProfileByUsername(username: String): UserProfile =
http
.get("${Config.apiBaseUrl}/user/$username") {
contentType(ContentType.Application.Json)
}
.body()
suspend fun getRegisteredUserCount(): Int =
http
.get("${Config.apiBaseUrl}/user/stats/registered-count") {
@@ -1,14 +1,17 @@
package ru.fromchat.ui
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
import coil3.svg.SvgDecoder
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -18,27 +21,42 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import ru.fromchat.AppForeground
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
import coil3.svg.SvgDecoder
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.AppForeground
import ru.fromchat.api.ApiClient
import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.core.Logger
import ru.fromchat.core.config.Config
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen
import ru.fromchat.ui.chat.PublicChatScreen
@@ -47,20 +65,8 @@ import ru.fromchat.ui.debug.DebugSearchBarDocsScreen
import ru.fromchat.ui.dm.DmChatRoute
import ru.fromchat.ui.dm.DmNav
import ru.fromchat.ui.dm.DmProfileRoute
import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.main.ChatsSearchScreen
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.setup.ServerConfigScreen
import ru.fromchat.ui.LocalSystemBarsVisibility
import ru.fromchat.ui.rememberSystemBarsController
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraphBuilder
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.main.settings.SettingsAccountScreen
import ru.fromchat.ui.main.settings.SettingsAppearanceScreen
import ru.fromchat.ui.main.settings.SettingsDevicesScreen
@@ -69,7 +75,8 @@ import ru.fromchat.ui.main.settings.SettingsRoutes
import ru.fromchat.ui.main.settings.SettingsSecurityHubScreen
import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen
import ru.fromchat.ui.main.settings.SettingsServerToolsScreen
import kotlinx.serialization.json.*
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.setup.ServerConfigScreen
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
@@ -156,7 +163,11 @@ private fun NavGraphBuilder.settingsSlideComposable(
fun App(
scrollToMessageId: Int? = null,
startAtPublicChat: Boolean = false,
startAtDmConversationUserId: Int? = null
startAtDmConversationUserId: Int? = null,
startAtProfileUserId: Int? = null,
startAtProfileUsername: String? = null,
profileLookupErrorMessage: String? = null,
onProfileLookupErrorMessageConsumed: () -> Unit = {}
) {
setSingletonImageLoaderFactory { context ->
ImageLoader.Builder(context)
@@ -204,7 +215,7 @@ fun App(
}
// Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]).
val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
fun syncForeground() {
AppForeground.setForeground(
@@ -242,25 +253,70 @@ fun App(
FromChatTheme {
SharedTransitionLayout {
val navController = rememberNavController()
val profileLookupSnackbarHostState = remember { SnackbarHostState() }
// Handle navigation to the target chat when launched from notification
LaunchedEffect(startAtDmConversationUserId, startAtPublicChat, startDestination) {
LaunchedEffect(profileLookupErrorMessage) {
profileLookupErrorMessage?.let { message ->
Logger.d("ProfileDeepLink", "showing snackbar for deep-link lookup failure: $message")
profileLookupSnackbarHostState.showSnackbar(
message = message,
withDismissAction = true,
duration = SnackbarDuration.Short
)
onProfileLookupErrorMessageConsumed()
}
}
// Handle startup/deep-link navigation targets (notification chat/profile)
LaunchedEffect(
startAtDmConversationUserId,
startAtPublicChat,
startAtProfileUserId,
startAtProfileUsername,
startDestination
) {
Logger.d(
"ProfileDeepLink",
"startup nav check: startDestination=$startDestination, startAtProfileUserId=$startAtProfileUserId, " +
"startAtProfileUsername=$startAtProfileUsername, startAtDmConversationUserId=$startAtDmConversationUserId, " +
"startAtPublicChat=$startAtPublicChat, scrollToMessageId=$scrollToMessageId"
)
if (startDestination == null || startDestination == "login") {
return@LaunchedEffect
}
if (startAtDmConversationUserId != null && startAtDmConversationUserId > 0) {
navController.navigate(
DmNav.chatRoute(
otherUserId = startAtDmConversationUserId,
sourceMessageId = scrollToMessageId
if (startAtProfileUserId != null && startAtProfileUserId > 0) {
Logger.d(
"ProfileDeepLink",
"navigating by deep link userId=$startAtProfileUserId"
)
navController.navigate("profile/$startAtProfileUserId?fromDeepLink=true")
} else {
val trimmedUsername = startAtProfileUsername?.trim()
if (!trimmedUsername.isNullOrBlank()) {
Logger.d(
"ProfileDeepLink",
"navigating by deep link username=$trimmedUsername"
)
) {
launchSingleTop = true
}
} else if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") {
navController.navigate("chats/publicChat") {
launchSingleTop = true
navController.navigate("profile/$trimmedUsername?fromDeepLink=true")
} else if (startAtDmConversationUserId != null && startAtDmConversationUserId > 0) {
Logger.d(
"ProfileDeepLink",
"navigating by notification chat route user=$startAtDmConversationUserId messageId=$scrollToMessageId"
)
navController.navigate(
DmNav.chatRoute(
otherUserId = startAtDmConversationUserId,
sourceMessageId = scrollToMessageId
)
) {
launchSingleTop = true
}
} else if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") {
Logger.d("ProfileDeepLink", "navigating to public chat route")
navController.navigate("chats/publicChat") {
launchSingleTop = true
}
}
}
}
@@ -268,7 +324,7 @@ fun App(
// Set up global auth error handler
LaunchedEffect(navController) {
ApiClient.onAuthError = {
ru.fromchat.core.Logger.d("App", "Global auth error handler triggered, navigating to login")
Logger.d("App", "Global auth error handler triggered, navigating to login")
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
@@ -281,7 +337,6 @@ fun App(
) {
if (startDestination != null) {
val animationSpec = tween<IntOffset>(400)
NavHost(
navController = navController,
startDestination = startDestination!!,
@@ -310,217 +365,253 @@ fun App(
)
}
) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("login") {
LoginScreen(
onLoginSuccess = {
WebSocketManager.connect(forceRestart = true)
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
}
)
}
composable("chat") {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this
)
}
composable("chats/publicChat") {
PublicChatScreen(
scrollToMessageId = scrollToMessageId,
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable
)
}
val searchScreenFade = tween<Float>(260)
composable(
route = "search/conversations",
enterTransition = { fadeIn(animationSpec = searchScreenFade) },
exitTransition = { fadeOut(animationSpec = searchScreenFade) },
popEnterTransition = { fadeIn(animationSpec = searchScreenFade) },
popExitTransition = { fadeOut(animationSpec = searchScreenFade) }
) {
ChatsSearchScreen(
onBack = { navController.popBackStack() },
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
onOpenProfile = { userId: Int ->
if (userId != 0) {
navController.navigate("profile/$userId")
}
},
onOpenConversation = { userId: Int ->
if (userId != 0) {
navController.navigate(DmNav.chatRoute(userId))
}
}
)
}
composable("debug") {
DebugApiScreen()
}
composable("debug/searchbar-docs") {
DebugSearchBarDocsScreen()
}
composable(
route = "profile/{userId}?useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}",
arguments = listOf(
navArgument("userId") { type = NavType.StringType },
navArgument("useSharedElement") {
type = NavType.StringType
defaultValue = "false"
},
navArgument("sourceMessageId") {
type = NavType.StringType
defaultValue = "-1"
}
)
) { backStackEntry ->
val handle = backStackEntry.savedStateHandle
val userId = handle.get<String>("userId")?.toIntOrNull()
val useSharedElement = handle.get<String>("useSharedElement") == "true"
val sourceMessageId = handle.get<String>("sourceMessageId")?.toIntOrNull() ?: -1
ProfileScreen(
userId = userId,
onBack = { navController.navigateUp() },
onChat = { navController.navigate(DmNav.chatRoute(it)) },
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable,
useSharedElementFromNavigation = useSharedElement,
sharedSourceMessageId = sourceMessageId
)
}
val dmChatProfileFade = tween<Float>(durationMillis = 280)
composable(
route = DmNav.CHAT_ROUTE,
arguments = listOf(
navArgument("otherUserId") { type = NavType.StringType },
navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 },
),
enterTransition = {
when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
else -> slideIntoContainer(Start, animationSpec = animationSpec)
}
},
exitTransition = {
when (targetState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
else -> slideOutOfContainer(Start, animationSpec = animationSpec)
}
},
popEnterTransition = {
when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
else -> slideIntoContainer(End, animationSpec = animationSpec)
}
},
popExitTransition = {
when (targetState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
else -> slideOutOfContainer(End, animationSpec = animationSpec)
}
},
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
val sourceMessageId = entry.savedStateHandle.get<Int>("sourceMessageId") ?: -1
if (otherUserId <= 0) return@composable
DmChatRoute(
otherUserId = otherUserId,
scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
}
composable(
route = DmNav.PROFILE_ROUTE,
arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = dmChatProfileFade) },
exitTransition = { fadeOut(animationSpec = dmChatProfileFade) },
popEnterTransition = { fadeIn(animationSpec = dmChatProfileFade) },
popExitTransition = { fadeOut(animationSpec = dmChatProfileFade) },
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
if (otherUserId <= 0) return@composable
DmProfileRoute(
otherUserId = otherUserId,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
}
settingsSlideComposable("about", animationSpec) {
AboutScreen()
}
val navigateToLoginClearingChat = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
composable("serverConfig") {
ServerConfigScreen()
}
}
settingsSlideComposable(SettingsRoutes.Appearance, animationSpec) {
SettingsAppearanceScreen(onBack = { navController.navigateUp() })
}
settingsSlideComposable(SettingsRoutes.ServerTools, animationSpec) {
SettingsServerToolsScreen(
onBack = { navController.navigateUp() },
outerNav = navController
)
}
settingsSlideComposable(SettingsRoutes.Notifications, animationSpec) {
SettingsNotificationsScreen(onBack = { navController.navigateUp() })
}
settingsSlideComposable(SettingsRoutes.Devices, animationSpec) {
SettingsDevicesScreen(onBack = { navController.navigateUp() })
}
settingsSlideComposable(SettingsRoutes.Security, animationSpec) {
SettingsSecurityHubScreen(
onBack = { navController.navigateUp() },
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }
)
}
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, animationSpec) {
SettingsSecurityPasswordFlowScreen(
onBack = { navController.navigateUp() },
onDonePopToHub = {
navController.popBackStack()
composable("login") {
LoginScreen(
onLoginSuccess = {
WebSocketManager.connect(forceRestart = true)
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
}
)
}
composable("chat") {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
snackbarHostState = profileLookupSnackbarHostState
)
}
composable("chats/publicChat") {
PublicChatScreen(
scrollToMessageId = scrollToMessageId,
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable
)
}
val searchScreenFade = tween<Float>(260)
composable(
route = "search/conversations",
enterTransition = { fadeIn(animationSpec = searchScreenFade) },
exitTransition = { fadeOut(animationSpec = searchScreenFade) },
popEnterTransition = { fadeIn(animationSpec = searchScreenFade) },
popExitTransition = { fadeOut(animationSpec = searchScreenFade) }
) {
ChatsSearchScreen(
onBack = { navController.popBackStack() },
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
onOpenProfile = { userId: Int ->
if (userId != 0) {
navController.navigate("profile/$userId")
}
},
onOpenConversation = { userId: Int ->
if (userId != 0) {
navController.navigate(DmNav.chatRoute(userId))
}
}
)
}
composable("debug") {
DebugApiScreen()
}
composable("debug/searchbar-docs") {
DebugSearchBarDocsScreen()
}
composable(
route = "profile/{userId}?fromDeepLink={fromDeepLink}&useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}",
arguments = listOf(
navArgument("userId") { type = NavType.StringType },
navArgument("fromDeepLink") {
type = NavType.BoolType
defaultValue = false
},
navArgument("useSharedElement") {
type = NavType.StringType
defaultValue = "false"
},
navArgument("sourceMessageId") {
type = NavType.StringType
defaultValue = "-1"
}
)
) { backStackEntry ->
val args = backStackEntry.savedStateHandle
val userIdParam = args.get<String>("userId")
val parsedUserId = userIdParam?.toIntOrNull()
val userId = if ((parsedUserId ?: 0) > 0) parsedUserId else null
val profileUsername = if (userId == null) {
userIdParam?.trim()?.takeIf { it.isNotBlank() }
} else {
null
}
)
}
settingsSlideComposable(SettingsRoutes.Account, animationSpec) {
SettingsAccountScreen(
onBack = { navController.navigateUp() },
onLogout = navigateToLoginClearingChat,
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }
)
}
val useSharedElement = when (val rawUseSharedElement = args.get<Any?>("useSharedElement")) {
is Boolean -> rawUseSharedElement
is String -> rawUseSharedElement == "true"
else -> false
}
val sourceMessageId = when (val rawSourceMessageId = args.get<Any?>("sourceMessageId")) {
is Int -> rawSourceMessageId
is String -> rawSourceMessageId.toIntOrNull() ?: -1
is Long -> rawSourceMessageId.toInt()
else -> -1
}
val fromDeepLink = when (val rawFromDeepLink = args.get<Any?>("fromDeepLink")) {
is Boolean -> rawFromDeepLink
is String -> rawFromDeepLink == "true"
else -> false
}
Logger.d(
"ProfileRoute",
"profile entry args: rawUserId=$userIdParam parsedUserId=$parsedUserId resolvedUserId=$userId " +
"resolvedUsername=$profileUsername sourceMessageId=$sourceMessageId fromDeepLink=$fromDeepLink " +
"useSharedElement=$useSharedElement currentRoute=${backStackEntry.destination.route}"
)
ProfileScreen(
userId = userId,
username = profileUsername,
onBack = { navController.navigateUp() },
onChat = { navController.navigate(DmNav.chatRoute(it)) },
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable,
useSharedElementFromNavigation = useSharedElement,
sharedSourceMessageId = sourceMessageId,
showErrorAsToast = fromDeepLink
)
}
val dmChatProfileFade = tween<Float>(durationMillis = 280)
composable(
route = DmNav.CHAT_ROUTE,
arguments = listOf(
navArgument("otherUserId") { type = NavType.StringType },
navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 },
),
enterTransition = {
when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
else -> slideIntoContainer(Start, animationSpec = animationSpec)
}
},
exitTransition = {
when (targetState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
else -> slideOutOfContainer(Start, animationSpec = animationSpec)
}
},
popEnterTransition = {
when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
else -> slideIntoContainer(End, animationSpec = animationSpec)
}
},
popExitTransition = {
when (targetState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
else -> slideOutOfContainer(End, animationSpec = animationSpec)
}
},
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
val sourceMessageId = entry.savedStateHandle.get<Int>("sourceMessageId") ?: -1
if (otherUserId <= 0) return@composable
DmChatRoute(
otherUserId = otherUserId,
scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
}
composable(
route = DmNav.PROFILE_ROUTE,
arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = dmChatProfileFade) },
exitTransition = { fadeOut(animationSpec = dmChatProfileFade) },
popEnterTransition = { fadeIn(animationSpec = dmChatProfileFade) },
popExitTransition = { fadeOut(animationSpec = dmChatProfileFade) },
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
if (otherUserId <= 0) return@composable
DmProfileRoute(
otherUserId = otherUserId,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
}
settingsSlideComposable("about", animationSpec) {
AboutScreen()
}
val navigateToLoginClearingChat = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
settingsSlideComposable(SettingsRoutes.Appearance, animationSpec) {
SettingsAppearanceScreen(onBack = { navController.navigateUp() })
}
settingsSlideComposable(SettingsRoutes.ServerTools, animationSpec) {
SettingsServerToolsScreen(
onBack = { navController.navigateUp() },
outerNav = navController
)
}
settingsSlideComposable(SettingsRoutes.Notifications, animationSpec) {
SettingsNotificationsScreen(onBack = { navController.navigateUp() })
}
settingsSlideComposable(SettingsRoutes.Devices, animationSpec) {
SettingsDevicesScreen(onBack = { navController.navigateUp() })
}
settingsSlideComposable(SettingsRoutes.Security, animationSpec) {
SettingsSecurityHubScreen(
onBack = { navController.navigateUp() },
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }
)
}
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, animationSpec) {
SettingsSecurityPasswordFlowScreen(
onBack = { navController.navigateUp() },
onDonePopToHub = {
navController.popBackStack()
}
)
}
settingsSlideComposable(SettingsRoutes.Account, animationSpec) {
SettingsAccountScreen(
onBack = { navController.navigateUp() },
onLogout = navigateToLoginClearingChat,
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }
)
}
}
}
}
@@ -22,8 +22,11 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource
@@ -47,7 +50,9 @@ private const val PAGE_COUNT = 4
fun MainScreen(
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
snackbarHostState: SnackbarHostState? = null
) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current
val pagerState = rememberPagerState(
initialPage = PAGE_CHATS,
@@ -60,6 +65,7 @@ fun MainScreen(
val isChatsPage = selectedPage == PAGE_CHATS
Scaffold(
snackbarHost = { SnackbarHost(hostState = effectiveSnackbarHostState) },
bottomBar = {
NavigationBar {
NavigationBarItem(
@@ -69,9 +69,11 @@ import com.pr0gramm3r101.components.ListItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import io.ktor.client.plugins.ClientRequestException
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.core.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UserProfile
@@ -114,6 +116,7 @@ private val profileActionCardPressSpring = spring<Float>(
@Composable
fun ProfileScreen(
userId: Int?,
username: String? = null,
onBack: () -> Unit,
onChat: (Int) -> Unit,
hideAvatar: Boolean = false,
@@ -126,6 +129,7 @@ fun ProfileScreen(
useSharedElementFromNavigation: Boolean = false,
sharedSourceMessageId: Int = -1,
initialDisplayName: String? = null,
showErrorAsToast: Boolean = false,
onOpenSettings: () -> Unit = {}
) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
@@ -134,6 +138,7 @@ fun ProfileScreen(
val profileTitle = stringResource(Res.string.profile_title)
val cdBack = stringResource(Res.string.back)
val profileLoadFailed = stringResource(Res.string.profile_load_failed)
val profileNotFound = stringResource(Res.string.profile_not_found)
val labelSettings = stringResource(Res.string.action_open_settings)
val labelChat = stringResource(Res.string.action_chat)
val labelLink = stringResource(Res.string.action_copy_link)
@@ -147,11 +152,27 @@ fun ProfileScreen(
val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support)
val hideBackButton = navController.currentDestination?.route == "chat"
val targetUserId = userId.takeIf { it != null && it > 0 }
val targetUsername = username?.trim()?.takeIf { it.isNotBlank() }
val ownUserId = ApiClient.user?.id?.takeIf { it > 0 }
val cacheLookupId = targetUserId ?: ownUserId
val cacheLookupId = when {
targetUserId != null -> targetUserId
targetUsername != null -> null
else -> ownUserId
}
val isTargetProfileLookup = targetUserId != null || targetUsername != null
val lookupMode = if (targetUserId != null) "id" else if (targetUsername != null) "username" else "own"
val lookupIdentifier = when {
targetUserId != null -> targetUserId.toString()
!targetUsername.isNullOrBlank() -> targetUsername
else -> "self"
}
var state by remember(targetUserId, ownUserId) {
var state by remember(targetUserId, targetUsername, ownUserId) {
val cached = cacheLookupId?.let { ProfileCache.get(it) }
Logger.d(
"ProfileScreen",
"state init: cacheLookupId=$cacheLookupId cachedId=${cached?.id} cachedUsername=${cached?.username} cachedDisplay=${cached?.displayName}"
)
mutableStateOf(
ProfileUiState(
profile = cached,
@@ -164,17 +185,25 @@ fun ProfileScreen(
val latestUi by rememberUpdatedState(state)
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(cacheLookupId, targetUserId, lifecycleOwner) {
LaunchedEffect(cacheLookupId, targetUserId, targetUsername, lifecycleOwner) {
Logger.d(
"ProfileScreen",
"load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId " +
"lifecycleStarted=${lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)}"
)
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
runCatching {
if (targetUserId == null) {
if (targetUserId == null && targetUsername == null) {
ApiClient.getOwnProfile()
} else if (targetUsername != null) {
ApiClient.getProfileByUsername(targetUsername)
} else {
ApiClient.getProfileById(targetUserId)
ApiClient.getProfileById(targetUserId!!)
}
}.onSuccess { profile ->
if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) }
Logger.d("ProfileScreen", "load success but blank identity for id=${profile.id}, dropping as unusable preview")
state = latestUi.copy(
profile = null,
isLoading = false,
@@ -182,16 +211,52 @@ fun ProfileScreen(
)
return@onSuccess
}
Logger.d(
"ProfileScreen",
"load success: mode=$lookupMode identifier=$lookupIdentifier -> id=${profile.id}, username='${profile.username}', display='${profile.displayName}', deleted=${profile.deleted}, suspended=${profile.suspended}"
)
ProfileCache.put(profile)
state = latestUi.copy(profile = profile, isLoading = false, error = null)
}.onFailure { err ->
val fallbackId = targetUserId ?: ownUserId
val fallbackId = if (targetUsername != null) null else targetUserId ?: ownUserId
fallbackId?.let { ProfileCache.evictUnusableClientPreview(it) }
val fallback = fallbackId?.let { ProfileCache.get(it) }
Logger.d(
"ProfileScreen",
"load failure fallback lookup: fallbackId=$fallbackId fallbackFound=${fallback != null}"
)
val resolvedErrorMessage = when {
err is ClientRequestException && err.response.status.value == 404 -> profileNotFound
else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed
}
if (err is ClientRequestException) {
Logger.d(
"ProfileScreen",
"load failed: mode=$lookupMode identifier=$lookupIdentifier " +
"status=${err.response.status.value} fallbackId=$fallbackId error=${err.message}"
)
} else {
Logger.d(
"ProfileScreen",
"load failed: mode=$lookupMode identifier=$lookupIdentifier " +
"errorType=${err::class.simpleName} message=${err.message}"
)
}
if (
showErrorAsToast &&
isTargetProfileLookup &&
latestUi.profile == null &&
fallback == null
) {
showProfileLoadErrorMessage(resolvedErrorMessage)
}
state = latestUi.copy(
error = if (latestUi.profile == null && fallback == null) {
err.message?.takeIf { it.isNotBlank() }?.let { ProfileLoadError.Message(it) }
?: ProfileLoadError.Generic
if (resolvedErrorMessage == profileLoadFailed) {
ProfileLoadError.Generic
} else {
ProfileLoadError.Message(resolvedErrorMessage)
}
} else {
null
},
@@ -4,4 +4,16 @@ import androidx.compose.ui.window.ComposeUIViewController
import ru.fromchat.ui.App
@Suppress("unused")
fun MainViewController() = ComposeUIViewController { App() }
fun MainViewController(
startAtProfileUserId: Int?,
startAtProfileUsername: String?
) = ComposeUIViewController {
App(
startAtProfileUserId = startAtProfileUserId,
startAtProfileUsername = startAtProfileUsername
)
}
fun MainViewController() = ComposeUIViewController {
App()
}
@@ -0,0 +1,6 @@
package ru.fromchat
actual fun showProfileLoadErrorMessage(message: String) {
// iOS currently relies on screen-level error UI for deep-link failures.
}
+2 -1
View File
@@ -24,4 +24,5 @@ android.nonTransitiveRClass=true
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
org.gradle.configuration-cache=true
+5 -4
View File
@@ -1,5 +1,5 @@
[versions]
agp = "9.1.0"
agp = "9.1.1"
androidx-activityCompose = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.18.0"
@@ -20,13 +20,13 @@ kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.9.0"
multiplatformSettings = "1.3.0"
serialization = "2.3.20"
serialization-json = "1.10.0"
serialization-json = "1.11.0"
material = "1.13.0"
activityKtx = "1.13.0"
navigationCompose = "2.9.2"
datastore = "1.2.1"
security-crypto = "1.1.0"
ktor = "3.4.1"
ktor = "3.4.2"
slf4j = "1.7.36"
kotlinxDatetime = "0.7.1"
lifecycleRuntimeKtx = "2.10.0"
@@ -37,7 +37,7 @@ composeComponents = "1.10.3"
playServicesBase = "18.10.0"
tweetnaclJava = "1.1.3"
androidxWork = "2.11.2"
cryptography-kotlin = "0.5.0"
cryptography-kotlin = "0.6.0"
krypto = "4.0.10"
sqldelight = "2.3.2"
@@ -46,6 +46,7 @@ androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", versi
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coilCompose" }
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilCompose" }