diff --git a/.idea/artifacts/shared.xml b/.idea/artifacts/shared.xml
index 59ff3f6..df2c7e6 100644
--- a/.idea/artifacts/shared.xml
+++ b/.idea/artifacts/shared.xml
@@ -1,6 +1,8 @@
$PROJECT_DIR$/utils/shared/build/libs
-
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 98358d8..2f43577 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -8,5 +8,6 @@
+
\ No newline at end of file
diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml
index c33c8ce..5cc166f 100644
--- a/app/android/src/main/AndroidManifest.xml
+++ b/app/android/src/main/AndroidManifest.xml
@@ -26,6 +26,15 @@
+
+
+
+
+
+
(null)
private var startAtPublicChat by mutableStateOf(false)
private var startAtDmConversationUserId by mutableStateOf(null)
+ private var startAtProfileUserId by mutableStateOf(null)
+ private var startAtProfileUsername by mutableStateOf(null)
+ private var profileLookupErrorMessage by mutableStateOf(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() {
diff --git a/app/ios/iosApp/ContentView.swift b/app/ios/iosApp/ContentView.swift
index ec34983..b129730 100644
--- a/app/ios/iosApp/ContentView.swift
+++ b/app/ios/iosApp/ContentView.swift
@@ -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/."
+ )
+ }
+
+ 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/."
+ )
+ }
+ 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)
+ }
+
}
diff --git a/app/ios/iosApp/Info.plist b/app/ios/iosApp/Info.plist
index f366a8a..1f411e8 100644
--- a/app/ios/iosApp/Info.plist
+++ b/app/ios/iosApp/Info.plist
@@ -32,6 +32,19 @@
UIApplicationSupportsMultipleScenes
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ fromchat
+ CFBundleURLSchemes
+
+ fromchat
+
+
+
UILaunchScreen
UIRequiredDeviceCapabilities
diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts
index 9015818..56d7f1a 100644
--- a/app/shared/build.gradle.kts
+++ b/app/shared/build.gradle.kts
@@ -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)
diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.android.kt
new file mode 100644
index 0000000..f095ec2
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.android.kt
@@ -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()
+}
+
diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index 3816f90..605a563 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -62,6 +62,8 @@
Отправить
Профиль
Не получилось загрузить профиль
+ Профиль не найден
+ Не удалось открыть профиль. Попробуйте снова.
Настройки
Написать
Скопировать ссылку
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index b3c22f5..97f6b52 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -81,6 +81,8 @@
Profile
Couldn’t load this profile
+ This profile could not be found
+ Could not open this profile. Please try again.
Settings
Chat
Copy link
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.kt
new file mode 100644
index 0000000..43e68c6
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.kt
@@ -0,0 +1,4 @@
+package ru.fromchat
+
+expect fun showProfileLoadErrorMessage(message: String)
+
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
index d08303d..48ad5f4 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -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") {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
index eef170f..810ae6a 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
@@ -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 { 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(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(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("userId")?.toIntOrNull()
- val useSharedElement = handle.get("useSharedElement") == "true"
- val sourceMessageId = handle.get("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(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("otherUserId")?.toIntOrNull() ?: 0
- val sourceMessageId = entry.savedStateHandle.get("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("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(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("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("useSharedElement")) {
+ is Boolean -> rawUseSharedElement
+ is String -> rawUseSharedElement == "true"
+ else -> false
+ }
+ val sourceMessageId = when (val rawSourceMessageId = args.get("sourceMessageId")) {
+ is Int -> rawSourceMessageId
+ is String -> rawSourceMessageId.toIntOrNull() ?: -1
+ is Long -> rawSourceMessageId.toInt()
+ else -> -1
+ }
+ val fromDeepLink = when (val rawFromDeepLink = args.get("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(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("otherUserId")?.toIntOrNull() ?: 0
+ val sourceMessageId = entry.savedStateHandle.get("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("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) }
+ )
+ }
}
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt
index 4a6b3a0..4b6cf88 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt
@@ -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(
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
index 3751979..b3b9b4b 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt
@@ -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(
@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
},
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/MainViewController.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/MainViewController.kt
index 548d109..eb7e297 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/MainViewController.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/MainViewController.kt
@@ -4,4 +4,16 @@ import androidx.compose.ui.window.ComposeUIViewController
import ru.fromchat.ui.App
@Suppress("unused")
-fun MainViewController() = ComposeUIViewController { App() }
\ No newline at end of file
+fun MainViewController(
+ startAtProfileUserId: Int?,
+ startAtProfileUsername: String?
+) = ComposeUIViewController {
+ App(
+ startAtProfileUserId = startAtProfileUserId,
+ startAtProfileUsername = startAtProfileUsername
+ )
+}
+
+fun MainViewController() = ComposeUIViewController {
+ App()
+}
\ No newline at end of file
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.ios.kt
new file mode 100644
index 0000000..062cc7f
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.ios.kt
@@ -0,0 +1,6 @@
+package ru.fromchat
+
+actual fun showProfileLoadErrorMessage(message: String) {
+ // iOS currently relies on screen-level error UI for deep-link failures.
+}
+
diff --git a/gradle.properties b/gradle.properties
index 5270d82..325ab5e 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -24,4 +24,5 @@ android.nonTransitiveRClass=true
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
-android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
\ No newline at end of file
+android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
+org.gradle.configuration-cache=true
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ddb8064..5c5ffc9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }