mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement deep links
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
Generated
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
<component name="ArtifactManager">
|
<component name="ArtifactManager">
|
||||||
<artifact type="jar" name="shared">
|
<artifact type="jar" name="shared">
|
||||||
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
|
<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>
|
</artifact>
|
||||||
</component>
|
</component>
|
||||||
Generated
+1
@@ -8,5 +8,6 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/.cursor/skills/material-3-skill" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -26,6 +26,15 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
<service
|
<service
|
||||||
android:name=".fcm.FromChatFirebaseMessagingService"
|
android:name=".fcm.FromChatFirebaseMessagingService"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ru.fromchat
|
package ru.fromchat
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -12,6 +13,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import com.google.android.gms.common.ConnectionResult
|
import com.google.android.gms.common.ConnectionResult
|
||||||
import com.google.android.gms.common.GoogleApiAvailability
|
import com.google.android.gms.common.GoogleApiAvailability
|
||||||
import io.ktor.client.call.body
|
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.client.request.setBody
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.MessagesResponse
|
import ru.fromchat.api.MessagesResponse
|
||||||
import ru.fromchat.core.config.Config
|
import ru.fromchat.core.config.Config
|
||||||
|
import ru.fromchat.core.Logger
|
||||||
import ru.fromchat.ui.App
|
import ru.fromchat.ui.App
|
||||||
import ru.fromchat.ui.isPublicChatVisible
|
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 EXTRA_MESSAGE_ID = "scroll_to_message_id"
|
||||||
private const val CHAT_TYPE_PUBLIC = "public"
|
private const val CHAT_TYPE_PUBLIC = "public"
|
||||||
private const val CHAT_TYPE_DM = "dm"
|
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() {
|
class MainActivity : ComponentActivity() {
|
||||||
private var scrollToMessageId by mutableStateOf<Int?>(null)
|
private var scrollToMessageId by mutableStateOf<Int?>(null)
|
||||||
private var startAtPublicChat by mutableStateOf(false)
|
private var startAtPublicChat by mutableStateOf(false)
|
||||||
private var startAtDmConversationUserId by mutableStateOf<Int?>(null)
|
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 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 messageId = intent?.getIntExtra(EXTRA_MESSAGE_ID, -1) ?: -1
|
||||||
val chatType = intent?.getStringExtra(EXTRA_NOTIFICATION_CHAT_TYPE) ?: CHAT_TYPE_PUBLIC
|
val chatType = intent?.getStringExtra(EXTRA_NOTIFICATION_CHAT_TYPE) ?: CHAT_TYPE_PUBLIC
|
||||||
val dmConversationUserId = intent?.getIntExtra(EXTRA_OPEN_DM_USER_ID, -1) ?: -1
|
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
|
val baseState = ProfileDeepLinkResolution(
|
||||||
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM
|
scrollToMessageId = if (messageId != -1) messageId else null,
|
||||||
startAtDmConversationUserId = if (chatType == CHAT_TYPE_DM && dmConversationUserId > 0) {
|
startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM,
|
||||||
dmConversationUserId
|
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 {
|
} else {
|
||||||
null
|
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) {
|
if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) {
|
||||||
markMessagesAsRead()
|
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)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
@@ -110,30 +235,38 @@ class MainActivity : ComponentActivity() {
|
|||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
// Handle initial intent
|
lifecycleScope.launch {
|
||||||
handleIntent(intent)
|
val launchState = buildLaunchState(intent)
|
||||||
|
applyLaunchState(launchState)
|
||||||
|
setContent {
|
||||||
|
App(
|
||||||
|
scrollToMessageId = scrollToMessageId,
|
||||||
|
startAtPublicChat = startAtPublicChat,
|
||||||
|
startAtDmConversationUserId = startAtDmConversationUserId,
|
||||||
|
startAtProfileUserId = startAtProfileUserId,
|
||||||
|
startAtProfileUsername = startAtProfileUsername,
|
||||||
|
profileLookupErrorMessage = profileLookupErrorMessage,
|
||||||
|
onProfileLookupErrorMessageConsumed = {
|
||||||
|
profileLookupErrorMessage = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
setContent {
|
checkGooglePlayServices()
|
||||||
App(
|
|
||||||
scrollToMessageId = scrollToMessageId,
|
|
||||||
startAtPublicChat = startAtPublicChat,
|
|
||||||
startAtDmConversationUserId = startAtDmConversationUserId
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request POST_NOTIFICATIONS permission on Android 13+
|
// Request POST_NOTIFICATIONS permission on Android 13+
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
registerForActivityResult(
|
requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) {}.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
checkGooglePlayServices()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleIntent(intent)
|
lifecycleScope.launch {
|
||||||
|
val launchState = buildLaunchState(intent)
|
||||||
|
applyLaunchState(launchState)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
|||||||
@@ -1,18 +1,99 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposeApp
|
import ComposeApp
|
||||||
|
|
||||||
struct ComposeView: UIViewControllerRepresentable {
|
struct ComposeView: UIViewControllerRepresentable {
|
||||||
|
let startAtProfileUserId: Int?
|
||||||
|
let startAtProfileUsername: String?
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIViewController {
|
func makeUIViewController(context: Context) -> UIViewController {
|
||||||
MainViewControllerKt.MainViewController()
|
MainViewControllerKt.MainViewController(
|
||||||
|
startAtProfileUserId = startAtProfileUserId,
|
||||||
|
startAtProfileUsername = startAtProfileUsername
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView: View {
|
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 {
|
var body: some View {
|
||||||
ComposeView()
|
ComposeView(
|
||||||
.ignoresSafeArea(.all) // Compose has own keyboard handler
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,19 @@
|
|||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
<false/>
|
<false/>
|
||||||
</dict>
|
</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>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ kotlin {
|
|||||||
implementation(libs.haze)
|
implementation(libs.haze)
|
||||||
implementation(libs.haze.materials)
|
implementation(libs.haze.materials)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
|
||||||
// Serialization
|
// Serialization
|
||||||
implementation(libs.kotlinx.serialization.json)
|
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="cd_send">Отправить</string>
|
||||||
<string name="profile_title">Профиль</string>
|
<string name="profile_title">Профиль</string>
|
||||||
<string name="profile_load_failed">Не получилось загрузить профиль</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_open_settings">Настройки</string>
|
||||||
<string name="action_chat">Написать</string>
|
<string name="action_chat">Написать</string>
|
||||||
<string name="action_copy_link">Скопировать ссылку</string>
|
<string name="action_copy_link">Скопировать ссылку</string>
|
||||||
|
|||||||
@@ -81,6 +81,8 @@
|
|||||||
<!-- Profile -->
|
<!-- Profile -->
|
||||||
<string name="profile_title">Profile</string>
|
<string name="profile_title">Profile</string>
|
||||||
<string name="profile_load_failed">Couldn’t load this profile</string>
|
<string name="profile_load_failed">Couldn’t 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_open_settings">Settings</string>
|
||||||
<string name="action_chat">Chat</string>
|
<string name="action_chat">Chat</string>
|
||||||
<string name="action_copy_link">Copy link</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()
|
.body()
|
||||||
|
|
||||||
|
suspend fun getProfileByUsername(username: String): UserProfile =
|
||||||
|
http
|
||||||
|
.get("${Config.apiBaseUrl}/user/$username") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
.body()
|
||||||
|
|
||||||
suspend fun getRegisteredUserCount(): Int =
|
suspend fun getRegisteredUserCount(): Int =
|
||||||
http
|
http
|
||||||
.get("${Config.apiBaseUrl}/user/stats/registered-count") {
|
.get("${Config.apiBaseUrl}/user/stats/registered-count") {
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package ru.fromchat.ui
|
package ru.fromchat.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContentScope
|
||||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
|
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
|
||||||
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
|
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
|
||||||
import androidx.compose.animation.SharedTransitionLayout
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import coil3.ImageLoader
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import coil3.compose.setSingletonImageLoaderFactory
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import coil3.svg.SvgDecoder
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
@@ -18,27 +21,42 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import ru.fromchat.AppForeground
|
import androidx.navigation.NavBackStackEntry
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavGraphBuilder
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
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.ApiClient
|
||||||
import ru.fromchat.api.UserStatusStore
|
|
||||||
import ru.fromchat.api.ProfileCache
|
import ru.fromchat.api.ProfileCache
|
||||||
import ru.fromchat.api.UpdateSyncManager
|
import ru.fromchat.api.UpdateSyncManager
|
||||||
|
import ru.fromchat.api.UserStatusStore
|
||||||
import ru.fromchat.api.WebSocketManager
|
import ru.fromchat.api.WebSocketManager
|
||||||
import ru.fromchat.api.WebSocketMessage
|
import ru.fromchat.api.WebSocketMessage
|
||||||
import ru.fromchat.api.WebSocketUpdatesData
|
import ru.fromchat.api.WebSocketUpdatesData
|
||||||
import ru.fromchat.net.NetworkConnectivity
|
import ru.fromchat.core.Logger
|
||||||
import ru.fromchat.core.config.Config
|
import ru.fromchat.core.config.Config
|
||||||
|
import ru.fromchat.net.NetworkConnectivity
|
||||||
import ru.fromchat.ui.auth.LoginScreen
|
import ru.fromchat.ui.auth.LoginScreen
|
||||||
import ru.fromchat.ui.auth.RegisterScreen
|
import ru.fromchat.ui.auth.RegisterScreen
|
||||||
import ru.fromchat.ui.chat.PublicChatScreen
|
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.DmChatRoute
|
||||||
import ru.fromchat.ui.dm.DmNav
|
import ru.fromchat.ui.dm.DmNav
|
||||||
import ru.fromchat.ui.dm.DmProfileRoute
|
import ru.fromchat.ui.dm.DmProfileRoute
|
||||||
import ru.fromchat.ui.main.MainScreen
|
|
||||||
import ru.fromchat.ui.main.ChatsSearchScreen
|
import ru.fromchat.ui.main.ChatsSearchScreen
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.main.MainScreen
|
||||||
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.settings.SettingsAccountScreen
|
import ru.fromchat.ui.main.settings.SettingsAccountScreen
|
||||||
import ru.fromchat.ui.main.settings.SettingsAppearanceScreen
|
import ru.fromchat.ui.main.settings.SettingsAppearanceScreen
|
||||||
import ru.fromchat.ui.main.settings.SettingsDevicesScreen
|
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.SettingsSecurityHubScreen
|
||||||
import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen
|
import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen
|
||||||
import ru.fromchat.ui.main.settings.SettingsServerToolsScreen
|
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") }
|
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
|
||||||
|
|
||||||
@@ -156,7 +163,11 @@ private fun NavGraphBuilder.settingsSlideComposable(
|
|||||||
fun App(
|
fun App(
|
||||||
scrollToMessageId: Int? = null,
|
scrollToMessageId: Int? = null,
|
||||||
startAtPublicChat: Boolean = false,
|
startAtPublicChat: Boolean = false,
|
||||||
startAtDmConversationUserId: Int? = null
|
startAtDmConversationUserId: Int? = null,
|
||||||
|
startAtProfileUserId: Int? = null,
|
||||||
|
startAtProfileUsername: String? = null,
|
||||||
|
profileLookupErrorMessage: String? = null,
|
||||||
|
onProfileLookupErrorMessageConsumed: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
setSingletonImageLoaderFactory { context ->
|
setSingletonImageLoaderFactory { context ->
|
||||||
ImageLoader.Builder(context)
|
ImageLoader.Builder(context)
|
||||||
@@ -204,7 +215,7 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]).
|
// Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]).
|
||||||
val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||||
DisposableEffect(lifecycleOwner) {
|
DisposableEffect(lifecycleOwner) {
|
||||||
fun syncForeground() {
|
fun syncForeground() {
|
||||||
AppForeground.setForeground(
|
AppForeground.setForeground(
|
||||||
@@ -242,25 +253,70 @@ fun App(
|
|||||||
FromChatTheme {
|
FromChatTheme {
|
||||||
SharedTransitionLayout {
|
SharedTransitionLayout {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
val profileLookupSnackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
// Handle navigation to the target chat when launched from notification
|
LaunchedEffect(profileLookupErrorMessage) {
|
||||||
LaunchedEffect(startAtDmConversationUserId, startAtPublicChat, startDestination) {
|
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") {
|
if (startDestination == null || startDestination == "login") {
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startAtDmConversationUserId != null && startAtDmConversationUserId > 0) {
|
if (startAtProfileUserId != null && startAtProfileUserId > 0) {
|
||||||
navController.navigate(
|
Logger.d(
|
||||||
DmNav.chatRoute(
|
"ProfileDeepLink",
|
||||||
otherUserId = startAtDmConversationUserId,
|
"navigating by deep link userId=$startAtProfileUserId"
|
||||||
sourceMessageId = scrollToMessageId
|
)
|
||||||
|
navController.navigate("profile/$startAtProfileUserId?fromDeepLink=true")
|
||||||
|
} else {
|
||||||
|
val trimmedUsername = startAtProfileUsername?.trim()
|
||||||
|
if (!trimmedUsername.isNullOrBlank()) {
|
||||||
|
Logger.d(
|
||||||
|
"ProfileDeepLink",
|
||||||
|
"navigating by deep link username=$trimmedUsername"
|
||||||
)
|
)
|
||||||
) {
|
navController.navigate("profile/$trimmedUsername?fromDeepLink=true")
|
||||||
launchSingleTop = true
|
} else if (startAtDmConversationUserId != null && startAtDmConversationUserId > 0) {
|
||||||
}
|
Logger.d(
|
||||||
} else if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") {
|
"ProfileDeepLink",
|
||||||
navController.navigate("chats/publicChat") {
|
"navigating by notification chat route user=$startAtDmConversationUserId messageId=$scrollToMessageId"
|
||||||
launchSingleTop = true
|
)
|
||||||
|
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
|
// Set up global auth error handler
|
||||||
LaunchedEffect(navController) {
|
LaunchedEffect(navController) {
|
||||||
ApiClient.onAuthError = {
|
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") {
|
navController.navigate("login") {
|
||||||
popUpTo("chat") { inclusive = true }
|
popUpTo("chat") { inclusive = true }
|
||||||
}
|
}
|
||||||
@@ -281,7 +337,6 @@ fun App(
|
|||||||
) {
|
) {
|
||||||
if (startDestination != null) {
|
if (startDestination != null) {
|
||||||
val animationSpec = tween<IntOffset>(400)
|
val animationSpec = tween<IntOffset>(400)
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination!!,
|
startDestination = startDestination!!,
|
||||||
@@ -310,217 +365,253 @@ fun App(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
composable("serverConfig") {
|
composable("serverConfig") {
|
||||||
ServerConfigScreen()
|
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 }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
settingsSlideComposable(SettingsRoutes.Appearance, animationSpec) {
|
composable("login") {
|
||||||
SettingsAppearanceScreen(onBack = { navController.navigateUp() })
|
LoginScreen(
|
||||||
}
|
onLoginSuccess = {
|
||||||
settingsSlideComposable(SettingsRoutes.ServerTools, animationSpec) {
|
WebSocketManager.connect(forceRestart = true)
|
||||||
SettingsServerToolsScreen(
|
navController.navigate("chat") {
|
||||||
onBack = { navController.navigateUp() },
|
popUpTo("login") { inclusive = true }
|
||||||
outerNav = navController
|
}
|
||||||
)
|
},
|
||||||
}
|
onNavigateToRegister = { navController.navigate("register") }
|
||||||
settingsSlideComposable(SettingsRoutes.Notifications, animationSpec) {
|
)
|
||||||
SettingsNotificationsScreen(onBack = { navController.navigateUp() })
|
}
|
||||||
}
|
|
||||||
settingsSlideComposable(SettingsRoutes.Devices, animationSpec) {
|
composable("register") {
|
||||||
SettingsDevicesScreen(onBack = { navController.navigateUp() })
|
RegisterScreen(
|
||||||
}
|
onRegistered = {
|
||||||
settingsSlideComposable(SettingsRoutes.Security, animationSpec) {
|
navController.navigate("chat") {
|
||||||
SettingsSecurityHubScreen(
|
popUpTo("login") { inclusive = true }
|
||||||
onBack = { navController.navigateUp() },
|
}
|
||||||
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, animationSpec) {
|
|
||||||
SettingsSecurityPasswordFlowScreen(
|
composable("chat") {
|
||||||
onBack = { navController.navigateUp() },
|
MainScreen(
|
||||||
onDonePopToHub = {
|
sharedTransitionScope = this@SharedTransitionLayout,
|
||||||
navController.popBackStack()
|
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
|
||||||
}
|
}
|
||||||
)
|
val useSharedElement = when (val rawUseSharedElement = args.get<Any?>("useSharedElement")) {
|
||||||
}
|
is Boolean -> rawUseSharedElement
|
||||||
settingsSlideComposable(SettingsRoutes.Account, animationSpec) {
|
is String -> rawUseSharedElement == "true"
|
||||||
SettingsAccountScreen(
|
else -> false
|
||||||
onBack = { navController.navigateUp() },
|
}
|
||||||
onLogout = navigateToLoginClearingChat,
|
val sourceMessageId = when (val rawSourceMessageId = args.get<Any?>("sourceMessageId")) {
|
||||||
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) }
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
@@ -47,7 +50,9 @@ private const val PAGE_COUNT = 4
|
|||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
sharedTransitionScope: SharedTransitionScope? = null,
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
|
snackbarHostState: SnackbarHostState? = null
|
||||||
) {
|
) {
|
||||||
|
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(
|
||||||
initialPage = PAGE_CHATS,
|
initialPage = PAGE_CHATS,
|
||||||
@@ -60,6 +65,7 @@ fun MainScreen(
|
|||||||
val isChatsPage = selectedPage == PAGE_CHATS
|
val isChatsPage = selectedPage == PAGE_CHATS
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(hostState = effectiveSnackbarHostState) },
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
NavigationBar {
|
NavigationBar {
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
|
|||||||
@@ -69,9 +69,11 @@ import com.pr0gramm3r101.components.ListItem
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.*
|
import ru.fromchat.*
|
||||||
|
import ru.fromchat.core.Logger
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.ProfileCache
|
import ru.fromchat.api.ProfileCache
|
||||||
import ru.fromchat.api.UserProfile
|
import ru.fromchat.api.UserProfile
|
||||||
@@ -114,6 +116,7 @@ private val profileActionCardPressSpring = spring<Float>(
|
|||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(
|
fun ProfileScreen(
|
||||||
userId: Int?,
|
userId: Int?,
|
||||||
|
username: String? = null,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onChat: (Int) -> Unit,
|
onChat: (Int) -> Unit,
|
||||||
hideAvatar: Boolean = false,
|
hideAvatar: Boolean = false,
|
||||||
@@ -126,6 +129,7 @@ fun ProfileScreen(
|
|||||||
useSharedElementFromNavigation: Boolean = false,
|
useSharedElementFromNavigation: Boolean = false,
|
||||||
sharedSourceMessageId: Int = -1,
|
sharedSourceMessageId: Int = -1,
|
||||||
initialDisplayName: String? = null,
|
initialDisplayName: String? = null,
|
||||||
|
showErrorAsToast: Boolean = false,
|
||||||
onOpenSettings: () -> Unit = {}
|
onOpenSettings: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||||
@@ -134,6 +138,7 @@ fun ProfileScreen(
|
|||||||
val profileTitle = stringResource(Res.string.profile_title)
|
val profileTitle = stringResource(Res.string.profile_title)
|
||||||
val cdBack = stringResource(Res.string.back)
|
val cdBack = stringResource(Res.string.back)
|
||||||
val profileLoadFailed = stringResource(Res.string.profile_load_failed)
|
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 labelSettings = stringResource(Res.string.action_open_settings)
|
||||||
val labelChat = stringResource(Res.string.action_chat)
|
val labelChat = stringResource(Res.string.action_chat)
|
||||||
val labelLink = stringResource(Res.string.action_copy_link)
|
val labelLink = stringResource(Res.string.action_copy_link)
|
||||||
@@ -147,11 +152,27 @@ fun ProfileScreen(
|
|||||||
val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support)
|
val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support)
|
||||||
val hideBackButton = navController.currentDestination?.route == "chat"
|
val hideBackButton = navController.currentDestination?.route == "chat"
|
||||||
val targetUserId = userId.takeIf { it != null && it > 0 }
|
val targetUserId = userId.takeIf { it != null && it > 0 }
|
||||||
|
val targetUsername = username?.trim()?.takeIf { it.isNotBlank() }
|
||||||
val ownUserId = ApiClient.user?.id?.takeIf { it > 0 }
|
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) }
|
val cached = cacheLookupId?.let { ProfileCache.get(it) }
|
||||||
|
Logger.d(
|
||||||
|
"ProfileScreen",
|
||||||
|
"state init: cacheLookupId=$cacheLookupId cachedId=${cached?.id} cachedUsername=${cached?.username} cachedDisplay=${cached?.displayName}"
|
||||||
|
)
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
ProfileUiState(
|
ProfileUiState(
|
||||||
profile = cached,
|
profile = cached,
|
||||||
@@ -164,17 +185,25 @@ fun ProfileScreen(
|
|||||||
val latestUi by rememberUpdatedState(state)
|
val latestUi by rememberUpdatedState(state)
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
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) {
|
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
runCatching {
|
runCatching {
|
||||||
if (targetUserId == null) {
|
if (targetUserId == null && targetUsername == null) {
|
||||||
ApiClient.getOwnProfile()
|
ApiClient.getOwnProfile()
|
||||||
|
} else if (targetUsername != null) {
|
||||||
|
ApiClient.getProfileByUsername(targetUsername)
|
||||||
} else {
|
} else {
|
||||||
ApiClient.getProfileById(targetUserId)
|
ApiClient.getProfileById(targetUserId!!)
|
||||||
}
|
}
|
||||||
}.onSuccess { profile ->
|
}.onSuccess { profile ->
|
||||||
if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
|
if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
|
||||||
cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) }
|
cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) }
|
||||||
|
Logger.d("ProfileScreen", "load success but blank identity for id=${profile.id}, dropping as unusable preview")
|
||||||
state = latestUi.copy(
|
state = latestUi.copy(
|
||||||
profile = null,
|
profile = null,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -182,16 +211,52 @@ fun ProfileScreen(
|
|||||||
)
|
)
|
||||||
return@onSuccess
|
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)
|
ProfileCache.put(profile)
|
||||||
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
state = latestUi.copy(profile = profile, isLoading = false, error = null)
|
||||||
}.onFailure { err ->
|
}.onFailure { err ->
|
||||||
val fallbackId = targetUserId ?: ownUserId
|
val fallbackId = if (targetUsername != null) null else targetUserId ?: ownUserId
|
||||||
fallbackId?.let { ProfileCache.evictUnusableClientPreview(it) }
|
fallbackId?.let { ProfileCache.evictUnusableClientPreview(it) }
|
||||||
val fallback = fallbackId?.let { ProfileCache.get(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(
|
state = latestUi.copy(
|
||||||
error = if (latestUi.profile == null && fallback == null) {
|
error = if (latestUi.profile == null && fallback == null) {
|
||||||
err.message?.takeIf { it.isNotBlank() }?.let { ProfileLoadError.Message(it) }
|
if (resolvedErrorMessage == profileLoadFailed) {
|
||||||
?: ProfileLoadError.Generic
|
ProfileLoadError.Generic
|
||||||
|
} else {
|
||||||
|
ProfileLoadError.Message(resolvedErrorMessage)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,4 +4,16 @@ import androidx.compose.ui.window.ComposeUIViewController
|
|||||||
import ru.fromchat.ui.App
|
import ru.fromchat.ui.App
|
||||||
|
|
||||||
@Suppress("unused")
|
@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
@@ -24,4 +24,5 @@ android.nonTransitiveRClass=true
|
|||||||
android.uniquePackageNames=false
|
android.uniquePackageNames=false
|
||||||
android.dependency.useConstraints=true
|
android.dependency.useConstraints=true
|
||||||
android.r8.strictFullModeForKeepRules=false
|
android.r8.strictFullModeForKeepRules=false
|
||||||
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
|
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.1.0"
|
agp = "9.1.1"
|
||||||
androidx-activityCompose = "1.13.0"
|
androidx-activityCompose = "1.13.0"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core-ktx = "1.18.0"
|
androidx-core-ktx = "1.18.0"
|
||||||
@@ -20,13 +20,13 @@ kotlinxCoroutinesCore = "1.10.2"
|
|||||||
kotlinxIoCore = "0.9.0"
|
kotlinxIoCore = "0.9.0"
|
||||||
multiplatformSettings = "1.3.0"
|
multiplatformSettings = "1.3.0"
|
||||||
serialization = "2.3.20"
|
serialization = "2.3.20"
|
||||||
serialization-json = "1.10.0"
|
serialization-json = "1.11.0"
|
||||||
material = "1.13.0"
|
material = "1.13.0"
|
||||||
activityKtx = "1.13.0"
|
activityKtx = "1.13.0"
|
||||||
navigationCompose = "2.9.2"
|
navigationCompose = "2.9.2"
|
||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
security-crypto = "1.1.0"
|
security-crypto = "1.1.0"
|
||||||
ktor = "3.4.1"
|
ktor = "3.4.2"
|
||||||
slf4j = "1.7.36"
|
slf4j = "1.7.36"
|
||||||
kotlinxDatetime = "0.7.1"
|
kotlinxDatetime = "0.7.1"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
@@ -37,7 +37,7 @@ composeComponents = "1.10.3"
|
|||||||
playServicesBase = "18.10.0"
|
playServicesBase = "18.10.0"
|
||||||
tweetnaclJava = "1.1.3"
|
tweetnaclJava = "1.1.3"
|
||||||
androidxWork = "2.11.2"
|
androidxWork = "2.11.2"
|
||||||
cryptography-kotlin = "0.5.0"
|
cryptography-kotlin = "0.6.0"
|
||||||
krypto = "4.0.10"
|
krypto = "4.0.10"
|
||||||
sqldelight = "2.3.2"
|
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-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-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" }
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
|
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-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-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" }
|
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilCompose" }
|
||||||
|
|||||||
Reference in New Issue
Block a user