mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-23 11:35:06 +03:00
Compare commits
7 Commits
Submodule .cursor/skills/material-3-skill updated: 6ebd3ae7de...14385f2bf3
@@ -19,6 +19,9 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:name=".App"
|
android:name=".App"
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||||
|
android:resource="@drawable/logo" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -39,6 +42,15 @@
|
|||||||
android:host="u"
|
android:host="u"
|
||||||
android:pathPrefix="/" />
|
android:pathPrefix="/" />
|
||||||
</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="oauth"
|
||||||
|
android:pathPrefix="/yandex" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<service
|
<service
|
||||||
android:name=".fcm.FromChatFirebaseMessagingService"
|
android:name=".fcm.FromChatFirebaseMessagingService"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class App: Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
UtilsLibrary.init(this)
|
UtilsLibrary.init(this)
|
||||||
|
ru.fromchat.notifications.ChatNotificationDismissals.install(this)
|
||||||
|
|
||||||
WebSocketManager.addGlobalMessageHandler { msg ->
|
WebSocketManager.addGlobalMessageHandler { msg ->
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
Logger.i("MainActivity", "onCreate savedInstanceStateNull=${savedInstanceState == null}")
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@@ -264,13 +265,25 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
Logger.i("MainActivity", "onPause")
|
||||||
super.onPause()
|
super.onPause()
|
||||||
prevIsPublicChatVisible = isPublicChatVisible
|
prevIsPublicChatVisible = isPublicChatVisible
|
||||||
isPublicChatVisible = false
|
isPublicChatVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
Logger.i("MainActivity", "onResume")
|
||||||
super.onResume()
|
super.onResume()
|
||||||
isPublicChatVisible = prevIsPublicChatVisible ?: false
|
isPublicChatVisible = prevIsPublicChatVisible ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
Logger.i("MainActivity", "onDestroy isFinishing=$isFinishing")
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
Logger.i("MainActivity", "onSaveInstanceState")
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -403,7 +403,7 @@ object NotificationHelper {
|
|||||||
notify(
|
notify(
|
||||||
SUMMARY_NOTIFICATION_ID,
|
SUMMARY_NOTIFICATION_ID,
|
||||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.logo_big)
|
.setSmallIcon(R.drawable.logo)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
.setStyle(
|
.setStyle(
|
||||||
@@ -506,7 +506,7 @@ object NotificationHelper {
|
|||||||
notify(
|
notify(
|
||||||
SUMMARY_NOTIFICATION_ID,
|
SUMMARY_NOTIFICATION_ID,
|
||||||
NotificationCompat.Builder(context, CHANNEL_ID)
|
NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.logo_big)
|
.setSmallIcon(R.drawable.logo)
|
||||||
.setStyle(
|
.setStyle(
|
||||||
NotificationCompat.MessagingStyle(
|
NotificationCompat.MessagingStyle(
|
||||||
Person.Builder().setName("FromChat").build()
|
Person.Builder().setName("FromChat").build()
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ kotlin {
|
|||||||
implementation(libs.sqldelight.driver.android)
|
implementation(libs.sqldelight.driver.android)
|
||||||
implementation(libs.livekit.android)
|
implementation(libs.livekit.android)
|
||||||
implementation(libs.livekit.android.compose.components)
|
implementation(libs.livekit.android.compose.components)
|
||||||
|
implementation(libs.androidx.webkit)
|
||||||
}
|
}
|
||||||
|
|
||||||
iosMain.dependencies {
|
iosMain.dependencies {
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.fromchat.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
|
||||||
|
actual object ChatNotificationDismissals {
|
||||||
|
/** Must match [ru.fromchat.notifications.NotificationHelper] summary id in app:android. */
|
||||||
|
private const val SUMMARY_NOTIFICATION_ID = 1_000_000
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
fun install(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun dismissAllMessageNotifications() {
|
||||||
|
val context = appContext ?: return
|
||||||
|
runCatching {
|
||||||
|
NotificationManagerCompat.from(context).cancel(SUMMARY_NOTIFICATION_ID)
|
||||||
|
Logger.d("ChatNotificationDismissals", "Cancelled message notifications")
|
||||||
|
}.onFailure {
|
||||||
|
Logger.w("ChatNotificationDismissals", "Failed to cancel notifications: ${it.message}", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+706
@@ -0,0 +1,706 @@
|
|||||||
|
package ru.fromchat.ui.auth.yandex
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color as AndroidColor
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.PixelCopy
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import androidx.webkit.WebSettingsCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
|
||||||
|
import ru.fromchat.auth.yandex.extractOAuthCode
|
||||||
|
import ru.fromchat.ui.components.PredictiveBackHandler
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
private const val LOG_TAG = "YandexOAuthWV"
|
||||||
|
private const val LOADING_FADE_MS = 250
|
||||||
|
private const val LOADING_HIDE_DELAY_MS = 500L
|
||||||
|
private const val TOP_ROW_SAMPLE_INTERVAL_MS = 50L
|
||||||
|
/** View tag: last applied [darkTheme] so we do not re-set force-dark (that reloads the page). */
|
||||||
|
private const val TAG_APPLIED_DARK_THEME = 0x46C7_0A01
|
||||||
|
|
||||||
|
private fun shortUrl(url: String?): String {
|
||||||
|
if (url.isNullOrBlank()) return "null"
|
||||||
|
return if (url.length <= 120) url else url.take(117) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val FORCE_COLOR_SCHEME_JS = """
|
||||||
|
(function(scheme) {
|
||||||
|
try {
|
||||||
|
var meta = document.querySelector('meta[name="color-scheme"]');
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta');
|
||||||
|
meta.name = 'color-scheme';
|
||||||
|
(document.head || document.documentElement).appendChild(meta);
|
||||||
|
}
|
||||||
|
meta.content = scheme;
|
||||||
|
document.documentElement.style.colorScheme = scheme;
|
||||||
|
document.documentElement.setAttribute('data-theme', scheme);
|
||||||
|
document.documentElement.classList.toggle('theme_dark', scheme === 'dark');
|
||||||
|
document.documentElement.classList.toggle('Theme_color_dark', scheme === 'dark');
|
||||||
|
document.documentElement.classList.toggle('theme_light', scheme === 'light');
|
||||||
|
try { localStorage.setItem('color-scheme', scheme); } catch (e) {}
|
||||||
|
try { localStorage.setItem('theme', scheme); } catch (e) {}
|
||||||
|
} catch (e) {}
|
||||||
|
})
|
||||||
|
"""
|
||||||
|
|
||||||
|
private const val DISABLE_USER_SELECT_JS = """
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var id = 'fromchat-no-select';
|
||||||
|
if (document.getElementById(id)) return;
|
||||||
|
var s = document.createElement('style');
|
||||||
|
s.id = id;
|
||||||
|
s.textContent = [
|
||||||
|
'*,*::before,*::after{',
|
||||||
|
'-webkit-user-select:none!important;',
|
||||||
|
'user-select:none!important;',
|
||||||
|
'-webkit-touch-callout:none!important;',
|
||||||
|
'}',
|
||||||
|
'input,textarea,select,[contenteditable],[contenteditable="true"],',
|
||||||
|
'input *,textarea *,[contenteditable] *,[contenteditable="true"] *{',
|
||||||
|
'-webkit-user-select:text!important;',
|
||||||
|
'user-select:text!important;',
|
||||||
|
'-webkit-touch-callout:default!important;',
|
||||||
|
'}'
|
||||||
|
].join('');
|
||||||
|
(document.head || document.documentElement).appendChild(s);
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
/** Document scroll offset (not just window); >0 means the page start is off-screen. */
|
||||||
|
private const val PAGE_SCROLL_Y_JS = """
|
||||||
|
(function() {
|
||||||
|
var y = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
|
||||||
|
var nodes = document.querySelectorAll('html,body,main,#root,#app,[data-scroll],.Scroll,.scroll,.passp-page');
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
try { y = Math.max(y, nodes[i].scrollTop || 0); } catch (e) {}
|
||||||
|
}
|
||||||
|
return y;
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
actual fun YandexOAuthWebView(
|
||||||
|
authorizeUrl: String,
|
||||||
|
languageTag: String,
|
||||||
|
darkTheme: Boolean,
|
||||||
|
fallbackColor: Color,
|
||||||
|
clearCookies: Boolean,
|
||||||
|
onPageBackgroundColor: (Color) -> Unit,
|
||||||
|
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
|
||||||
|
onCode: (String) -> Unit,
|
||||||
|
onError: (String) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
var canGoBack by remember { mutableStateOf(false) }
|
||||||
|
var pageLoading by remember { mutableStateOf(true) }
|
||||||
|
var showLoadingOverlay by remember { mutableStateOf(true) }
|
||||||
|
var chromeColor by remember { mutableStateOf(fallbackColor) }
|
||||||
|
// Survives Activity recreate so the OAuth page is not reloaded from authorizeUrl.
|
||||||
|
val savedWebViewState = rememberSaveable { Bundle() }
|
||||||
|
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
|
||||||
|
val scheme = if (darkTheme) "dark" else "light"
|
||||||
|
val onHistoryBackAvailabilityChangedState = rememberUpdatedState(onHistoryBackAvailabilityChanged)
|
||||||
|
val onPageBackgroundColorState = rememberUpdatedState(onPageBackgroundColor)
|
||||||
|
val onCodeState = rememberUpdatedState(onCode)
|
||||||
|
val onErrorState = rememberUpdatedState(onError)
|
||||||
|
val onCancelState = rememberUpdatedState(onCancel)
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
|
||||||
|
|
||||||
|
DisposableEffect(instanceId) {
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"compose enter id=$instanceId darkTheme=$darkTheme lang=$lang " +
|
||||||
|
"savedBundleEmpty=${savedWebViewState.isEmpty} " +
|
||||||
|
"savedBundleSize=${savedWebViewState.size()} " +
|
||||||
|
"lifecycle=${lifecycleOwner.lifecycle.currentState} " +
|
||||||
|
"authorizeUrl=${shortUrl(authorizeUrl)}",
|
||||||
|
)
|
||||||
|
onDispose {
|
||||||
|
Logger.i(LOG_TAG, "compose dispose id=$instanceId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyChromeColor(color: Color) {
|
||||||
|
if (chromeColor == color) return
|
||||||
|
chromeColor = color
|
||||||
|
onPageBackgroundColorState.value(color)
|
||||||
|
webView?.setBackgroundColor(color.toArgb())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCanGoBack(value: Boolean) {
|
||||||
|
if (canGoBack != value) {
|
||||||
|
Logger.d(LOG_TAG, "canGoBack $canGoBack → $value id=$instanceId")
|
||||||
|
canGoBack = value
|
||||||
|
onHistoryBackAvailabilityChangedState.value(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyYandexWebViewSystemBars(
|
||||||
|
chromeColor = chromeColor,
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
restoreSurfaceColor = fallbackColor,
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(pageLoading) {
|
||||||
|
Logger.d(LOG_TAG, "pageLoading=$pageLoading → overlay scheduling id=$instanceId")
|
||||||
|
if (pageLoading) {
|
||||||
|
showLoadingOverlay = true
|
||||||
|
} else {
|
||||||
|
delay(LOADING_HIDE_DELAY_MS)
|
||||||
|
showLoadingOverlay = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(webView, lifecycleOwner) {
|
||||||
|
val wv = webView ?: return@LaunchedEffect
|
||||||
|
// PixelCopy crashes if the window surface is gone (pause/stop). Only sample while resumed.
|
||||||
|
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
|
Logger.d(LOG_TAG, "color sample loop start id=$instanceId")
|
||||||
|
while (isActive) {
|
||||||
|
// Only sample when the document top is visible — not the current scrolled viewport.
|
||||||
|
if (pageScrollY(wv) <= 1.0) {
|
||||||
|
sampleTopRowColor(wv)?.let { applyChromeColor(it) }
|
||||||
|
}
|
||||||
|
delay(TOP_ROW_SAMPLE_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Logger.d(LOG_TAG, "color sample loop paused (not resumed) id=$instanceId")
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(webView, lifecycleOwner) {
|
||||||
|
val wv = webView
|
||||||
|
if (wv == null) {
|
||||||
|
return@DisposableEffect onDispose { }
|
||||||
|
}
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"lifecycle $event id=$instanceId wv=${Integer.toHexString(System.identityHashCode(wv))} " +
|
||||||
|
"url=${shortUrl(wv.url)} progress=${wv.progress} canGoBack=${wv.canGoBack()}",
|
||||||
|
)
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_PAUSE -> wv.onPause()
|
||||||
|
Lifecycle.Event.ON_RESUME -> wv.onResume()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||||
|
wv.onResume()
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
Logger.d(LOG_TAG, "lifecycle observer dispose id=$instanceId")
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
wv.onPause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always consume predictive back here. If NavHost owns the gesture it scales this
|
||||||
|
// screen and WebView jumps scroll-to-top + leaves a gap under the content.
|
||||||
|
PredictiveBackHandler(
|
||||||
|
enabled = true,
|
||||||
|
onProgress = { },
|
||||||
|
onCommit = {
|
||||||
|
val wv = webView
|
||||||
|
if (wv != null && wv.canGoBack()) {
|
||||||
|
wv.goBack()
|
||||||
|
} else {
|
||||||
|
onCancelState.value()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel = { },
|
||||||
|
)
|
||||||
|
|
||||||
|
val client = remember(authorizeUrl, lang, darkTheme) {
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"WebViewClient create id=$instanceId darkTheme=$darkTheme lang=$lang " +
|
||||||
|
"authorizeUrl=${shortUrl(authorizeUrl)}",
|
||||||
|
)
|
||||||
|
object : WebViewClient() {
|
||||||
|
private fun handleSpecialUrl(view: WebView?, url: String?): Boolean {
|
||||||
|
if (url.isNullOrBlank()) return false
|
||||||
|
if (url.startsWith("fromchat://", ignoreCase = true)) {
|
||||||
|
Logger.i(LOG_TAG, "intercept redirect url=${shortUrl(url)} id=$instanceId")
|
||||||
|
val code = extractOAuthCode(url)
|
||||||
|
if (code != null) {
|
||||||
|
onCodeState.value(code)
|
||||||
|
} else {
|
||||||
|
onErrorState.value("")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!isYandexAuthNavigation(url)) {
|
||||||
|
Logger.d(LOG_TAG, "external nav url=${shortUrl(url)} id=$instanceId")
|
||||||
|
view?.context?.let { ctx ->
|
||||||
|
runCatching {
|
||||||
|
ctx.startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPageChrome(view: WebView?) {
|
||||||
|
view ?: return
|
||||||
|
view.evaluateJavascript("$FORCE_COLOR_SCHEME_JS('$scheme');", null)
|
||||||
|
view.evaluateJavascript(DISABLE_USER_SELECT_JS, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
|
val url = request?.url?.toString() ?: return false
|
||||||
|
return handleSpecialUrl(view, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
|
||||||
|
return handleSpecialUrl(view, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"onPageStarted id=$instanceId url=${shortUrl(url)} " +
|
||||||
|
"wv=${view?.let { Integer.toHexString(System.identityHashCode(it)) }} " +
|
||||||
|
"canGoBack=${view?.canGoBack()} stack=${Throwable().stackTraceToString().lineSequence().take(8).joinToString(" ← ")}",
|
||||||
|
)
|
||||||
|
if (url != null && url.startsWith(YANDEX_OAUTH_REDIRECT_URI, ignoreCase = true)) {
|
||||||
|
handleSpecialUrl(view, url)
|
||||||
|
}
|
||||||
|
pageLoading = true
|
||||||
|
applyPageChrome(view)
|
||||||
|
updateCanGoBack(view?.canGoBack() == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"onPageFinished id=$instanceId url=${shortUrl(url)} " +
|
||||||
|
"progress=${view?.progress} canGoBack=${view?.canGoBack()} " +
|
||||||
|
"historySize=${view?.copyBackForwardList()?.size}",
|
||||||
|
)
|
||||||
|
pageLoading = false
|
||||||
|
applyPageChrome(view)
|
||||||
|
updateCanGoBack(view?.canGoBack() == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
|
||||||
|
Logger.d(
|
||||||
|
LOG_TAG,
|
||||||
|
"doUpdateVisitedHistory id=$instanceId isReload=$isReload url=${shortUrl(url)} " +
|
||||||
|
"canGoBack=${view?.canGoBack()}",
|
||||||
|
)
|
||||||
|
updateCanGoBack(view?.canGoBack() == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(chromeColor),
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
val activity = context.findActivity() ?: context
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"AndroidView.factory START id=$instanceId " +
|
||||||
|
"savedEmpty=${savedWebViewState.isEmpty} savedSize=${savedWebViewState.size()} " +
|
||||||
|
"darkTheme=$darkTheme clearCookies=$clearCookies " +
|
||||||
|
"stack=${Throwable().stackTraceToString().lineSequence().drop(1).take(10).joinToString(" ← ")}",
|
||||||
|
)
|
||||||
|
if (clearCookies) {
|
||||||
|
clearYandexWebViewCookies()
|
||||||
|
}
|
||||||
|
seedYandexThemeCookies(darkTheme)
|
||||||
|
WebView(activity).apply {
|
||||||
|
setBackgroundColor(fallbackColor.toArgb())
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
settings.javaScriptCanOpenWindowsAutomatically = true
|
||||||
|
settings.setSupportMultipleWindows(true)
|
||||||
|
applyYandexDarkSettingsIfNeeded(this, darkTheme)
|
||||||
|
webViewClient = client
|
||||||
|
webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onCreateWindow(
|
||||||
|
view: WebView?,
|
||||||
|
isDialog: Boolean,
|
||||||
|
isUserGesture: Boolean,
|
||||||
|
resultMsg: android.os.Message?,
|
||||||
|
): Boolean {
|
||||||
|
val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
|
||||||
|
val temp = WebView(activity).apply {
|
||||||
|
applyYandexDarkSettingsIfNeeded(this, darkTheme)
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldOverrideUrlLoading(
|
||||||
|
v: WebView?,
|
||||||
|
request: WebResourceRequest?,
|
||||||
|
): Boolean {
|
||||||
|
val url = request?.url?.toString() ?: return false
|
||||||
|
if (!isYandexAuthNavigation(url)) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW, Uri.parse(url)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view?.loadUrl(url)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport.webView = temp
|
||||||
|
resultMsg.sendToTarget()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val hadSavedState = savedWebViewState.isEmpty.not()
|
||||||
|
val restoreList = if (hadSavedState) restoreState(savedWebViewState) else null
|
||||||
|
val historySize = copyBackForwardList().size
|
||||||
|
val restored = hadSavedState && restoreList != null && historySize > 0
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"AndroidView.factory restore id=$instanceId hadSaved=$hadSavedState " +
|
||||||
|
"restoreListNull=${restoreList == null} historySize=$historySize " +
|
||||||
|
"restored=$restored wv=${Integer.toHexString(System.identityHashCode(this))}",
|
||||||
|
)
|
||||||
|
if (restored) {
|
||||||
|
pageLoading = false
|
||||||
|
showLoadingOverlay = false
|
||||||
|
updateCanGoBack(canGoBack())
|
||||||
|
} else {
|
||||||
|
savedWebViewState.clear()
|
||||||
|
Logger.w(
|
||||||
|
LOG_TAG,
|
||||||
|
"AndroidView.factory loadUrl (RESET) id=$instanceId url=${shortUrl(authorizeUrl)}",
|
||||||
|
)
|
||||||
|
loadUrl(
|
||||||
|
authorizeUrl,
|
||||||
|
mapOf("Accept-Language" to "$languageTag,$lang;q=0.9,en;q=0.8"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
webView = this
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
update = { wv ->
|
||||||
|
val clientChanged = wv.webViewClient !== client
|
||||||
|
val darkBefore = wv.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
|
||||||
|
applyYandexDarkSettingsIfNeeded(wv, darkTheme)
|
||||||
|
if (clientChanged) {
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"AndroidView.update reassign client id=$instanceId " +
|
||||||
|
"darkBefore=$darkBefore darkTheme=$darkTheme " +
|
||||||
|
"url=${shortUrl(wv.url)}",
|
||||||
|
)
|
||||||
|
wv.webViewClient = client
|
||||||
|
}
|
||||||
|
webView = wv
|
||||||
|
updateCanGoBack(wv.canGoBack())
|
||||||
|
},
|
||||||
|
onRelease = { wv ->
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"AndroidView.onRelease id=$instanceId " +
|
||||||
|
"wv=${Integer.toHexString(System.identityHashCode(wv))} " +
|
||||||
|
"url=${shortUrl(wv.url)} historySize=${wv.copyBackForwardList().size} " +
|
||||||
|
"stack=${Throwable().stackTraceToString().lineSequence().drop(1).take(10).joinToString(" ← ")}",
|
||||||
|
)
|
||||||
|
savedWebViewState.clear()
|
||||||
|
wv.saveState(savedWebViewState)
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"AndroidView.onRelease saved id=$instanceId " +
|
||||||
|
"bundleEmpty=${savedWebViewState.isEmpty} bundleSize=${savedWebViewState.size()}",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showLoadingOverlay,
|
||||||
|
enter = fadeIn(tween(0)),
|
||||||
|
exit = fadeOut(tween(LOADING_FADE_MS)),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(fallbackColor),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
LoadingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ApplyYandexWebViewSystemBars(
|
||||||
|
chromeColor: Color,
|
||||||
|
darkTheme: Boolean,
|
||||||
|
restoreSurfaceColor: Color,
|
||||||
|
) {
|
||||||
|
val view = LocalView.current
|
||||||
|
val lightIcons = chromeColor.luminance() > 0.5f
|
||||||
|
val chromeArgb = chromeColor.toArgb()
|
||||||
|
val restoreArgb = restoreSurfaceColor.toArgb()
|
||||||
|
|
||||||
|
// Match page chrome while OAuth is open…
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as? Activity)?.window ?: return@SideEffect
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
window.isNavigationBarContrastEnforced = false
|
||||||
|
}
|
||||||
|
window.decorView.setBackgroundColor(chromeArgb)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.statusBarColor = chromeArgb
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.navigationBarColor = chromeArgb
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
isAppearanceLightNavigationBars = lightIcons
|
||||||
|
isAppearanceLightStatusBars = lightIcons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// …and restore app theme bars when leaving (SideEffect alone won't re-run if theme deps unchanged).
|
||||||
|
DisposableEffect(darkTheme, restoreArgb) {
|
||||||
|
onDispose {
|
||||||
|
val window = (view.context as? Activity)?.window ?: return@onDispose
|
||||||
|
WindowCompat.getInsetsController(window, view).apply {
|
||||||
|
isAppearanceLightStatusBars = !darkTheme
|
||||||
|
isAppearanceLightNavigationBars = !darkTheme
|
||||||
|
}
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.statusBarColor = AndroidColor.TRANSPARENT
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.navigationBarColor = AndroidColor.TRANSPARENT
|
||||||
|
window.decorView.setBackgroundColor(restoreArgb)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
window.isNavigationBarContrastEnforced = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Context.findActivity(): Activity? {
|
||||||
|
var ctx: Context? = this
|
||||||
|
while (ctx is ContextWrapper) {
|
||||||
|
if (ctx is Activity) return ctx
|
||||||
|
ctx = ctx.baseContext
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearYandexWebViewCookies() {
|
||||||
|
val cookieManager = CookieManager.getInstance()
|
||||||
|
cookieManager.setAcceptCookie(true)
|
||||||
|
cookieManager.removeAllCookies(null)
|
||||||
|
cookieManager.flush()
|
||||||
|
Logger.i(LOG_TAG, "cleared all WebView cookies for Yandex re-auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seedYandexThemeCookies(darkTheme: Boolean) {
|
||||||
|
val theme = if (darkTheme) "dark" else "light"
|
||||||
|
val cookieManager = CookieManager.getInstance()
|
||||||
|
cookieManager.setAcceptCookie(true)
|
||||||
|
val hosts = listOf(
|
||||||
|
"https://yandex.ru",
|
||||||
|
"https://yandex.com",
|
||||||
|
"https://passport.yandex.ru",
|
||||||
|
"https://passport.yandex.com",
|
||||||
|
"https://oauth.yandex.ru",
|
||||||
|
"https://oauth.yandex.com",
|
||||||
|
)
|
||||||
|
for (host in hosts) {
|
||||||
|
cookieManager.setCookie(host, "color_scheme=$theme; path=/")
|
||||||
|
cookieManager.setCookie(host, "theme=$theme; path=/")
|
||||||
|
cookieManager.setCookie(host, "yh=Theme=$theme; path=/")
|
||||||
|
}
|
||||||
|
cookieManager.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies force-dark / algorithmic darkening only when [darkTheme] changed.
|
||||||
|
* Re-applying the same force-dark value can reload the page and wipe in-progress OAuth UI.
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun applyYandexDarkSettingsIfNeeded(webView: WebView, darkTheme: Boolean) {
|
||||||
|
val previous = webView.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
|
||||||
|
if (previous == darkTheme) return
|
||||||
|
Logger.w(
|
||||||
|
LOG_TAG,
|
||||||
|
"applyDarkSettings CHANGE previous=$previous → $darkTheme " +
|
||||||
|
"wv=${Integer.toHexString(System.identityHashCode(webView))} url=${shortUrl(webView.url)}",
|
||||||
|
)
|
||||||
|
webView.setTag(TAG_APPLIED_DARK_THEME, darkTheme)
|
||||||
|
val settings = webView.settings
|
||||||
|
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
|
||||||
|
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, darkTheme)
|
||||||
|
}
|
||||||
|
if (darkTheme && WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
|
||||||
|
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_ON)
|
||||||
|
} else if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
|
||||||
|
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pageScrollY(webView: WebView): Double =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
webView.evaluateJavascript(PAGE_SCROLL_Y_JS) { raw ->
|
||||||
|
val value = raw?.trim()?.removeSurrounding("\"")?.toDoubleOrNull() ?: 0.0
|
||||||
|
cont.resume(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Samples the first visible pixel row of the WebView (mid-x).
|
||||||
|
* Caller must only invoke this when the document is scrolled to the page start
|
||||||
|
* and the activity is resumed (window still has a surface).
|
||||||
|
*/
|
||||||
|
private suspend fun sampleTopRowColor(webView: WebView): Color? {
|
||||||
|
if (webView.width <= 0 || webView.height <= 0) return null
|
||||||
|
val activity = webView.context.findActivity() ?: return null
|
||||||
|
if (activity is androidx.lifecycle.LifecycleOwner &&
|
||||||
|
!activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val window = activity.window ?: return null
|
||||||
|
val decor = window.decorView
|
||||||
|
if (!decor.isAttachedToWindow || decor.width <= 0 || decor.height <= 0) return null
|
||||||
|
val loc = IntArray(2)
|
||||||
|
webView.getLocationInWindow(loc)
|
||||||
|
val x = loc[0] + webView.width / 2
|
||||||
|
val y = loc[1]
|
||||||
|
if (x < 0 || y < 0) return null
|
||||||
|
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
|
||||||
|
val src = Rect(x, y, x + 1, y + 1)
|
||||||
|
return suspendCancellableCoroutine { cont ->
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
bitmap.recycle()
|
||||||
|
cont.resume(null)
|
||||||
|
return@suspendCancellableCoroutine
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
PixelCopy.request(
|
||||||
|
window,
|
||||||
|
src,
|
||||||
|
bitmap,
|
||||||
|
{ result ->
|
||||||
|
if (result == PixelCopy.SUCCESS) {
|
||||||
|
val pixel = bitmap.getPixel(0, 0)
|
||||||
|
cont.resume(Color(pixel))
|
||||||
|
} else {
|
||||||
|
cont.resume(null)
|
||||||
|
}
|
||||||
|
bitmap.recycle()
|
||||||
|
},
|
||||||
|
Handler(Looper.getMainLooper()),
|
||||||
|
)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// e.g. "Window doesn't have a backing surface!" after ON_PAUSE/ON_STOP
|
||||||
|
Logger.w(LOG_TAG, "PixelCopy skipped: ${e.message}")
|
||||||
|
bitmap.recycle()
|
||||||
|
cont.resume(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep Yandex ID / OAuth / captcha flows in the WebView; open everything else externally.
|
||||||
|
*/
|
||||||
|
internal fun isYandexAuthNavigation(url: String): Boolean {
|
||||||
|
if (url.startsWith("fromchat://", ignoreCase = true)) return true
|
||||||
|
val uri = Uri.parse(url)
|
||||||
|
val host = uri.host?.lowercase() ?: return false
|
||||||
|
val path = uri.path.orEmpty().lowercase()
|
||||||
|
|
||||||
|
if (host == "yandex.ru" || host == "www.yandex.ru" || host == "ya.ru" || host == "www.ya.ru") {
|
||||||
|
return path.contains("captcha") ||
|
||||||
|
path.startsWith("/auth") ||
|
||||||
|
path.startsWith("/showcaptcha") ||
|
||||||
|
path.startsWith("/checkcaptcha")
|
||||||
|
}
|
||||||
|
|
||||||
|
return host == "oauth.yandex.com" ||
|
||||||
|
host == "oauth.yandex.ru" ||
|
||||||
|
host.endsWith(".oauth.yandex.com") ||
|
||||||
|
host.endsWith(".oauth.yandex.ru") ||
|
||||||
|
host == "passport.yandex.ru" ||
|
||||||
|
host == "passport.yandex.com" ||
|
||||||
|
host.endsWith(".passport.yandex.ru") ||
|
||||||
|
host.endsWith(".passport.yandex.com") ||
|
||||||
|
host.startsWith("auth.yandex.") ||
|
||||||
|
host.startsWith("login.yandex.") ||
|
||||||
|
host.startsWith("id.yandex.") ||
|
||||||
|
host == "sso.passport.yandex.ru" ||
|
||||||
|
host == "captcha.yandex.net" ||
|
||||||
|
host.endsWith(".captcha.yandex.net") ||
|
||||||
|
(host.contains("captcha") && host.contains("yandex"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<!-- Yandex “Я” mark only; circular brand background removed for tintable icons. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M13.32,7.666h-0.924c-1.694,0 -2.585,0.858 -2.585,2.123c0,1.43 0.616,2.1 1.881,2.959l1.045,0.704l-3.003,4.487H7.49l2.695,-4.014c-1.55,-1.111 -2.42,-2.19 -2.42,-4.015c0,-2.288 1.595,-3.85 4.62,-3.85h3.003v11.868H13.32V7.666z" />
|
||||||
|
</vector>
|
||||||
@@ -51,6 +51,12 @@
|
|||||||
<string name="auth_step_password_body">Мы войдём в аккаунт или создадим новый.</string>
|
<string name="auth_step_password_body">Мы войдём в аккаунт или создадим новый.</string>
|
||||||
<string name="auth_step_confirm_title">Подтвердите пароль</string>
|
<string name="auth_step_confirm_title">Подтвердите пароль</string>
|
||||||
<string name="auth_step_confirm_body">Введите тот же пароль ещё раз.</string>
|
<string name="auth_step_confirm_body">Введите тот же пароль ещё раз.</string>
|
||||||
|
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
|
||||||
|
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.</string>
|
||||||
|
<string name="auth_step_yandex_cta">Продолжить через Яндекс ID</string>
|
||||||
|
<string name="auth_yandex_webview_title">Яндекс ID</string>
|
||||||
|
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
|
||||||
|
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
||||||
<string name="chats">Чаты</string>
|
<string name="chats">Чаты</string>
|
||||||
<string name="contacts">Контакты</string>
|
<string name="contacts">Контакты</string>
|
||||||
<string name="profile">Профиль</string>
|
<string name="profile">Профиль</string>
|
||||||
@@ -180,10 +186,28 @@
|
|||||||
<string name="profile_headline_member_since">Дата регистрации</string>
|
<string name="profile_headline_member_since">Дата регистрации</string>
|
||||||
<string name="profile_headline_bio">О себе</string>
|
<string name="profile_headline_bio">О себе</string>
|
||||||
<string name="profile_headline_verification">Официальный аккаунт</string>
|
<string name="profile_headline_verification">Официальный аккаунт</string>
|
||||||
<string name="profile_verified_support">Это официальный аккаунт</string>
|
<string name="profile_verified_support">Этот аккаунт - официальное лицо FromChat.</string>
|
||||||
<string name="profile_verify_prompt_support">Нажмите, чтобы сделать аккаунт официальным (только админы)</string>
|
<string name="profile_admin_actions_category">Действия администратора</string>
|
||||||
<string name="verify">Сделать официальным</string>
|
<string name="profile_admin_verify">Подтвердить</string>
|
||||||
<string name="unverify">Снять официальный статус</string>
|
<string name="profile_admin_unverify">Отменить подтверждение</string>
|
||||||
|
<string name="profile_admin_suspend">Заблокировать аккаунт</string>
|
||||||
|
<string name="profile_admin_unsuspend">Разблокировать аккаунт</string>
|
||||||
|
<string name="profile_admin_delete">Удалить аккаунт</string>
|
||||||
|
<string name="profile_admin_verify_confirm_title">Подтвердить аккаунт?</string>
|
||||||
|
<string name="profile_admin_verify_confirm_body">Отметить этот аккаунт как официальное лицо FromChat?</string>
|
||||||
|
<string name="profile_admin_unverify_confirm_title">Снять подтверждение?</string>
|
||||||
|
<string name="profile_admin_unverify_confirm_body">Убрать официальное подтверждение с этого аккаунта?</string>
|
||||||
|
<string name="profile_admin_suspend_confirm_title">Заблокировать аккаунт</string>
|
||||||
|
<string name="profile_admin_suspend_confirm_body">Укажите причину блокировки этого аккаунта:</string>
|
||||||
|
<string name="profile_admin_suspend_reason_label">Причина</string>
|
||||||
|
<string name="profile_admin_suspend_confirm">Заблокировать</string>
|
||||||
|
<string name="profile_admin_unsuspend_confirm_title">Разблокировать аккаунт?</string>
|
||||||
|
<string name="profile_admin_unsuspend_confirm_body">Вернуть этому аккаунту возможность отправлять сообщения?</string>
|
||||||
|
<string name="profile_admin_delete_confirm_title">Удалить аккаунт</string>
|
||||||
|
<string name="profile_admin_delete_confirm_body">Данные пользователя будут удалены навсегда, но сообщения и переписки сохранятся. Если пользователь в сети, он будет сразу разлогинен. Это действие нельзя отменить.</string>
|
||||||
|
<string name="profile_admin_delete_confirm">Удалить</string>
|
||||||
|
<string name="verify">Подтвердить</string>
|
||||||
|
<string name="unverify">Отменить подтверждение</string>
|
||||||
<string name="cd_verified_account">Официальный аккаунт</string>
|
<string name="cd_verified_account">Официальный аккаунт</string>
|
||||||
<string name="cd_account_blocked">Аккаунт заблокирован</string>
|
<string name="cd_account_blocked">Аккаунт заблокирован</string>
|
||||||
<string name="cd_similar_verified">Похож на официальный аккаунт</string>
|
<string name="cd_similar_verified">Похож на официальный аккаунт</string>
|
||||||
@@ -400,6 +424,14 @@
|
|||||||
<string name="settings_account_logout_confirm_body">Придётся войти снова.</string>
|
<string name="settings_account_logout_confirm_body">Придётся войти снова.</string>
|
||||||
<string name="settings_account_delete">Удалить аккаунт</string>
|
<string name="settings_account_delete">Удалить аккаунт</string>
|
||||||
<string name="settings_account_delete_d">Безвозвратно удалить аккаунт и данные</string>
|
<string name="settings_account_delete_d">Безвозвратно удалить аккаунт и данные</string>
|
||||||
|
<string name="settings_account_change_yandex">Сменить Яндекс ID</string>
|
||||||
|
<string name="settings_account_change_yandex_d">Привязать другой аккаунт Яндекса</string>
|
||||||
|
<string name="settings_yandex_step_confirm_title">Сменить Яндекс ID?</string>
|
||||||
|
<string name="settings_yandex_step_confirm_body">Это не удаляет ваш аккаунт FromChat. Предыдущий Яндекс ID будет освобождён, а вместо него привяжется тот, с которым вы войдёте.</string>
|
||||||
|
<string name="settings_yandex_step_confirm_cta">Продолжить</string>
|
||||||
|
<string name="settings_yandex_step_done_title">Яндекс ID обновлён</string>
|
||||||
|
<string name="settings_yandex_step_done_body">Аккаунт теперь привязан к Яндекс ID, с которым вы только что вошли.</string>
|
||||||
|
<string name="settings_done">Готово</string>
|
||||||
<string name="settings_account_delete_confirm_title">Удалить аккаунт?</string>
|
<string name="settings_account_delete_confirm_title">Удалить аккаунт?</string>
|
||||||
<string name="settings_account_delete_confirm_body">Это нельзя отменить.</string>
|
<string name="settings_account_delete_confirm_body">Это нельзя отменить.</string>
|
||||||
<string name="settings_account_deleted">Аккаунт удалён</string>
|
<string name="settings_account_deleted">Аккаунт удалён</string>
|
||||||
|
|||||||
@@ -58,6 +58,12 @@
|
|||||||
<string name="auth_step_password_body">We will sign you in or create a new account.</string>
|
<string name="auth_step_password_body">We will sign you in or create a new account.</string>
|
||||||
<string name="auth_step_confirm_title">Confirm your password</string>
|
<string name="auth_step_confirm_title">Confirm your password</string>
|
||||||
<string name="auth_step_confirm_body">Enter the same password again.</string>
|
<string name="auth_step_confirm_body">Enter the same password again.</string>
|
||||||
|
<string name="auth_step_yandex_title">Sign in with Yandex ID</string>
|
||||||
|
<string name="auth_step_yandex_body">This helps us fight malicious bots and meet Russian legal requirements. From Yandex we only get your email — and we don’t store it; sign-in is only used for security reasons.</string>
|
||||||
|
<string name="auth_step_yandex_cta">Continue with Yandex ID</string>
|
||||||
|
<string name="auth_yandex_webview_title">Yandex ID</string>
|
||||||
|
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
|
||||||
|
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
||||||
<!-- Main Screen -->
|
<!-- Main Screen -->
|
||||||
<string name="chats">Chats</string>
|
<string name="chats">Chats</string>
|
||||||
<string name="contacts">Contacts</string>
|
<string name="contacts">Contacts</string>
|
||||||
@@ -198,8 +204,28 @@
|
|||||||
<string name="profile_headline_member_since">Joined</string>
|
<string name="profile_headline_member_since">Joined</string>
|
||||||
<string name="profile_headline_bio">About</string>
|
<string name="profile_headline_bio">About</string>
|
||||||
<string name="profile_headline_verification">Verified account</string>
|
<string name="profile_headline_verification">Verified account</string>
|
||||||
<string name="profile_verified_support">This account is verified</string>
|
<string name="profile_verified_support">This account is an official FromChat representative.</string>
|
||||||
<string name="profile_verify_prompt_support">Tap to verify (admins only)</string>
|
|
||||||
|
<!-- Admin actions (owner / user id 1) -->
|
||||||
|
<string name="profile_admin_actions_category">Admin actions</string>
|
||||||
|
<string name="profile_admin_verify">Verify</string>
|
||||||
|
<string name="profile_admin_unverify">Remove verification</string>
|
||||||
|
<string name="profile_admin_suspend">Suspend account</string>
|
||||||
|
<string name="profile_admin_unsuspend">Unsuspend account</string>
|
||||||
|
<string name="profile_admin_delete">Delete account</string>
|
||||||
|
<string name="profile_admin_verify_confirm_title">Verify account?</string>
|
||||||
|
<string name="profile_admin_verify_confirm_body">Mark this account as an official FromChat representative?</string>
|
||||||
|
<string name="profile_admin_unverify_confirm_title">Remove verification?</string>
|
||||||
|
<string name="profile_admin_unverify_confirm_body">Remove official verification from this account?</string>
|
||||||
|
<string name="profile_admin_suspend_confirm_title">Suspend account</string>
|
||||||
|
<string name="profile_admin_suspend_confirm_body">Enter the reason for suspending this account:</string>
|
||||||
|
<string name="profile_admin_suspend_reason_label">Reason</string>
|
||||||
|
<string name="profile_admin_suspend_confirm">Suspend</string>
|
||||||
|
<string name="profile_admin_unsuspend_confirm_title">Unsuspend account?</string>
|
||||||
|
<string name="profile_admin_unsuspend_confirm_body">Restore this account’s ability to send messages?</string>
|
||||||
|
<string name="profile_admin_delete_confirm_title">Delete account</string>
|
||||||
|
<string name="profile_admin_delete_confirm_body">This will permanently delete user data but preserve messages and conversations. If the user is online, they will be immediately logged out. This action cannot be undone.</string>
|
||||||
|
<string name="profile_admin_delete_confirm">Delete</string>
|
||||||
|
|
||||||
<!-- Verify (admin) -->
|
<!-- Verify (admin) -->
|
||||||
<string name="verify">Verify</string>
|
<string name="verify">Verify</string>
|
||||||
@@ -426,6 +452,14 @@
|
|||||||
<string name="settings_account_logout_confirm_body">You will need to sign in again.</string>
|
<string name="settings_account_logout_confirm_body">You will need to sign in again.</string>
|
||||||
<string name="settings_account_delete">Delete account</string>
|
<string name="settings_account_delete">Delete account</string>
|
||||||
<string name="settings_account_delete_d">Permanently delete your account and data</string>
|
<string name="settings_account_delete_d">Permanently delete your account and data</string>
|
||||||
|
<string name="settings_account_change_yandex">Change Yandex ID</string>
|
||||||
|
<string name="settings_account_change_yandex_d">Link a different Yandex account</string>
|
||||||
|
<string name="settings_yandex_step_confirm_title">Change Yandex ID?</string>
|
||||||
|
<string name="settings_yandex_step_confirm_body">This does not delete your FromChat account. Your previous Yandex ID will be freed, and the new one you sign in with will be linked instead.</string>
|
||||||
|
<string name="settings_yandex_step_confirm_cta">Continue</string>
|
||||||
|
<string name="settings_yandex_step_done_title">Yandex ID updated</string>
|
||||||
|
<string name="settings_yandex_step_done_body">Your account is now linked to the Yandex ID you just signed in with.</string>
|
||||||
|
<string name="settings_done">Done</string>
|
||||||
<string name="settings_account_delete_confirm_title">Delete account?</string>
|
<string name="settings_account_delete_confirm_title">Delete account?</string>
|
||||||
<string name="settings_account_delete_confirm_body">This cannot be undone.</string>
|
<string name="settings_account_delete_confirm_body">This cannot be undone.</string>
|
||||||
<string name="settings_account_deleted">Account deleted</string>
|
<string name="settings_account_deleted">Account deleted</string>
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import ru.fromchat.api.ApiClient.logout
|
import ru.fromchat.api.ApiClient.logout
|
||||||
import ru.fromchat.api.ApiClient.persistSessionToStorage
|
import ru.fromchat.api.ApiClient.persistSessionToStorage
|
||||||
import ru.fromchat.api.crypto.IdentityKeyManager
|
import ru.fromchat.api.crypto.IdentityKeyManager
|
||||||
@@ -68,6 +73,7 @@ import ru.fromchat.api.schema.calls.LiveKitTokenResponse
|
|||||||
import ru.fromchat.api.schema.core.SimpleStatusResponse
|
import ru.fromchat.api.schema.core.SimpleStatusResponse
|
||||||
import ru.fromchat.api.schema.messages.MarkReadRequest
|
import ru.fromchat.api.schema.messages.MarkReadRequest
|
||||||
import ru.fromchat.api.schema.messages.MessagesResponse
|
import ru.fromchat.api.schema.messages.MessagesResponse
|
||||||
|
import ru.fromchat.api.schema.messages.dm.DmArchiveRequest
|
||||||
import ru.fromchat.api.schema.messages.dm.DmConversation
|
import ru.fromchat.api.schema.messages.dm.DmConversation
|
||||||
import ru.fromchat.api.schema.messages.dm.DmConversationsResponse
|
import ru.fromchat.api.schema.messages.dm.DmConversationsResponse
|
||||||
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
|
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
|
||||||
@@ -98,16 +104,27 @@ import ru.fromchat.api.schema.user.FcmTokenRequest
|
|||||||
import ru.fromchat.api.schema.user.User
|
import ru.fromchat.api.schema.user.User
|
||||||
import ru.fromchat.api.schema.user.UsersSearchResponse
|
import ru.fromchat.api.schema.user.UsersSearchResponse
|
||||||
import ru.fromchat.api.schema.user.VerifyPasswordRequest
|
import ru.fromchat.api.schema.user.VerifyPasswordRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.AuthPasswordStepRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.AuthUsernameStepRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.AuthUsernameStepResponse
|
||||||
|
import ru.fromchat.api.schema.user.auth.AccountYandexResponse
|
||||||
|
import ru.fromchat.api.schema.user.auth.ChangeYandexRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.ChangeYandexResponse
|
||||||
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
|
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
|
||||||
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
|
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
|
||||||
import ru.fromchat.api.schema.user.auth.LoginRequest
|
import ru.fromchat.api.schema.user.auth.LoginRequest
|
||||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||||
|
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
||||||
import ru.fromchat.api.schema.user.auth.RegisterRequest
|
import ru.fromchat.api.schema.user.auth.RegisterRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
|
||||||
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
import ru.fromchat.api.schema.user.devices.DeviceSessionInfo
|
import ru.fromchat.api.schema.user.devices.DeviceSessionInfo
|
||||||
import ru.fromchat.api.schema.user.devices.DevicesListResponse
|
import ru.fromchat.api.schema.user.devices.DevicesListResponse
|
||||||
import ru.fromchat.api.schema.user.keys.BackupBlobRequest
|
import ru.fromchat.api.schema.user.keys.BackupBlobRequest
|
||||||
import ru.fromchat.api.schema.user.keys.BackupBlobResponse
|
import ru.fromchat.api.schema.user.keys.BackupBlobResponse
|
||||||
import ru.fromchat.api.schema.user.keys.PublicKeyResponse
|
import ru.fromchat.api.schema.user.keys.PublicKeyResponse
|
||||||
|
import ru.fromchat.api.schema.user.profile.SuspendUserRequest
|
||||||
import ru.fromchat.api.schema.user.profile.UpdateProfileRequest
|
import ru.fromchat.api.schema.user.profile.UpdateProfileRequest
|
||||||
import ru.fromchat.api.schema.user.profile.UpdateProfileResponse
|
import ru.fromchat.api.schema.user.profile.UpdateProfileResponse
|
||||||
import ru.fromchat.api.schema.user.profile.UserProfile
|
import ru.fromchat.api.schema.user.profile.UserProfile
|
||||||
@@ -483,6 +500,60 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
.body()
|
.body()
|
||||||
|
|
||||||
|
suspend fun authUsernameStep(username: String): AuthUsernameStepResponse =
|
||||||
|
httpProbe
|
||||||
|
.post("${ServerConfig.apiBaseUrl}/auth/steps/username") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(AuthUsernameStepRequest(username = username.trim()))
|
||||||
|
}
|
||||||
|
.body()
|
||||||
|
|
||||||
|
sealed interface AuthPasswordStepOutcome {
|
||||||
|
data class LoggedIn(val response: LoginResponse) : AuthPasswordStepOutcome
|
||||||
|
data class NeedsRegister(
|
||||||
|
val yandexRequired: Boolean,
|
||||||
|
val yandex: YandexOAuthParams?,
|
||||||
|
) : AuthPasswordStepOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun authPasswordStep(username: String, passwordDerived: String): AuthPasswordStepOutcome {
|
||||||
|
val raw = httpProbe
|
||||||
|
.post("${ServerConfig.apiBaseUrl}/auth/steps/password") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(
|
||||||
|
AuthPasswordStepRequest(
|
||||||
|
username = username.trim(),
|
||||||
|
password = passwordDerived,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.body<JsonObject>()
|
||||||
|
val status = raw["status"]?.jsonPrimitive?.contentOrNull
|
||||||
|
return when (status) {
|
||||||
|
"needs_register" -> AuthPasswordStepOutcome.NeedsRegister(
|
||||||
|
yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true,
|
||||||
|
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
|
||||||
|
)
|
||||||
|
else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun authYandexExchange(code: String, codeVerifier: String): YandexExchangeResponse =
|
||||||
|
httpProbe
|
||||||
|
.post("${ServerConfig.apiBaseUrl}/auth/yandex/exchange") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(YandexExchangeRequest(code = code, code_verifier = codeVerifier))
|
||||||
|
}
|
||||||
|
.body()
|
||||||
|
|
||||||
|
suspend fun authRegisterConfirm(request: RegisterConfirmRequest): LoginResponse =
|
||||||
|
httpProbe
|
||||||
|
.post("${ServerConfig.apiBaseUrl}/auth/steps/register/confirm") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}
|
||||||
|
.body()
|
||||||
|
|
||||||
/** Public pre-auth lookup; uses probe client so an existing session token is not sent. */
|
/** Public pre-auth lookup; uses probe client so an existing session token is not sent. */
|
||||||
suspend fun checkUsername(username: String): CheckUsernameResponse =
|
suspend fun checkUsername(username: String): CheckUsernameResponse =
|
||||||
httpProbe
|
httpProbe
|
||||||
@@ -557,12 +628,18 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
.body()
|
.body()
|
||||||
|
|
||||||
suspend fun getProfileById(userId: Int): UserProfile =
|
suspend fun getProfileById(userId: Int, force: Boolean = false): UserProfile {
|
||||||
http
|
if (!force) {
|
||||||
|
ProfileCache.get(userId)?.takeIf {
|
||||||
|
!it.isClientPreviewOnly && ProfileCache.hasFreshFullProfile(userId)
|
||||||
|
}?.let { return it }
|
||||||
|
}
|
||||||
|
return http
|
||||||
.get("${ServerConfig.apiBaseUrl}/user/id/$userId") {
|
.get("${ServerConfig.apiBaseUrl}/user/id/$userId") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
}
|
}
|
||||||
.body()
|
.body()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getProfileByUsername(username: String): UserProfile =
|
suspend fun getProfileByUsername(username: String): UserProfile =
|
||||||
http
|
http
|
||||||
@@ -637,6 +714,34 @@ object ApiClient {
|
|||||||
.body<VerifyResponse>()
|
.body<VerifyResponse>()
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
suspend fun suspendUser(userId: Int, reason: String): SimpleStatusResponse? =
|
||||||
|
runCatching {
|
||||||
|
http
|
||||||
|
.post("${ServerConfig.apiBaseUrl}/user/$userId/suspend") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(SuspendUserRequest(reason = reason))
|
||||||
|
}
|
||||||
|
.body<SimpleStatusResponse>()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
suspend fun unsuspendUser(userId: Int): SimpleStatusResponse? =
|
||||||
|
runCatching {
|
||||||
|
http
|
||||||
|
.post("${ServerConfig.apiBaseUrl}/user/$userId/unsuspend") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
.body<SimpleStatusResponse>()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
suspend fun adminDeleteUser(userId: Int): SimpleStatusResponse? =
|
||||||
|
runCatching {
|
||||||
|
http
|
||||||
|
.post("${ServerConfig.apiBaseUrl}/user/$userId/delete") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
.body<SimpleStatusResponse>()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
suspend fun getDmConversations(): List<DmConversation> =
|
suspend fun getDmConversations(): List<DmConversation> =
|
||||||
http
|
http
|
||||||
.get("${ServerConfig.apiBaseUrl}/dm/conversations") {
|
.get("${ServerConfig.apiBaseUrl}/dm/conversations") {
|
||||||
@@ -652,6 +757,13 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun archiveDmConversation(otherUserId: Int, archived: Boolean = true) {
|
||||||
|
http.post("${ServerConfig.apiBaseUrl}/dm/conversations/$otherUserId/archive") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(DmArchiveRequest(archived = archived))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun searchUsers(query: String): List<User> {
|
suspend fun searchUsers(query: String): List<User> {
|
||||||
val trimmed = query.trim()
|
val trimmed = query.trim()
|
||||||
if (trimmed.length < 2) return emptyList()
|
if (trimmed.length < 2) return emptyList()
|
||||||
@@ -1435,6 +1547,19 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getAccountYandex(): AccountYandexResponse =
|
||||||
|
http
|
||||||
|
.get("${ServerConfig.apiBaseUrl}/account/yandex")
|
||||||
|
.body()
|
||||||
|
|
||||||
|
suspend fun changeAccountYandex(registrationProof: String): ChangeYandexResponse =
|
||||||
|
http
|
||||||
|
.post("${ServerConfig.apiBaseUrl}/account/yandex") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(ChangeYandexRequest(registration_proof = registrationProof))
|
||||||
|
}
|
||||||
|
.body()
|
||||||
|
|
||||||
suspend fun verifyPasswordDerived(passwordDerived: String) {
|
suspend fun verifyPasswordDerived(passwordDerived: String) {
|
||||||
http.post("${ServerConfig.apiBaseUrl}/verify-password") {
|
http.post("${ServerConfig.apiBaseUrl}/verify-password") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
@@ -1589,9 +1714,7 @@ object ApiClient {
|
|||||||
|
|
||||||
suspend fun deleteMessage(messageId: Int) {
|
suspend fun deleteMessage(messageId: Int) {
|
||||||
if (_suspensionState.value.isSuspended) return
|
if (_suspensionState.value.isSuspended) return
|
||||||
runCatching {
|
// WS-only (like Web): HTTP-first would hard-delete before WS and skip messageDeleted broadcast.
|
||||||
http.delete("${ServerConfig.apiBaseUrl}/delete_message/$messageId")
|
|
||||||
}
|
|
||||||
WebSocketManager.send(
|
WebSocketManager.send(
|
||||||
WebSocketMessage(
|
WebSocketMessage(
|
||||||
type = "deleteMessage",
|
type = "deleteMessage",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import ru.fromchat.api.local.WebSocketManager
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
||||||
@@ -16,14 +15,14 @@ import ru.fromchat.api.local.db.store.ConnectionStatus
|
|||||||
import ru.fromchat.api.local.db.store.MessageCacheStore
|
import ru.fromchat.api.local.db.store.MessageCacheStore
|
||||||
import ru.fromchat.api.local.db.store.MessageRepository
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.local.messages.PublicInboxCoordinator
|
||||||
import ru.fromchat.api.local.messages.parseMessageTimestampMillis
|
import ru.fromchat.api.local.messages.parseMessageTimestampMillis
|
||||||
import ru.fromchat.api.schema.messages.Message
|
|
||||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps the chats tab list in sync: DM conversations from the server and the latest public-chat
|
* Keeps the chats tab list in sync: DM conversations from the server and public-chat
|
||||||
* message for list previews (without opening each chat first).
|
* previews (cache is filled by the updates pipeline / history rebuild).
|
||||||
*/
|
*/
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
object ChatListSync {
|
object ChatListSync {
|
||||||
@@ -58,7 +57,14 @@ object ChatListSync {
|
|||||||
suspend fun syncFromNetwork() {
|
suspend fun syncFromNetwork() {
|
||||||
if (!canSync()) return
|
if (!canSync()) return
|
||||||
syncDmConversations()
|
syncDmConversations()
|
||||||
syncPublicChatPreview()
|
// Preview only — never the sole catch-up path for missed messages.
|
||||||
|
refreshPublicChatPreviewFromLatest()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Used by tooLong rebuild before per-chat history fetch. */
|
||||||
|
suspend fun syncDmConversationsForRebuild() {
|
||||||
|
if (!canSync()) return
|
||||||
|
syncDmConversations()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun canSync(): Boolean {
|
private fun canSync(): Boolean {
|
||||||
@@ -78,12 +84,13 @@ object ChatListSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncPublicChatPreview() {
|
private suspend fun refreshPublicChatPreviewFromLatest() {
|
||||||
runCatching {
|
runCatching {
|
||||||
val response = ApiClient.getMessages(limit = 1)
|
val response = ApiClient.getMessages(limit = 1)
|
||||||
val latest = response.messages.maxByOrNull { message ->
|
val latest = response.messages.maxByOrNull { message ->
|
||||||
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
|
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
|
||||||
} ?: return@runCatching
|
} ?: return@runCatching
|
||||||
|
// Upsert only — does not wipe older cached messages.
|
||||||
MessageRepository.upsertPublicMessage(latest)
|
MessageRepository.upsertPublicMessage(latest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,16 +107,14 @@ object ChatListSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"newMessage" -> message.data?.let { element ->
|
"newMessage" -> message.data?.let { element ->
|
||||||
scope.launch { ingestPublicMessage(element) }
|
scope.launch { PublicInboxCoordinator.processNew(element) }
|
||||||
|
}
|
||||||
|
"messageEdited" -> message.data?.let { element ->
|
||||||
|
scope.launch { PublicInboxCoordinator.processEdited(element) }
|
||||||
|
}
|
||||||
|
"messageDeleted" -> message.data?.let { element ->
|
||||||
|
scope.launch { PublicInboxCoordinator.processDeleted(element) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ingestPublicMessage(element: JsonElement) {
|
|
||||||
val newMsg = runCatching {
|
|
||||||
ApiClient.json.decodeFromJsonElement(Message.serializer(), element)
|
|
||||||
}.getOrNull() ?: return
|
|
||||||
ProfileCache.mergePreviewFromPublicMessage(newMsg)
|
|
||||||
MessageRepository.upsertPublicMessage(newMsg)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,40 @@
|
|||||||
package ru.fromchat.api
|
package ru.fromchat.api
|
||||||
|
|
||||||
import com.pr0gramm3r101.utils.settings.settings
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.local.WebSocketManager
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
import ru.fromchat.api.local.db.store.ConnectionStateStore
|
||||||
|
import ru.fromchat.api.local.db.store.MessageCacheStore
|
||||||
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.local.messages.DmInboundMessageProcessor
|
||||||
|
import ru.fromchat.api.local.messages.UpdatesBatchApplier
|
||||||
|
import ru.fromchat.api.local.messages.parseMessageTimestampMillis
|
||||||
|
import ru.fromchat.api.schema.messages.Message
|
||||||
|
import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
||||||
import ru.fromchat.api.schema.websocket.WebSocketCredentials
|
import ru.fromchat.api.schema.websocket.WebSocketCredentials
|
||||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
|
import ru.fromchat.api.schema.websocket.requests.AckUpdatesRequest
|
||||||
import ru.fromchat.api.schema.websocket.requests.GetUpdatesRequest
|
import ru.fromchat.api.schema.websocket.requests.GetUpdatesRequest
|
||||||
import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse
|
import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks the last seen WebSocket update sequence for the current user and
|
* Per-device update cursor: advance + ack only after batches are applied successfully.
|
||||||
* persists it between sessions so we can ask the backend for missed updates.
|
|
||||||
*/
|
*/
|
||||||
object UpdateSyncManager {
|
object UpdateSyncManager {
|
||||||
private const val KEY_UPDATES_LAST_SEQ_PREFIX = "updates_last_seq_user_"
|
private const val KEY_UPDATES_LAST_SEQ_PREFIX = "updates_last_seq_user_"
|
||||||
|
private const val HISTORY_PAGE_SIZE = 50
|
||||||
|
private const val MAX_HISTORY_PAGES = 20
|
||||||
|
|
||||||
private val _lastSeq = MutableStateFlow(0)
|
private val _lastSeq = MutableStateFlow(0)
|
||||||
val lastSeq: StateFlow<Int> = _lastSeq.asStateFlow()
|
val lastSeq: StateFlow<Int> = _lastSeq.asStateFlow()
|
||||||
@@ -30,6 +42,8 @@ object UpdateSyncManager {
|
|||||||
private val _lastMissedCount = MutableStateFlow<Int?>(null)
|
private val _lastMissedCount = MutableStateFlow<Int?>(null)
|
||||||
val lastMissedCount: StateFlow<Int?> = _lastMissedCount.asStateFlow()
|
val lastMissedCount: StateFlow<Int?> = _lastMissedCount.asStateFlow()
|
||||||
|
|
||||||
|
private val applyMutex = Mutex()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var gapDetectionInProgress: Boolean = false
|
private var gapDetectionInProgress: Boolean = false
|
||||||
|
|
||||||
@@ -42,22 +56,15 @@ object UpdateSyncManager {
|
|||||||
ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null)
|
ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
/**
|
||||||
fun onUpdatesBatch(seq: Int) {
|
* Apply a live or replayed updates envelope, then advance cursor and ack the server.
|
||||||
if (seq <= 0) return
|
*/
|
||||||
val currentUserId = ApiClient.user?.id ?: return
|
suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
|
||||||
val newSeq = seq.coerceAtLeast(_lastSeq.value)
|
applyMutex.withLock {
|
||||||
if (newSeq == _lastSeq.value) return
|
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: return@withLock
|
||||||
|
if (seq > _lastSeq.value) {
|
||||||
_lastSeq.value = newSeq
|
persistLastSeq(seq)
|
||||||
ConnectionStateStore.updateSeqAndMissed(lastSeq = newSeq, missedCount = _lastMissedCount.value)
|
sendAck(seq)
|
||||||
|
|
||||||
val key = KEY_UPDATES_LAST_SEQ_PREFIX + currentUserId
|
|
||||||
GlobalScope.launch(Dispatchers.Default) {
|
|
||||||
runCatching {
|
|
||||||
settings.putInt(key, newSeq)
|
|
||||||
}.onFailure {
|
|
||||||
Logger.w("UpdateSyncManager", "Failed to persist lastSeq=$newSeq for userId=$currentUserId: ${it.message}", it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,9 +88,8 @@ object UpdateSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the backend for missed updates between our lastSeq and the current sequence.
|
* Catch up from [lastSeq]: chunked getUpdates, or tooLong → history rebuild.
|
||||||
* This call is idempotent while in progress and will no-op if there is no active
|
* Does not advance the cursor until apply/rebuild succeeds.
|
||||||
* WebSocket session or no authenticated user.
|
|
||||||
*/
|
*/
|
||||||
suspend fun runGapDetectionIfNeeded() {
|
suspend fun runGapDetectionIfNeeded() {
|
||||||
if (gapDetectionInProgress) {
|
if (gapDetectionInProgress) {
|
||||||
@@ -98,52 +104,158 @@ object UpdateSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gapDetectionInProgress = true
|
gapDetectionInProgress = true
|
||||||
val startSeq = _lastSeq.value
|
ConnectionStateStore.onUpdating(start = true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Logger.i("UpdateSyncManager", "Running gap detection from lastSeq=$startSeq")
|
var rounds = 0
|
||||||
if (startSeq > 0) {
|
while (rounds < 100) {
|
||||||
ConnectionStateStore.onUpdating(start = true)
|
rounds++
|
||||||
}
|
val startSeq = _lastSeq.value
|
||||||
|
Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)")
|
||||||
|
|
||||||
val requestMessage = WebSocketMessage(
|
val response = requestGetUpdates(token, startSeq) ?: break
|
||||||
type = "getUpdates",
|
|
||||||
credentials = WebSocketCredentials(
|
|
||||||
scheme = "Bearer",
|
|
||||||
credentials = token
|
|
||||||
),
|
|
||||||
data = ApiClient.json.encodeToJsonElement(
|
|
||||||
GetUpdatesRequest.serializer(),
|
|
||||||
GetUpdatesRequest(lastSeq = startSeq)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val response = WebSocketManager.request(requestMessage)
|
|
||||||
val data = response?.data
|
|
||||||
|
|
||||||
if (data != null) {
|
|
||||||
runCatching {
|
|
||||||
val parsed = ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
|
|
||||||
Logger.i(
|
Logger.i(
|
||||||
"UpdateSyncManager",
|
"UpdateSyncManager",
|
||||||
"Gap detection result: status=${parsed.status}, lastSeq=${parsed.lastSeq}, missed=${parsed.missedCount}"
|
"Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
|
||||||
|
"missed=${response.missedCount}, hasMore=${response.hasMore}",
|
||||||
)
|
)
|
||||||
onUpdatesBatch(parsed.lastSeq)
|
updateMissedCount(response.missedCount)
|
||||||
updateMissedCount(parsed.missedCount)
|
|
||||||
}.onFailure {
|
when (response.status) {
|
||||||
Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it)
|
"tooLong" -> {
|
||||||
}
|
val ok = rebuildStateFromHistory()
|
||||||
|
if (ok) {
|
||||||
|
persistLastSeq(response.lastSeq)
|
||||||
|
sendAck(response.lastSeq)
|
||||||
} else {
|
} else {
|
||||||
Logger.d("UpdateSyncManager", "No data returned from getUpdates; treating as no-op")
|
Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
"ok" -> {
|
||||||
|
if (response.lastSeq > _lastSeq.value && response.missedCount == 0) {
|
||||||
|
persistLastSeq(response.lastSeq)
|
||||||
|
sendAck(response.lastSeq)
|
||||||
|
}
|
||||||
|
if (!response.hasMore) break
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t)
|
Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t)
|
||||||
} finally {
|
} finally {
|
||||||
if (startSeq > 0) {
|
|
||||||
ConnectionStateStore.onUpdating(start = false)
|
ConnectionStateStore.onUpdating(start = false)
|
||||||
}
|
|
||||||
gapDetectionInProgress = false
|
gapDetectionInProgress = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun requestGetUpdates(token: String, lastSeq: Int): GetUpdatesResponse? {
|
||||||
|
val requestMessage = WebSocketMessage(
|
||||||
|
type = "getUpdates",
|
||||||
|
credentials = WebSocketCredentials(
|
||||||
|
scheme = "Bearer",
|
||||||
|
credentials = token,
|
||||||
|
),
|
||||||
|
data = ApiClient.json.encodeToJsonElement(
|
||||||
|
GetUpdatesRequest.serializer(),
|
||||||
|
GetUpdatesRequest(lastSeq = lastSeq),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val response = WebSocketManager.request(requestMessage)
|
||||||
|
val data = response?.data ?: return null
|
||||||
|
return runCatching {
|
||||||
|
ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
|
||||||
|
}.onFailure {
|
||||||
|
Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendAck(seq: Int) {
|
||||||
|
val token = ApiClient.token ?: return
|
||||||
|
if (seq <= 0) return
|
||||||
|
runCatching {
|
||||||
|
WebSocketManager.request(
|
||||||
|
WebSocketMessage(
|
||||||
|
type = "ackUpdates",
|
||||||
|
credentials = WebSocketCredentials(scheme = "Bearer", credentials = token),
|
||||||
|
data = ApiClient.json.encodeToJsonElement(
|
||||||
|
AckUpdatesRequest.serializer(),
|
||||||
|
AckUpdatesRequest(lastSeq = seq),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.onFailure {
|
||||||
|
Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun persistLastSeq(seq: Int) {
|
||||||
|
val currentUserId = ApiClient.user?.id ?: return
|
||||||
|
if (seq <= _lastSeq.value) return
|
||||||
|
_lastSeq.value = seq
|
||||||
|
ConnectionStateStore.updateSeqAndMissed(lastSeq = seq, missedCount = _lastMissedCount.value)
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
runCatching {
|
||||||
|
settings.putInt(KEY_UPDATES_LAST_SEQ_PREFIX + currentUserId, seq)
|
||||||
|
}.onFailure {
|
||||||
|
Logger.w("UpdateSyncManager", "Failed to persist lastSeq=$seq: ${it.message}", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun rebuildStateFromHistory(): Boolean = withContext(Dispatchers.Default) {
|
||||||
|
Logger.i("UpdateSyncManager", "Rebuilding local state from history (tooLong)")
|
||||||
|
runCatching {
|
||||||
|
ChatListSync.syncDmConversationsForRebuild()
|
||||||
|
rebuildPublicHistory()
|
||||||
|
rebuildDmHistories()
|
||||||
|
true
|
||||||
|
}.onFailure {
|
||||||
|
Logger.w("UpdateSyncManager", "rebuildStateFromHistory failed: ${it.message}", it)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun rebuildPublicHistory() {
|
||||||
|
val collected = LinkedHashMap<Int, Message>()
|
||||||
|
var beforeId: Int? = null
|
||||||
|
repeat(MAX_HISTORY_PAGES) {
|
||||||
|
val page = ApiClient.getMessages(limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
|
||||||
|
if (page.messages.isEmpty()) return@repeat
|
||||||
|
page.messages.forEach { msg ->
|
||||||
|
ProfileCache.mergePreviewFromPublicMessage(msg)
|
||||||
|
collected[msg.id] = msg
|
||||||
|
}
|
||||||
|
val oldest = page.messages.minByOrNull { it.id } ?: return@repeat
|
||||||
|
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
|
||||||
|
beforeId = oldest.id
|
||||||
|
}
|
||||||
|
val ordered = collected.values.sortedBy {
|
||||||
|
parseMessageTimestampMillis(it.timestamp) ?: 0L
|
||||||
|
}
|
||||||
|
MessageRepository.replacePublicMessages(ordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun rebuildDmHistories() {
|
||||||
|
val conversations = MessageRepository.loadCachedDmConversations()
|
||||||
|
for (conversation in conversations) {
|
||||||
|
val otherId = conversation.otherUserId
|
||||||
|
MessageCacheStore.clearDmMessages(otherId)
|
||||||
|
var beforeId: Int? = null
|
||||||
|
repeat(MAX_HISTORY_PAGES) {
|
||||||
|
val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
|
||||||
|
if (page.messages.isEmpty()) return@repeat
|
||||||
|
for (envelope in page.messages) {
|
||||||
|
val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope)
|
||||||
|
DmInboundMessageProcessor.processNew(element)
|
||||||
|
}
|
||||||
|
val oldest = page.messages.minByOrNull { it.id } ?: return@repeat
|
||||||
|
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
|
||||||
|
beforeId = oldest.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package ru.fromchat.api.instance
|
|||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.WebSocketManager
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
import ru.fromchat.api.local.db.store.InstanceRegistryStore
|
|
||||||
import ru.fromchat.config.ServerConfigData
|
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
|
import ru.fromchat.api.local.db.store.InstanceRegistryStore
|
||||||
import ru.fromchat.config.ServerConfig
|
import ru.fromchat.config.ServerConfig
|
||||||
|
import ru.fromchat.config.ServerConfigData
|
||||||
import ru.fromchat.legal.DocumentRepository
|
import ru.fromchat.legal.DocumentRepository
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.TimeSource
|
import kotlin.time.TimeSource
|
||||||
|
|
||||||
sealed interface ServerProbeResult {
|
sealed interface ServerProbeResult {
|
||||||
@@ -51,20 +52,26 @@ fun Throwable.isSslProtocolError(): Boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun probeCallsReachable(config: ServerConfigData): Boolean {
|
suspend fun probeCallsReachable(config: ServerConfigData) = runCatching {
|
||||||
val urlScheme = if (config.httpsEnabled) "https" else "http"
|
withTimeout(CALLS_PROBE_MS.milliseconds) {
|
||||||
val host = config.serverIp.trim()
|
ApiClient.probeHttpGet(
|
||||||
val authorityHost = host.removePrefix("[").removeSuffix("]").ifEmpty { host }
|
buildString {
|
||||||
// Caddy rewrites this to LiveKit "/" (plain "OK"). GET /rtc is always 404 without a WS upgrade.
|
append(if (config.httpsEnabled) "https" else "http")
|
||||||
val url = "$urlScheme://$authorityHost:${config.apiPort}/livekit-health"
|
append("://")
|
||||||
return runCatching {
|
append(
|
||||||
withTimeout(CALLS_PROBE_MS) {
|
config.serverIp.trim().let {
|
||||||
ApiClient.probeHttpGet(url)
|
it.removePrefix("[").removeSuffix("]").ifEmpty { it }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
append(":")
|
||||||
|
append(config.apiPort)
|
||||||
|
append("/livekit-health")
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun probeServer(config: ServerConfigData): ServerProbeResult =
|
suspend fun probeServer(config: ServerConfigData) =
|
||||||
probeServerEndpoint(
|
probeServerEndpoint(
|
||||||
host = config.serverIp,
|
host = config.serverIp,
|
||||||
port = config.apiPort,
|
port = config.apiPort,
|
||||||
@@ -82,44 +89,51 @@ suspend fun probeServerEndpoint(
|
|||||||
httpsPreferred: Boolean,
|
httpsPreferred: Boolean,
|
||||||
schemeExplicit: Boolean,
|
schemeExplicit: Boolean,
|
||||||
): Pair<ServerConfigData, ServerProbeResult> {
|
): Pair<ServerConfigData, ServerProbeResult> {
|
||||||
val schemes = when {
|
|
||||||
schemeExplicit -> listOf(httpsPreferred)
|
|
||||||
else -> listOf(true, false)
|
|
||||||
}
|
|
||||||
var lastConfig = ServerConfigData(
|
var lastConfig = ServerConfigData(
|
||||||
serverIp = host,
|
serverIp = host,
|
||||||
apiPort = port,
|
apiPort = port,
|
||||||
callsPort = port,
|
callsPort = port,
|
||||||
httpsEnabled = httpsPreferred,
|
httpsEnabled = httpsPreferred,
|
||||||
)
|
)
|
||||||
|
|
||||||
var lastResult: ServerProbeResult = ServerProbeResult.Unreachable
|
var lastResult: ServerProbeResult = ServerProbeResult.Unreachable
|
||||||
for ((index, https) in schemes.withIndex()) {
|
|
||||||
|
when {
|
||||||
|
schemeExplicit -> listOf(httpsPreferred)
|
||||||
|
else -> listOf(true, false)
|
||||||
|
}.apply {
|
||||||
|
forEachIndexed { index, https ->
|
||||||
val config = ServerConfigData(
|
val config = ServerConfigData(
|
||||||
serverIp = host,
|
serverIp = host,
|
||||||
apiPort = port,
|
apiPort = port,
|
||||||
callsPort = port,
|
callsPort = port,
|
||||||
httpsEnabled = https,
|
httpsEnabled = https,
|
||||||
)
|
)
|
||||||
|
|
||||||
lastConfig = config
|
lastConfig = config
|
||||||
val (result, failure) = probeServerOnce(config)
|
val (result, failure) = probeServerOnce(config)
|
||||||
lastResult = result
|
lastResult = result
|
||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is ServerProbeResult.Supported,
|
is ServerProbeResult.Supported,
|
||||||
ServerProbeResult.Unsupported,
|
ServerProbeResult.Unsupported -> return config to result
|
||||||
-> return config to result
|
|
||||||
ServerProbeResult.Timeout,
|
ServerProbeResult.Timeout,
|
||||||
ServerProbeResult.Unreachable,
|
ServerProbeResult.Unreachable -> {
|
||||||
-> {
|
if (!(!schemeExplicit && https && index < lastIndex)) return config to result
|
||||||
val hasHttpFallback = !schemeExplicit && https && index < schemes.lastIndex
|
|
||||||
if (!hasHttpFallback) return config to result
|
|
||||||
// Browser-like: only fall back to HTTP after a TLS/protocol failure.
|
// Browser-like: only fall back to HTTP after a TLS/protocol failure.
|
||||||
if (failure?.isSslProtocolError() == true) continue
|
if (failure?.isSslProtocolError() == true)
|
||||||
if (result is ServerProbeResult.Timeout) return config to result
|
return@forEachIndexed
|
||||||
|
if (result is ServerProbeResult.Timeout)
|
||||||
|
return config to result
|
||||||
|
|
||||||
// Non-SSL Unreachable (e.g. LAN cleartext / connection refused on 443): try HTTP.
|
// Non-SSL Unreachable (e.g. LAN cleartext / connection refused on 443): try HTTP.
|
||||||
continue
|
return@forEachIndexed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return lastConfig to lastResult
|
return lastConfig to lastResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,17 +141,21 @@ private suspend fun probeServerOnce(
|
|||||||
config: ServerConfigData,
|
config: ServerConfigData,
|
||||||
): Pair<ServerProbeResult, Throwable?> {
|
): Pair<ServerProbeResult, Throwable?> {
|
||||||
val apiBase = apiBaseUrlFor(config)
|
val apiBase = apiBaseUrlFor(config)
|
||||||
val mark = TimeSource.Monotonic.markNow()
|
val time = TimeSource.Monotonic.markNow()
|
||||||
InstanceIdGuard.probeConfig = config
|
InstanceIdGuard.probeConfig = config
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
val pingMs = time.elapsedNow().inWholeMilliseconds.toInt().coerceAtLeast(0)
|
||||||
|
|
||||||
|
return ServerProbeResult.Supported(
|
||||||
|
when (
|
||||||
val resolve = resolveInstanceId(
|
val resolve = resolveInstanceId(
|
||||||
config = config,
|
config = config,
|
||||||
apiBaseUrl = apiBase,
|
apiBaseUrl = apiBase,
|
||||||
forceNetwork = true,
|
forceNetwork = true,
|
||||||
allowCachedOnFailure = false,
|
allowCachedOnFailure = false,
|
||||||
)
|
)
|
||||||
val pingMs = mark.elapsedNow().inWholeMilliseconds.toInt().coerceAtLeast(0)
|
) {
|
||||||
val instanceId = when (resolve) {
|
|
||||||
is InstanceIdResolveResult.Cached -> resolve.instanceId
|
is InstanceIdResolveResult.Cached -> resolve.instanceId
|
||||||
is InstanceIdResolveResult.Fetched -> resolve.instanceId
|
is InstanceIdResolveResult.Fetched -> resolve.instanceId
|
||||||
is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId
|
is InstanceIdResolveResult.InstanceIdChanged -> resolve.newId
|
||||||
@@ -151,9 +169,10 @@ private suspend fun probeServerOnce(
|
|||||||
}.exceptionOrNull()
|
}.exceptionOrNull()
|
||||||
return ServerProbeResult.Unreachable to failure
|
return ServerProbeResult.Unreachable to failure
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
val callsOk = probeCallsReachable(config)
|
probeCallsReachable(config),
|
||||||
return ServerProbeResult.Supported(instanceId, callsOk, pingMs) to null
|
pingMs
|
||||||
|
) to null
|
||||||
} finally {
|
} finally {
|
||||||
InstanceIdGuard.probeConfig = null
|
InstanceIdGuard.probeConfig = null
|
||||||
}
|
}
|
||||||
@@ -182,13 +201,15 @@ suspend fun applyServerAndNavigate(
|
|||||||
): ApplyServerResult {
|
): ApplyServerResult {
|
||||||
val apiBase = apiBaseUrlFor(config)
|
val apiBase = apiBaseUrlFor(config)
|
||||||
val token = bearer.trim()
|
val token = bearer.trim()
|
||||||
|
|
||||||
if (token.isEmpty()) {
|
if (token.isEmpty()) {
|
||||||
applyServerConfig(config, probe.instanceId, probe.callsOk)
|
applyServerConfig(config, probe.instanceId, probe.callsOk)
|
||||||
WebSocketManager.disconnect()
|
WebSocketManager.disconnect()
|
||||||
onNavigateLogin()
|
onNavigateLogin()
|
||||||
return ApplyServerResult.Applied
|
return ApplyServerResult.Applied
|
||||||
}
|
}
|
||||||
when (val auth = ApiClient.checkAuthAt(apiBase, token)) {
|
|
||||||
|
when (ApiClient.checkAuthAt(apiBase, token)) {
|
||||||
ApiClient.CheckAuthResult.Authenticated -> {
|
ApiClient.CheckAuthResult.Authenticated -> {
|
||||||
applyServerConfig(config, probe.instanceId, probe.callsOk)
|
applyServerConfig(config, probe.instanceId, probe.callsOk)
|
||||||
WebSocketManager.disconnect()
|
WebSocketManager.disconnect()
|
||||||
@@ -196,9 +217,11 @@ suspend fun applyServerAndNavigate(
|
|||||||
onNavigateChat()
|
onNavigateChat()
|
||||||
return ApplyServerResult.Applied
|
return ApplyServerResult.Applied
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiClient.CheckAuthResult.Unreachable -> {
|
ApiClient.CheckAuthResult.Unreachable -> {
|
||||||
return ApplyServerResult.ServerUnreachable
|
return ApplyServerResult.ServerUnreachable
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiClient.CheckAuthResult.NotAuthenticated -> {
|
ApiClient.CheckAuthResult.NotAuthenticated -> {
|
||||||
onLogoutOldHost()
|
onLogoutOldHost()
|
||||||
applyServerConfig(config, probe.instanceId, probe.callsOk)
|
applyServerConfig(config, probe.instanceId, probe.callsOk)
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import ru.fromchat.api.local.db.store.ConnectionStateStore
|
|||||||
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
||||||
import ru.fromchat.api.schema.websocket.WebSocketCredentials
|
import ru.fromchat.api.schema.websocket.WebSocketCredentials
|
||||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
|
||||||
import ru.fromchat.config.ServerConfig
|
import ru.fromchat.config.ServerConfig
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -234,12 +233,11 @@ object WebSocketManager {
|
|||||||
|
|
||||||
val msg = when (messageType) {
|
val msg = when (messageType) {
|
||||||
"updates" -> {
|
"updates" -> {
|
||||||
|
// Apply add/edit/delete before advancing; ack happens inside.
|
||||||
runCatching {
|
runCatching {
|
||||||
val updatesData = json.decodeFromJsonElement(
|
UpdateSyncManager.onUpdatesEnvelope(jsonTree)
|
||||||
WebSocketUpdatesData.serializer(), jsonTree)
|
|
||||||
UpdateSyncManager.onUpdatesBatch(updatesData.seq)
|
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
logW("Failed to decode updates envelope for seq tracking: ${it.message}", it)
|
logW("Failed to apply updates envelope: ${it.message}", it)
|
||||||
}
|
}
|
||||||
|
|
||||||
WebSocketMessage(
|
WebSocketMessage(
|
||||||
|
|||||||
+6
-1
@@ -297,6 +297,10 @@ object MessageCacheStore {
|
|||||||
syncDmConversationPreviewFromCache(otherUserId)
|
syncDmConversationPreviewFromCache(otherUserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun deletePublicMessageById(messageId: Int) {
|
||||||
|
deleteMessageById(conversationIdForPublic(), messageId)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun deleteMessageByClientMessageId(conversationId: String, clientMessageId: String) {
|
suspend fun deleteMessageByClientMessageId(conversationId: String, clientMessageId: String) {
|
||||||
deleteByClientMessageId(conversationId, clientMessageId)
|
deleteByClientMessageId(conversationId, clientMessageId)
|
||||||
}
|
}
|
||||||
@@ -535,7 +539,7 @@ object MessageCacheStore {
|
|||||||
.filter { it.type == "dm" }
|
.filter { it.type == "dm" }
|
||||||
localDm.forEach { row ->
|
localDm.forEach { row ->
|
||||||
val otherId = row.otherUserId?.toInt() ?: return@forEach
|
val otherId = row.otherUserId?.toInt() ?: return@forEach
|
||||||
if (otherId !in serverOtherUserIds) {
|
if (otherId !in serverOtherUserIds && row.archived == 0L) {
|
||||||
db.messageDatabaseQueries.deleteConversationById(instanceId, row.id)
|
db.messageDatabaseQueries.deleteConversationById(instanceId, row.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -686,6 +690,7 @@ object MessageCacheStore {
|
|||||||
id = convId,
|
id = convId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DmConversationListNotifier.notifyChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteDmConversation(otherUserId: Int) {
|
suspend fun deleteDmConversation(otherUserId: Int) {
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ object MessageRepository {
|
|||||||
suspend fun markPublicMessageDeleted(messageId: Int) =
|
suspend fun markPublicMessageDeleted(messageId: Int) =
|
||||||
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
|
markMessageDeleted(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID), messageId)
|
||||||
|
|
||||||
|
suspend fun deletePublicMessageById(messageId: Int) =
|
||||||
|
MessageCacheStore.deletePublicMessageById(messageId)
|
||||||
|
|
||||||
suspend fun loadDmMessages(otherUserId: Int): List<Message> =
|
suspend fun loadDmMessages(otherUserId: Int): List<Message> =
|
||||||
MessageCacheStore.loadDmMessages(otherUserId)
|
MessageCacheStore.loadDmMessages(otherUserId)
|
||||||
|
|
||||||
@@ -114,6 +117,7 @@ object MessageRepository {
|
|||||||
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
|
||||||
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
|
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
|
||||||
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
|
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
|
||||||
|
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) {
|
suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) {
|
||||||
@@ -134,6 +138,7 @@ object MessageRepository {
|
|||||||
runCatching { ApiClient.markMessagesRead(ids) }
|
runCatching { ApiClient.markMessagesRead(ids) }
|
||||||
}
|
}
|
||||||
MessageCacheStore.markPublicMessagesReadLocally()
|
MessageCacheStore.markPublicMessagesReadLocally()
|
||||||
|
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun archiveDmConversation(otherUserId: Int) =
|
suspend fun archiveDmConversation(otherUserId: Int) =
|
||||||
|
|||||||
@@ -20,13 +20,19 @@ import ru.fromchat.api.schema.user.profile.UserProfile
|
|||||||
import ru.fromchat.api.schema.user.profile.VerificationStatus
|
import ru.fromchat.api.schema.user.profile.VerificationStatus
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true when a profile entry should not expose its `username` field
|
* Returns true when a profile entry should not expose its `username` field
|
||||||
* to the UI (for suspended or deleted users), except for the current user.
|
* to the UI (for suspended or deleted users), except for the current user.
|
||||||
*/
|
*/
|
||||||
fun UserProfile.shouldHideUsername(currentUserId: Int? = null): Boolean =
|
fun UserProfile.shouldHideUsername(currentUserId: Int? = null): Boolean =
|
||||||
id != currentUserId && (deleted == true || isDeletedPlaceholderUsername(username))
|
id != currentUserId && (
|
||||||
|
deleted == true ||
|
||||||
|
isDeletedPlaceholderUsername(username) ||
|
||||||
|
(suspended == true && currentUserId != 1)
|
||||||
|
)
|
||||||
|
|
||||||
private fun isDeletedPlaceholderUsername(username: String?): Boolean =
|
private fun isDeletedPlaceholderUsername(username: String?): Boolean =
|
||||||
username?.startsWith("#deleted") == true
|
username?.startsWith("#deleted") == true
|
||||||
@@ -52,18 +58,34 @@ object ProfileCache {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var loadedInstanceId: String = ""
|
private var loadedInstanceId: String = ""
|
||||||
|
|
||||||
|
/** Epoch millis when a full (non-preview) profile was last applied from the server. */
|
||||||
|
@Volatile
|
||||||
|
private var fullProfileFetchedAtMs: Map<Int, Long> = emptyMap()
|
||||||
|
|
||||||
private val _revision = MutableStateFlow(0)
|
private val _revision = MutableStateFlow(0)
|
||||||
val revision: StateFlow<Int> = _revision.asStateFlow()
|
val revision: StateFlow<Int> = _revision.asStateFlow()
|
||||||
|
|
||||||
private val persistMutex = Mutex()
|
private val persistMutex = Mutex()
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
/** Skip force=false network refetch when a full profile was fetched within this window. */
|
||||||
|
const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L
|
||||||
|
|
||||||
private fun bumpRevision() {
|
private fun bumpRevision() {
|
||||||
_revision.value++
|
_revision.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
fun get(userId: Int): UserProfile? = profiles[userId]
|
fun get(userId: Int): UserProfile? = profiles[userId]
|
||||||
|
|
||||||
|
/** True when a full non-preview profile was fetched recently enough to skip refetch. */
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
fun hasFreshFullProfile(userId: Int, maxAgeMs: Long = FULL_PROFILE_TTL_MS): Boolean {
|
||||||
|
val profile = get(userId) ?: return false
|
||||||
|
if (profile.isClientPreviewOnly) return false
|
||||||
|
val fetchedAt = fullProfileFetchedAtMs[userId] ?: return false
|
||||||
|
return (Clock.System.now().toEpochMilliseconds() - fetchedAt) <= maxAgeMs
|
||||||
|
}
|
||||||
|
|
||||||
/** Emits whenever this user's cached profile changes (including bio). */
|
/** Emits whenever this user's cached profile changes (including bio). */
|
||||||
fun observeUser(userId: Int): Flow<UserProfile?> =
|
fun observeUser(userId: Int): Flow<UserProfile?> =
|
||||||
revision.map { get(userId) }.distinctUntilChanged()
|
revision.map { get(userId) }.distinctUntilChanged()
|
||||||
@@ -169,12 +191,20 @@ object ProfileCache {
|
|||||||
* When [force] is false, an existing full (non-preview) cache row is kept so a slow HTTP
|
* When [force] is false, an existing full (non-preview) cache row is kept so a slow HTTP
|
||||||
* response cannot overwrite a fresher WebSocket update.
|
* response cannot overwrite a fresher WebSocket update.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
fun applyServerProfile(profile: UserProfile, force: Boolean = false) {
|
fun applyServerProfile(profile: UserProfile, force: Boolean = false) {
|
||||||
if (profile.id <= 0) return
|
if (profile.id <= 0) return
|
||||||
val normalized = profile.copy(isClientPreviewOnly = false)
|
val normalized = profile.copy(isClientPreviewOnly = false)
|
||||||
|
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||||
if (!force) {
|
if (!force) {
|
||||||
val existing = get(profile.id)
|
val existing = get(profile.id)
|
||||||
if (existing != null && !existing.isClientPreviewOnly) {
|
if (existing != null && !existing.isClientPreviewOnly) {
|
||||||
|
val patched = existing.copy(
|
||||||
|
verified = normalized.verified ?: existing.verified,
|
||||||
|
verificationStatus = normalized.verificationStatus
|
||||||
|
?: existing.verificationStatus,
|
||||||
|
)
|
||||||
|
if (patched != existing) put(patched)
|
||||||
if (existing.bio != normalized.bio) {
|
if (existing.bio != normalized.bio) {
|
||||||
ru.fromchat.Logger.d(
|
ru.fromchat.Logger.d(
|
||||||
"ProfileCache",
|
"ProfileCache",
|
||||||
@@ -182,6 +212,8 @@ object ProfileCache {
|
|||||||
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'",
|
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Refresh TTL so force=false callers stop refetching.
|
||||||
|
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,6 +223,7 @@ object ProfileCache {
|
|||||||
"bio='${normalized.bio?.take(48)}'",
|
"bio='${normalized.bio?.take(48)}'",
|
||||||
)
|
)
|
||||||
put(normalized)
|
put(normalized)
|
||||||
|
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(userId: Int) {
|
fun remove(userId: Int) {
|
||||||
@@ -293,7 +326,14 @@ object ProfileCache {
|
|||||||
val uid = message.user_id
|
val uid = message.user_id
|
||||||
if (uid <= 0) return
|
if (uid <= 0) return
|
||||||
val existing = get(uid)
|
val existing = get(uid)
|
||||||
if (existing != null && !existing.isClientPreviewOnly) return
|
if (existing != null && !existing.isClientPreviewOnly) {
|
||||||
|
val patched = existing.copy(
|
||||||
|
verified = message.verified ?: existing.verified,
|
||||||
|
verificationStatus = message.verificationStatus ?: existing.verificationStatus,
|
||||||
|
)
|
||||||
|
if (patched != existing) put(patched)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
|
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
|
||||||
if (uname.isBlank()) return
|
if (uname.isBlank()) return
|
||||||
@@ -371,6 +411,7 @@ object ProfileCache {
|
|||||||
} else {
|
} else {
|
||||||
emptyMap()
|
emptyMap()
|
||||||
}
|
}
|
||||||
|
fullProfileFetchedAtMs = emptyMap()
|
||||||
pruneUnusableClientPreviewsLocked()
|
pruneUnusableClientPreviewsLocked()
|
||||||
bumpRevision()
|
bumpRevision()
|
||||||
}
|
}
|
||||||
@@ -424,6 +465,7 @@ object ProfileCache {
|
|||||||
suspend fun clear() {
|
suspend fun clear() {
|
||||||
persistMutex.withLock {
|
persistMutex.withLock {
|
||||||
profiles = emptyMap()
|
profiles = emptyMap()
|
||||||
|
fullProfileFetchedAtMs = emptyMap()
|
||||||
loadedInstanceId = ""
|
loadedInstanceId = ""
|
||||||
bumpRevision()
|
bumpRevision()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,11 @@ object UserStatusStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeTyping(userId: Int, username: String) {
|
fun removeTyping(userId: Int, username: String = "") {
|
||||||
val trimmed = username.trim().takeIf { it.isNotBlank() } ?: return
|
|
||||||
_status.update { current ->
|
_status.update { current ->
|
||||||
val existing = current[userId] ?: return@update current
|
val existing = current[userId] ?: return@update current
|
||||||
val typing = existing.typingUsernames.filterNot { it.equals(trimmed, ignoreCase = true) }
|
// Clear by userId: after suspend/delete the stop event may use #deleted{id}.
|
||||||
current + (userId to existing.copy(typingUsernames = typing))
|
current + (userId to existing.copy(typingUsernames = emptyList()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ import ru.fromchat.api.schema.messages.Message
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat list order: confirmed messages by time, then outgoing queued (negative id) at the bottom.
|
* Chat list order: confirmed messages by time, then outgoing queued (negative id) at the bottom.
|
||||||
* Avoids optimistic rows jumping mid-thread when client clock lags the server.
|
* Pending rows sort by enqueue sequence (monotonic negative id), not hash or wall-clock.
|
||||||
*/
|
*/
|
||||||
internal fun sortMessagesForChatDisplay(messages: List<Message>): List<Message> {
|
internal fun sortMessagesForChatDisplay(messages: List<Message>): List<Message> {
|
||||||
if (messages.size <= 1) return messages
|
if (messages.size <= 1) return messages
|
||||||
val (pending, confirmed) = messages.partition { it.id < 0 }
|
val (pending, confirmed) = messages.partition { it.id < 0 }
|
||||||
val comparator = compareBy<Message>(
|
val confirmedComparator = compareBy<Message>(
|
||||||
{ messageSortEpochMillis(it) },
|
{ messageSortEpochMillis(it) },
|
||||||
{ it.id.toLong() },
|
{ it.id.toLong() },
|
||||||
)
|
)
|
||||||
return confirmed.sortedWith(comparator) + pending.sortedWith(comparator)
|
// Monotonic ids are -1, -2, … so -id ascending = enqueue order.
|
||||||
|
val pendingComparator = compareBy<Message>(
|
||||||
|
{ -it.id.toLong() },
|
||||||
|
{ it.client_message_id.orEmpty() },
|
||||||
|
)
|
||||||
|
return confirmed.sortedWith(confirmedComparator) + pending.sortedWith(pendingComparator)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun messageSortEpochMillis(message: Message): Long =
|
internal fun messageSortEpochMillis(message: Message): Long =
|
||||||
|
|||||||
+3
-3
@@ -20,8 +20,8 @@ fun nowMessageTimestampIso(): String = Clock.System.now().toString()
|
|||||||
* Parse message timestamps from server or client.
|
* Parse message timestamps from server or client.
|
||||||
*
|
*
|
||||||
* - Strings with `Z` / an offset (optimistic client stamps, proper UTC) are true instants.
|
* - Strings with `Z` / an offset (optimistic client stamps, proper UTC) are true instants.
|
||||||
* - Zone-less ISO from the API is naive server wall time (`datetime.now().isoformat()`),
|
* - Zone-less ISO from the API is naive UTC wall time (`datetime.now().isoformat()`),
|
||||||
* interpreted in the device zone so HH:mm matches the user's clock.
|
* interpreted as UTC so local formatting shows the user's clock.
|
||||||
*/
|
*/
|
||||||
internal fun parseMessageInstant(timestamp: String): Instant? {
|
internal fun parseMessageInstant(timestamp: String): Instant? {
|
||||||
val raw = timestamp.trim()
|
val raw = timestamp.trim()
|
||||||
@@ -32,7 +32,7 @@ internal fun parseMessageInstant(timestamp: String): Instant? {
|
|||||||
}
|
}
|
||||||
val local = parseLocalDateTimeOrNull(normalized) ?: return null
|
val local = parseLocalDateTimeOrNull(normalized) ?: return null
|
||||||
return runCatching {
|
return runCatching {
|
||||||
local.toInstant(TimeZone.currentSystemDefault())
|
local.toInstant(TimeZone.UTC)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-5
@@ -1,13 +1,42 @@
|
|||||||
package ru.fromchat.api.local.messages
|
package ru.fromchat.api.local.messages
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import ru.fromchat.api.schema.messages.Message
|
import ru.fromchat.api.schema.messages.Message
|
||||||
import kotlin.math.abs
|
|
||||||
|
|
||||||
/** Stable negative row id for an optimistic / outbox [clientMessageId]. */
|
private val optimisticIdMutex = Mutex()
|
||||||
|
private var nextOptimisticSeq = 0
|
||||||
|
private val idByClientMessageId = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
|
private inline fun <T> withOptimisticIdLock(block: () -> T): T {
|
||||||
|
while (!optimisticIdMutex.tryLock()) {
|
||||||
|
// spin — allocation is rare and short
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} finally {
|
||||||
|
optimisticIdMutex.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Negative row id for an optimistic / outbox [clientMessageId].
|
||||||
|
* Monotonic enqueue order (-1, -2, …); stable for the same client id within process.
|
||||||
|
*/
|
||||||
fun optimisticMessageIdForClientMessageId(clientMessageId: String): Int {
|
fun optimisticMessageIdForClientMessageId(clientMessageId: String): Int {
|
||||||
val hc = clientMessageId.hashCode()
|
val key = clientMessageId.trim()
|
||||||
val absHc = if (hc == Int.MIN_VALUE) Int.MAX_VALUE else abs(hc)
|
if (key.isEmpty()) return nextOptimisticMessageId()
|
||||||
return -(if (absHc == 0) 1 else absHc)
|
return withOptimisticIdLock {
|
||||||
|
idByClientMessageId.getOrPut(key) {
|
||||||
|
nextOptimisticSeq += 1
|
||||||
|
-nextOptimisticSeq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh monotonic negative id when no client message id is available yet. */
|
||||||
|
fun nextOptimisticMessageId(): Int = withOptimisticIdLock {
|
||||||
|
nextOptimisticSeq += 1
|
||||||
|
-nextOptimisticSeq
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Message.isQueuedOutbound(): Boolean = id < 0
|
fun Message.isQueuedOutbound(): Boolean = id < 0
|
||||||
|
|||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package ru.fromchat.api.local.messages
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.schema.messages.Message
|
||||||
|
import ru.fromchat.api.schema.websocket.types.MessageDeletedData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global public-chat inbox: persists add/edit/delete into [MessageRepository] even when
|
||||||
|
* no [ru.fromchat.ui.chat.panels.publicchat.PublicChatPanel] is open.
|
||||||
|
*/
|
||||||
|
object PublicInboxCoordinator {
|
||||||
|
suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) {
|
||||||
|
val message = decodeMessage(element) ?: return@withContext
|
||||||
|
ProfileCache.mergePreviewFromPublicMessage(message)
|
||||||
|
val clientId = message.client_message_id?.trim().orEmpty()
|
||||||
|
val currentUserId = ApiClient.user?.id
|
||||||
|
if (currentUserId != null && message.user_id == currentUserId) {
|
||||||
|
if (clientId.isNotEmpty()) {
|
||||||
|
MessageRepository.confirmPublicMessage(clientId, message)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
// Server omitted client_message_id — still clear a matching pending row if unique.
|
||||||
|
val pending = MessageRepository.loadPublicMessages()
|
||||||
|
.filter { it.user_id == currentUserId && it.id < 0 }
|
||||||
|
val matchCid = when {
|
||||||
|
pending.size == 1 -> pending.first().client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
else -> {
|
||||||
|
val content = message.content.trim()
|
||||||
|
pending.singleOrNull {
|
||||||
|
it.content.trim() == content ||
|
||||||
|
(!it.files.isNullOrEmpty() && !message.files.isNullOrEmpty())
|
||||||
|
}?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchCid != null) {
|
||||||
|
MessageRepository.confirmPublicMessage(
|
||||||
|
matchCid,
|
||||||
|
message.copy(client_message_id = matchCid),
|
||||||
|
)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageRepository.upsertPublicMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) {
|
||||||
|
val edited = decodeMessage(element) ?: return@withContext
|
||||||
|
ProfileCache.mergePreviewFromPublicMessage(edited)
|
||||||
|
val existing = MessageRepository.loadPublicMessages()
|
||||||
|
val merged = existing.map { current ->
|
||||||
|
if (current.id == edited.id) {
|
||||||
|
edited.copy(reply_to = edited.reply_to ?: current.reply_to)
|
||||||
|
} else {
|
||||||
|
current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (merged.none { it.id == edited.id }) {
|
||||||
|
MessageRepository.upsertPublicMessage(edited)
|
||||||
|
} else {
|
||||||
|
MessageRepository.replacePublicMessages(merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) {
|
||||||
|
val deleted = runCatching {
|
||||||
|
ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element)
|
||||||
|
}.getOrNull() ?: return@withContext
|
||||||
|
MessageRepository.deletePublicMessageById(deleted.message_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeMessage(element: JsonElement): Message? =
|
||||||
|
runCatching {
|
||||||
|
ApiClient.json.decodeFromJsonElement(Message.serializer(), element)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package ru.fromchat.api.local.messages
|
||||||
|
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
|
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a single updates batch in order (add/edit/delete) before the cursor advances.
|
||||||
|
*/
|
||||||
|
object UpdatesBatchApplier {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val publicTypes = setOf("newMessage", "messageEdited", "messageDeleted")
|
||||||
|
private val dmTypes = setOf("dmNew", "dmDeleted", "dmEdited")
|
||||||
|
|
||||||
|
suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock {
|
||||||
|
val envelope = runCatching {
|
||||||
|
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
|
||||||
|
}.getOrNull() ?: return@withLock null
|
||||||
|
|
||||||
|
for (update in envelope.updates) {
|
||||||
|
applyOne(WebSocketMessage(type = update.type, data = update.data))
|
||||||
|
}
|
||||||
|
envelope.seq
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun applyOne(message: WebSocketMessage) {
|
||||||
|
when (message.type) {
|
||||||
|
"updates" -> {
|
||||||
|
val data = message.data ?: return
|
||||||
|
applyEnvelope(data)
|
||||||
|
}
|
||||||
|
"newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) }
|
||||||
|
"messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) }
|
||||||
|
"messageDeleted" -> message.data?.let { PublicInboxCoordinator.processDeleted(it) }
|
||||||
|
"dmNew", "dmDeleted", "dmEdited" -> DmInboxCoordinator.handleMessage(message)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isCacheAffecting(type: String): Boolean =
|
||||||
|
type in publicTypes || type in dmTypes
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package ru.fromchat.api.schema.user.auth
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthUsernameStepRequest(
|
||||||
|
val username: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthUsernameStepResponse(
|
||||||
|
val ok: Boolean = true,
|
||||||
|
val exists: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthPasswordStepRequest(
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class YandexOAuthParams(
|
||||||
|
val client_id: String,
|
||||||
|
val redirect_uri: String,
|
||||||
|
val authorize_url: String,
|
||||||
|
val scope: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AuthNeedsRegisterResponse(
|
||||||
|
val status: String,
|
||||||
|
val yandex_required: Boolean = false,
|
||||||
|
val yandex: YandexOAuthParams? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class YandexExchangeRequest(
|
||||||
|
val code: String,
|
||||||
|
val code_verifier: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class YandexExchangeResponse(
|
||||||
|
val registration_proof: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AccountYandexResponse(
|
||||||
|
val linked: Boolean = false,
|
||||||
|
val yandex: YandexOAuthParams? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChangeYandexRequest(
|
||||||
|
val registration_proof: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChangeYandexResponse(
|
||||||
|
val status: String? = null,
|
||||||
|
val unchanged: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RegisterConfirmRequest(
|
||||||
|
val username: String,
|
||||||
|
val display_name: String,
|
||||||
|
val password: String,
|
||||||
|
val confirm_password: String,
|
||||||
|
val bio: String? = null,
|
||||||
|
val registration_proof: String? = null,
|
||||||
|
)
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.fromchat.api.schema.user.profile
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SuspendUserRequest(
|
||||||
|
val reason: String,
|
||||||
|
)
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.fromchat.api.schema.websocket.requests
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AckUpdatesRequest(
|
||||||
|
@SerialName("lastSeq") val lastSeq: Int,
|
||||||
|
)
|
||||||
+2
-1
@@ -7,5 +7,6 @@ import kotlinx.serialization.Serializable
|
|||||||
data class GetUpdatesResponse(
|
data class GetUpdatesResponse(
|
||||||
val status: String,
|
val status: String,
|
||||||
@SerialName("lastSeq") val lastSeq: Int,
|
@SerialName("lastSeq") val lastSeq: Int,
|
||||||
@SerialName("missedCount") val missedCount: Int
|
@SerialName("missedCount") val missedCount: Int,
|
||||||
|
@SerialName("hasMore") val hasMore: Boolean = false,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package ru.fromchat.auth.yandex
|
||||||
|
|
||||||
|
import korlibs.crypto.SHA256
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
internal data class PkcePair(
|
||||||
|
val codeVerifier: String,
|
||||||
|
val codeChallenge: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun generatePkcePair(): PkcePair {
|
||||||
|
val verifierBytes = ByteArray(32).also { Random.Default.nextBytes(it) }
|
||||||
|
val codeVerifier = base64UrlNoPad(verifierBytes)
|
||||||
|
val challenge = base64UrlNoPad(SHA256.digest(codeVerifier.encodeToByteArray()).bytes)
|
||||||
|
return PkcePair(codeVerifier = codeVerifier, codeChallenge = challenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base64UrlNoPad(bytes: ByteArray): String {
|
||||||
|
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||||
|
val out = StringBuilder((bytes.size * 4 + 2) / 3)
|
||||||
|
var i = 0
|
||||||
|
while (i < bytes.size) {
|
||||||
|
val b0 = bytes[i].toInt() and 0xff
|
||||||
|
val b1 = if (i + 1 < bytes.size) bytes[i + 1].toInt() and 0xff else 0
|
||||||
|
val b2 = if (i + 2 < bytes.size) bytes[i + 2].toInt() and 0xff else 0
|
||||||
|
out.append(alphabet[b0 shr 2])
|
||||||
|
out.append(alphabet[((b0 and 0x03) shl 4) or (b1 shr 4)])
|
||||||
|
if (i + 1 < bytes.size) {
|
||||||
|
out.append(alphabet[((b1 and 0x0f) shl 2) or (b2 shr 6)])
|
||||||
|
}
|
||||||
|
if (i + 2 < bytes.size) {
|
||||||
|
out.append(alphabet[b2 and 0x3f])
|
||||||
|
}
|
||||||
|
i += 3
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val OFFICIAL_API_HOST = "api.fromchat.ru"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prod Yandex OAuth client id (identity-only app). When non-empty and the API host is
|
||||||
|
* [OFFICIAL_API_HOST], the server-supplied client_id must match this value.
|
||||||
|
*/
|
||||||
|
internal const val OFFICIAL_YANDEX_OAUTH_CLIENT_ID = ""
|
||||||
|
|
||||||
|
internal const val YANDEX_OAUTH_REDIRECT_URI = "fromchat://oauth/yandex"
|
||||||
|
|
||||||
|
internal fun isOfficialApiHost(serverIp: String): Boolean {
|
||||||
|
val host = serverIp.trim().lowercase().substringBefore("/").substringBefore(":")
|
||||||
|
return host == OFFICIAL_API_HOST
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the client_id to use, or null if the official host sent a mismatched id.
|
||||||
|
*/
|
||||||
|
internal fun resolveYandexClientId(serverClientId: String, serverIp: String): String? {
|
||||||
|
val trimmed = serverClientId.trim()
|
||||||
|
if (trimmed.isEmpty()) return null
|
||||||
|
if (!isOfficialApiHost(serverIp)) return trimmed
|
||||||
|
val pinned = OFFICIAL_YANDEX_OAUTH_CLIENT_ID.trim()
|
||||||
|
if (pinned.isEmpty()) return trimmed
|
||||||
|
return if (pinned == trimmed) trimmed else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yandex UI language follows the authorize host (`.ru` vs `.com`), not OAuth `lang`.
|
||||||
|
* Keep `lang` anyway; rewrite host so passport matches the app locale.
|
||||||
|
*/
|
||||||
|
internal fun yandexAuthorizeHostForLanguage(languageTag: String): String {
|
||||||
|
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
|
||||||
|
return if (lang == "ru") "oauth.yandex.ru" else "oauth.yandex.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun withYandexAuthorizeHost(authorizeUrl: String, languageTag: String): String {
|
||||||
|
val targetHost = yandexAuthorizeHostForLanguage(languageTag)
|
||||||
|
return Regex("""oauth\.yandex\.(com|ru)""", RegexOption.IGNORE_CASE)
|
||||||
|
.replace(authorizeUrl.trim()) { targetHost }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildYandexAuthorizeUrl(
|
||||||
|
authorizeUrl: String,
|
||||||
|
clientId: String,
|
||||||
|
redirectUri: String,
|
||||||
|
scope: String,
|
||||||
|
codeChallenge: String,
|
||||||
|
languageTag: String = "en",
|
||||||
|
darkTheme: Boolean = false,
|
||||||
|
): String {
|
||||||
|
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
|
||||||
|
val base = withYandexAuthorizeHost(authorizeUrl, lang).trimEnd('?')
|
||||||
|
val theme = if (darkTheme) "dark" else "light"
|
||||||
|
val params = buildList {
|
||||||
|
add("response_type" to "code")
|
||||||
|
add("client_id" to clientId)
|
||||||
|
add("redirect_uri" to redirectUri)
|
||||||
|
if (scope.isNotBlank()) {
|
||||||
|
add("scope" to scope.trim())
|
||||||
|
}
|
||||||
|
add("code_challenge" to codeChallenge)
|
||||||
|
add("code_challenge_method" to "S256")
|
||||||
|
add("force_confirm" to "yes")
|
||||||
|
add("lang" to lang)
|
||||||
|
add("theme" to theme)
|
||||||
|
add("color_scheme" to theme)
|
||||||
|
}.joinToString("&") { (k, v) ->
|
||||||
|
"${encodeUrl(k)}=${encodeUrl(v)}"
|
||||||
|
}
|
||||||
|
return "$base?$params"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeUrl(value: String): String = buildString(value.length) {
|
||||||
|
for (ch in value) {
|
||||||
|
when {
|
||||||
|
ch.isLetterOrDigit() || ch in "-_.~" -> append(ch)
|
||||||
|
else -> {
|
||||||
|
val bytes = ch.toString().encodeToByteArray()
|
||||||
|
for (b in bytes) {
|
||||||
|
append('%')
|
||||||
|
append(((b.toInt() shr 4) and 0xf).toString(16).uppercase())
|
||||||
|
append((b.toInt() and 0xf).toString(16).uppercase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun extractOAuthCode(redirectUrl: String): String? {
|
||||||
|
val uri = redirectUrl.trim()
|
||||||
|
if (!uri.startsWith(YANDEX_OAUTH_REDIRECT_URI, ignoreCase = true) &&
|
||||||
|
!uri.startsWith("fromchat://oauth/yandex?", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val query = uri.substringAfter('?', missingDelimiterValue = "")
|
||||||
|
if (query.isEmpty()) return null
|
||||||
|
return query.split('&').firstNotNullOfOrNull { part ->
|
||||||
|
val key = part.substringBefore('=')
|
||||||
|
val raw = part.substringAfter('=', missingDelimiterValue = "")
|
||||||
|
if (key == "code" && raw.isNotEmpty()) decodeUrl(raw) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeUrl(value: String): String {
|
||||||
|
val bytes = ArrayList<Byte>()
|
||||||
|
var i = 0
|
||||||
|
while (i < value.length) {
|
||||||
|
val c = value[i]
|
||||||
|
when {
|
||||||
|
c == '+' -> {
|
||||||
|
bytes.add(' '.code.toByte())
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
c == '%' && i + 2 < value.length -> {
|
||||||
|
val hex = value.substring(i + 1, i + 3)
|
||||||
|
bytes.add(hex.toInt(16).toByte())
|
||||||
|
i += 3
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
bytes.add(c.code.toByte())
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes.toByteArray().decodeToString()
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.fromchat.notifications
|
||||||
|
|
||||||
|
/** Platform bridge so shared mark-read can dismiss message notifications. */
|
||||||
|
expect object ChatNotificationDismissals {
|
||||||
|
fun dismissAllMessageNotifications()
|
||||||
|
}
|
||||||
@@ -79,6 +79,8 @@ import ru.fromchat.legal.DocumentScreen
|
|||||||
import ru.fromchat.legal.DocumentType
|
import ru.fromchat.legal.DocumentType
|
||||||
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||||
import ru.fromchat.ui.auth.AuthScreen
|
import ru.fromchat.ui.auth.AuthScreen
|
||||||
|
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
|
||||||
|
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
|
||||||
import ru.fromchat.ui.calls.CallOverlay
|
import ru.fromchat.ui.calls.CallOverlay
|
||||||
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
|
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
|
||||||
import ru.fromchat.ui.chat.panels.dm.DmNav
|
import ru.fromchat.ui.chat.panels.dm.DmNav
|
||||||
@@ -98,6 +100,9 @@ import ru.fromchat.ui.main.settings.NotificationsScreen
|
|||||||
import ru.fromchat.ui.main.settings.SettingsRoutes
|
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||||
import ru.fromchat.ui.main.settings.account.AccountScreen
|
import ru.fromchat.ui.main.settings.account.AccountScreen
|
||||||
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
|
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
|
||||||
|
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexConfirmScreen
|
||||||
|
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexDoneScreen
|
||||||
|
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexOAuthScreen
|
||||||
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
|
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
|
||||||
import ru.fromchat.ui.main.settings.server.ServerConfigScreen
|
import ru.fromchat.ui.main.settings.server.ServerConfigScreen
|
||||||
import ru.fromchat.ui.profile.EditProfileFocusField
|
import ru.fromchat.ui.profile.EditProfileFocusField
|
||||||
@@ -457,6 +462,10 @@ fun App(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(YandexOAuthNav.ROUTE) {
|
||||||
|
YandexOAuthScreen()
|
||||||
|
}
|
||||||
|
|
||||||
composable("chat") {
|
composable("chat") {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
sharedTransitionScope = this@SharedTransitionLayout,
|
sharedTransitionScope = this@SharedTransitionLayout,
|
||||||
@@ -670,6 +679,26 @@ fun App(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
settingsComposable(SettingsRoutes.AccountYandexFlow) {
|
||||||
|
ChangeYandexConfirmScreen(
|
||||||
|
onBack = { navController.navigateUp() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsComposable(SettingsRoutes.AccountYandexOAuth) {
|
||||||
|
ChangeYandexOAuthScreen(
|
||||||
|
onBack = { navController.navigateUp() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsComposable(SettingsRoutes.AccountYandexDone) {
|
||||||
|
ChangeYandexDoneScreen(
|
||||||
|
onDone = {
|
||||||
|
navController.popBackStack(SettingsRoutes.Account, inclusive = false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
settingsComposable(SettingsRoutes.Account) {
|
settingsComposable(SettingsRoutes.Account) {
|
||||||
AccountScreen(
|
AccountScreen(
|
||||||
onBack = { navController.navigateUp() },
|
onBack = { navController.navigateUp() },
|
||||||
@@ -679,6 +708,7 @@ fun App(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) },
|
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) },
|
||||||
|
onChangeYandexId = { navController.navigate(SettingsRoutes.AccountYandexFlow) },
|
||||||
onDeleteAccount = { navController.navigate(SettingsRoutes.AccountDeleteFlow) },
|
onDeleteAccount = { navController.navigate(SettingsRoutes.AccountDeleteFlow) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package ru.fromchat.ui.auth
|
||||||
|
|
||||||
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Survives [AuthScreen] leaving composition when navigating to the Yandex OAuth route.
|
||||||
|
* Cleared on welcome / successful auth / explicit reset to username.
|
||||||
|
*/
|
||||||
|
internal object AuthRegisterDraft {
|
||||||
|
var username: String = ""
|
||||||
|
var password: String = ""
|
||||||
|
var confirmPassword: String = ""
|
||||||
|
var displayName: String = ""
|
||||||
|
var bio: String = ""
|
||||||
|
var yandexRequired: Boolean = false
|
||||||
|
var yandexParams: YandexOAuthParams? = null
|
||||||
|
var registrationProof: String? = null
|
||||||
|
var page: Int = 0
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
confirmPassword = ""
|
||||||
|
displayName = ""
|
||||||
|
bio = ""
|
||||||
|
yandexRequired = false
|
||||||
|
yandexParams = null
|
||||||
|
registrationProof = null
|
||||||
|
page = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.Storage
|
import androidx.compose.material.icons.filled.Storage
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -27,14 +28,15 @@ import ru.fromchat.api.local.db.clearAccountCacheOnLogout
|
|||||||
import ru.fromchat.api.instance.ServerProbeResult
|
import ru.fromchat.api.instance.ServerProbeResult
|
||||||
import ru.fromchat.api.instance.probeServer
|
import ru.fromchat.api.instance.probeServer
|
||||||
import ru.fromchat.api.schema.core.ErrorResponse
|
import ru.fromchat.api.schema.core.ErrorResponse
|
||||||
import ru.fromchat.api.schema.user.auth.LoginRequest
|
|
||||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||||
import ru.fromchat.api.schema.user.auth.RegisterRequest
|
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
import ru.fromchat.change_server
|
import ru.fromchat.change_server
|
||||||
import ru.fromchat.config.Settings
|
import ru.fromchat.config.Settings
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.auth.register.confirmPasswordStepPage
|
import ru.fromchat.ui.auth.register.confirmPasswordStepPage
|
||||||
import ru.fromchat.ui.auth.register.profileStepPage
|
import ru.fromchat.ui.auth.register.profileStepPage
|
||||||
|
import ru.fromchat.ui.auth.yandex.yandexIdStepPage
|
||||||
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
|
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import ru.fromchat.ui.components.TextCta
|
import ru.fromchat.ui.components.TextCta
|
||||||
@@ -47,12 +49,16 @@ private enum class AuthFlowStep {
|
|||||||
Username,
|
Username,
|
||||||
Password,
|
Password,
|
||||||
ConfirmPassword,
|
ConfirmPassword,
|
||||||
|
YandexId,
|
||||||
Profile,
|
Profile,
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed interface PasswordStepResult {
|
internal sealed interface PasswordStepResult {
|
||||||
data object LoginSuccess : PasswordStepResult
|
data object LoginSuccess : PasswordStepResult
|
||||||
data object AdvanceToRegister : PasswordStepResult
|
data class NeedsRegister(
|
||||||
|
val yandexRequired: Boolean,
|
||||||
|
val yandex: YandexOAuthParams?,
|
||||||
|
) : PasswordStepResult
|
||||||
data class WrongPassword(val message: String) : PasswordStepResult
|
data class WrongPassword(val message: String) : PasswordStepResult
|
||||||
data class RateLimited(val message: String) : PasswordStepResult
|
data class RateLimited(val message: String) : PasswordStepResult
|
||||||
data class Error(val message: String, val cause: Throwable? = null) : PasswordStepResult
|
data class Error(val message: String, val cause: Throwable? = null) : PasswordStepResult
|
||||||
@@ -72,29 +78,6 @@ internal suspend fun probeCurrentServer() = runCatching {
|
|||||||
}
|
}
|
||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
|
|
||||||
internal suspend fun authBranch(
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
wrongPasswordMessage: String,
|
|
||||||
rateLimitMessage: String,
|
|
||||||
unexpectedError: String,
|
|
||||||
) = login(
|
|
||||||
username.trim(),
|
|
||||||
password,
|
|
||||||
wrongPasswordMessage,
|
|
||||||
rateLimitMessage,
|
|
||||||
unexpectedError,
|
|
||||||
).let { result ->
|
|
||||||
if (
|
|
||||||
result is PasswordStepResult.WrongPassword &&
|
|
||||||
!runCatching { ApiClient.checkUsername(username.trim()).exists }.getOrDefault(true)
|
|
||||||
) {
|
|
||||||
PasswordStepResult.AdvanceToRegister
|
|
||||||
} else {
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun fullLogin(
|
private suspend fun fullLogin(
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
@@ -123,20 +106,25 @@ private suspend fun fullLogin(
|
|||||||
runCatching { ApiClient.refreshServerInstanceFingerprint() }
|
runCatching { ApiClient.refreshServerInstanceFingerprint() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun login(
|
internal suspend fun authPasswordStep(
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
wrongPasswordMessage: String,
|
wrongPasswordMessage: String,
|
||||||
rateLimitMessage: String,
|
rateLimitMessage: String,
|
||||||
unexpectedError: String,
|
unexpectedError: String,
|
||||||
) = try {
|
) = try {
|
||||||
fullLogin(username, password.trim()) {
|
val derived = deriveAuthSecret(username.trim(), password.trim())
|
||||||
ApiClient.loginRequest(
|
when (val outcome = ApiClient.authPasswordStep(username.trim(), derived)) {
|
||||||
LoginRequest(username, deriveAuthSecret(username, password.trim())),
|
is ApiClient.AuthPasswordStepOutcome.LoggedIn -> {
|
||||||
)
|
fullLogin(username.trim(), password.trim()) { outcome.response }
|
||||||
|
PasswordStepResult.LoginSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
PasswordStepResult.LoginSuccess
|
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister(
|
||||||
|
yandexRequired = outcome.yandexRequired,
|
||||||
|
yandex = outcome.yandex,
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (e: ClientRequestException) {
|
} catch (e: ClientRequestException) {
|
||||||
when (e.response.status.value) {
|
when (e.response.status.value) {
|
||||||
401 -> PasswordStepResult.WrongPassword(
|
401 -> PasswordStepResult.WrongPassword(
|
||||||
@@ -158,27 +146,23 @@ internal suspend fun register(
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
password: String,
|
password: String,
|
||||||
bio: String,
|
bio: String,
|
||||||
|
registrationProof: String?,
|
||||||
unexpectedError: String,
|
unexpectedError: String,
|
||||||
) = try {
|
) = try {
|
||||||
if (runCatching { ApiClient.checkUsername(username.trim()).exists }.getOrDefault(false)) {
|
|
||||||
RegisterResult.UsernameTaken
|
|
||||||
} else {
|
|
||||||
fullLogin(username.trim(), password.trim()) {
|
fullLogin(username.trim(), password.trim()) {
|
||||||
val derived = deriveAuthSecret(username.trim(), password.trim())
|
val derived = deriveAuthSecret(username.trim(), password.trim())
|
||||||
|
ApiClient.authRegisterConfirm(
|
||||||
ApiClient.registerRequest(
|
RegisterConfirmRequest(
|
||||||
RegisterRequest(
|
|
||||||
username = username.trim(),
|
username = username.trim(),
|
||||||
display_name = displayName.trim(),
|
display_name = displayName.trim(),
|
||||||
password = derived,
|
password = derived,
|
||||||
confirm_password = derived,
|
confirm_password = derived,
|
||||||
bio = bio.trim().takeIf { it.isNotEmpty() },
|
bio = bio.trim().takeIf { it.isNotEmpty() },
|
||||||
|
registration_proof = registrationProof,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
RegisterResult.Success
|
RegisterResult.Success
|
||||||
}
|
|
||||||
} catch (e: ClientRequestException) {
|
} catch (e: ClientRequestException) {
|
||||||
if (e.response.status.value == 400 && isUsernameTakenError(e)) {
|
if (e.response.status.value == 400 && isUsernameTakenError(e)) {
|
||||||
RegisterResult.UsernameTaken
|
RegisterResult.UsernameTaken
|
||||||
@@ -225,11 +209,26 @@ fun AuthScreen(
|
|||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val flowState = rememberExpressiveStepFlow(AuthFlowStep.entries.size)
|
val flowState = rememberExpressiveStepFlow(AuthFlowStep.entries.size)
|
||||||
|
|
||||||
var username by remember { mutableStateOf("") }
|
var username by remember { mutableStateOf(AuthRegisterDraft.username) }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf(AuthRegisterDraft.password) }
|
||||||
var confirmPassword by remember { mutableStateOf("") }
|
var confirmPassword by remember { mutableStateOf(AuthRegisterDraft.confirmPassword) }
|
||||||
var displayName by remember { mutableStateOf("") }
|
var displayName by remember { mutableStateOf(AuthRegisterDraft.displayName) }
|
||||||
var bio by remember { mutableStateOf("") }
|
var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
|
||||||
|
var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) }
|
||||||
|
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
|
||||||
|
var registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
|
||||||
|
|
||||||
|
fun persistDraft() {
|
||||||
|
AuthRegisterDraft.username = username
|
||||||
|
AuthRegisterDraft.password = password
|
||||||
|
AuthRegisterDraft.confirmPassword = confirmPassword
|
||||||
|
AuthRegisterDraft.displayName = displayName
|
||||||
|
AuthRegisterDraft.bio = bio
|
||||||
|
AuthRegisterDraft.yandexRequired = yandexRequired
|
||||||
|
AuthRegisterDraft.yandexParams = yandexParams
|
||||||
|
AuthRegisterDraft.registrationProof = registrationProof
|
||||||
|
AuthRegisterDraft.page = flowState.pagerState.currentPage
|
||||||
|
}
|
||||||
|
|
||||||
fun snackbar(text: String, cause: Throwable? = null) {
|
fun snackbar(text: String, cause: Throwable? = null) {
|
||||||
scope.showLoggedSnackbar(
|
scope.showLoggedSnackbar(
|
||||||
@@ -240,44 +239,86 @@ fun AuthScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val wrappedAuthSuccess: () -> Unit = {
|
||||||
|
AuthRegisterDraft.clear()
|
||||||
|
onAuthSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
val wrappedBackToWelcome: () -> Unit = {
|
||||||
|
AuthRegisterDraft.clear()
|
||||||
|
onBackToWelcome()
|
||||||
|
}
|
||||||
|
|
||||||
val resetToUsername: () -> Unit = {
|
val resetToUsername: () -> Unit = {
|
||||||
username = ""
|
username = ""
|
||||||
password = ""
|
password = ""
|
||||||
confirmPassword = ""
|
confirmPassword = ""
|
||||||
displayName = ""
|
displayName = ""
|
||||||
bio = ""
|
bio = ""
|
||||||
|
yandexRequired = false
|
||||||
|
yandexParams = null
|
||||||
|
registrationProof = null
|
||||||
|
AuthRegisterDraft.clear()
|
||||||
flowState.resetPredictiveState()
|
flowState.resetPredictiveState()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Username.ordinal)
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Username.ordinal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
username = ""
|
onDispose { persistDraft() }
|
||||||
password = ""
|
}
|
||||||
confirmPassword = ""
|
|
||||||
displayName = ""
|
LaunchedEffect(username, password, confirmPassword, displayName, bio, yandexRequired, yandexParams, registrationProof) {
|
||||||
bio = ""
|
persistDraft()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(flowState.pagerState) {
|
LaunchedEffect(flowState.pagerState) {
|
||||||
|
val restored = AuthRegisterDraft.page
|
||||||
|
if (restored in 1 until AuthFlowStep.entries.size &&
|
||||||
|
flowState.pagerState.currentPage != restored
|
||||||
|
) {
|
||||||
|
flowState.pagerState.scrollToPage(restored)
|
||||||
|
}
|
||||||
var settledPage = flowState.pagerState.currentPage
|
var settledPage = flowState.pagerState.currentPage
|
||||||
snapshotFlow { flowState.pagerState.currentPage }
|
snapshotFlow { flowState.pagerState.currentPage }
|
||||||
.collect { page ->
|
.collect { page ->
|
||||||
|
AuthRegisterDraft.page = page
|
||||||
|
if (page == AuthFlowStep.YandexId.ordinal && !yandexRequired) {
|
||||||
|
val target = if (page > settledPage) {
|
||||||
|
AuthFlowStep.Profile.ordinal
|
||||||
|
} else {
|
||||||
|
AuthFlowStep.ConfirmPassword.ordinal
|
||||||
|
}
|
||||||
|
settledPage = target
|
||||||
|
flowState.pagerState.scrollToPage(target)
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
if (page < settledPage) {
|
if (page < settledPage) {
|
||||||
when (page) {
|
when (page) {
|
||||||
AuthFlowStep.Username.ordinal -> {
|
AuthFlowStep.Username.ordinal -> {
|
||||||
password = ""
|
password = ""
|
||||||
confirmPassword = ""
|
confirmPassword = ""
|
||||||
|
yandexRequired = false
|
||||||
|
yandexParams = null
|
||||||
|
registrationProof = null
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthFlowStep.Password.ordinal -> {
|
AuthFlowStep.Password.ordinal -> {
|
||||||
password = ""
|
password = ""
|
||||||
confirmPassword = ""
|
confirmPassword = ""
|
||||||
|
yandexRequired = false
|
||||||
|
yandexParams = null
|
||||||
|
registrationProof = null
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthFlowStep.ConfirmPassword.ordinal -> {
|
AuthFlowStep.ConfirmPassword.ordinal -> {
|
||||||
confirmPassword = ""
|
// Keep confirm password when returning from Yandex ID / OAuth.
|
||||||
|
registrationProof = null
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthFlowStep.YandexId.ordinal -> {
|
||||||
|
registrationProof = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -285,6 +326,7 @@ fun AuthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val yandexStep = yandexParams
|
||||||
ExpressiveStepFlowScaffold(
|
ExpressiveStepFlowScaffold(
|
||||||
flowState = flowState,
|
flowState = flowState,
|
||||||
pages = listOf(
|
pages = listOf(
|
||||||
@@ -300,8 +342,11 @@ fun AuthScreen(
|
|||||||
username = username,
|
username = username,
|
||||||
password = password,
|
password = password,
|
||||||
onPasswordChange = { password = it },
|
onPasswordChange = { password = it },
|
||||||
onLoginSuccess = onAuthSuccess,
|
onLoginSuccess = wrappedAuthSuccess,
|
||||||
onRegister = {
|
onNeedsRegister = { required, params ->
|
||||||
|
yandexRequired = required
|
||||||
|
yandexParams = params
|
||||||
|
registrationProof = null
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
@@ -311,10 +356,34 @@ fun AuthScreen(
|
|||||||
onConfirmPasswordChange = { confirmPassword = it },
|
onConfirmPasswordChange = { confirmPassword = it },
|
||||||
password = password,
|
password = password,
|
||||||
onContinue = {
|
onContinue = {
|
||||||
|
if (yandexRequired && yandexParams != null) {
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
||||||
|
} else {
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
),
|
),
|
||||||
|
if (yandexStep != null) {
|
||||||
|
yandexIdStepPage(
|
||||||
|
yandex = yandexStep,
|
||||||
|
onProof = { proof ->
|
||||||
|
registrationProof = proof
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
|
},
|
||||||
|
onSnackbar = ::snackbar,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
confirmPasswordStepPage(
|
||||||
|
confirmPassword = confirmPassword,
|
||||||
|
onConfirmPasswordChange = { confirmPassword = it },
|
||||||
|
password = password,
|
||||||
|
onContinue = {
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
|
},
|
||||||
|
onSnackbar = ::snackbar,
|
||||||
|
)
|
||||||
|
},
|
||||||
profileStepPage(
|
profileStepPage(
|
||||||
username = username,
|
username = username,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
@@ -322,12 +391,13 @@ fun AuthScreen(
|
|||||||
bio = bio,
|
bio = bio,
|
||||||
onBioChange = { bio = it },
|
onBioChange = { bio = it },
|
||||||
password = password,
|
password = password,
|
||||||
onRegisterSuccess = onAuthSuccess,
|
registrationProof = registrationProof,
|
||||||
|
onRegisterSuccess = wrappedAuthSuccess,
|
||||||
onUsernameTaken = resetToUsername,
|
onUsernameTaken = resetToUsername,
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
onBackAtFirstPage = onBackToWelcome,
|
onBackAtFirstPage = wrappedBackToWelcome,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import ru.fromchat.login
|
|||||||
import ru.fromchat.password
|
import ru.fromchat.password
|
||||||
import ru.fromchat.password_length_error
|
import ru.fromchat.password_length_error
|
||||||
import ru.fromchat.show_password
|
import ru.fromchat.show_password
|
||||||
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
import ru.fromchat.ui.components.ActionButton
|
import ru.fromchat.ui.components.ActionButton
|
||||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||||
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
|
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
|
||||||
@@ -53,7 +54,7 @@ internal fun passwordStepPage(
|
|||||||
password: String,
|
password: String,
|
||||||
onPasswordChange: (String) -> Unit,
|
onPasswordChange: (String) -> Unit,
|
||||||
onLoginSuccess: () -> Unit,
|
onLoginSuccess: () -> Unit,
|
||||||
onRegister: suspend () -> Unit,
|
onNeedsRegister: suspend (yandexRequired: Boolean, yandex: YandexOAuthParams?) -> Unit,
|
||||||
onSnackbar: (String, Throwable?) -> Unit,
|
onSnackbar: (String, Throwable?) -> Unit,
|
||||||
): ExpressiveStepPage {
|
): ExpressiveStepPage {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -121,7 +122,7 @@ internal fun passwordStepPage(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
when (
|
when (
|
||||||
val result = authBranch(
|
val result = authPasswordStep(
|
||||||
username = username,
|
username = username,
|
||||||
password = password,
|
password = password,
|
||||||
wrongPasswordMessage = wrongPassword,
|
wrongPasswordMessage = wrongPassword,
|
||||||
@@ -133,8 +134,8 @@ internal fun passwordStepPage(
|
|||||||
onLoginSuccess()
|
onLoginSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
is PasswordStepResult.AdvanceToRegister -> {
|
is PasswordStepResult.NeedsRegister -> {
|
||||||
onRegister()
|
onNeedsRegister(result.yandexRequired, result.yandex)
|
||||||
}
|
}
|
||||||
|
|
||||||
is PasswordStepResult.WrongPassword -> {
|
is PasswordStepResult.WrongPassword -> {
|
||||||
|
|||||||
@@ -17,12 +17,17 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.schema.core.ErrorResponse
|
||||||
import ru.fromchat.auth_server_connect_failed
|
import ru.fromchat.auth_server_connect_failed
|
||||||
import ru.fromchat.auth_step_username_body
|
import ru.fromchat.auth_step_username_body
|
||||||
import ru.fromchat.auth_step_username_title
|
import ru.fromchat.auth_step_username_title
|
||||||
|
import ru.fromchat.error_unexpected
|
||||||
import ru.fromchat.fill_all_fields
|
import ru.fromchat.fill_all_fields
|
||||||
import ru.fromchat.settings_next
|
import ru.fromchat.settings_next
|
||||||
import ru.fromchat.ui.components.ActionButton
|
import ru.fromchat.ui.components.ActionButton
|
||||||
@@ -55,6 +60,7 @@ internal fun usernameStepPage(
|
|||||||
val fillAll = stringResource(Res.string.fill_all_fields)
|
val fillAll = stringResource(Res.string.fill_all_fields)
|
||||||
val usernameLenError = stringResource(Res.string.username_length_error)
|
val usernameLenError = stringResource(Res.string.username_length_error)
|
||||||
val serverFail = stringResource(Res.string.auth_server_connect_failed)
|
val serverFail = stringResource(Res.string.auth_server_connect_failed)
|
||||||
|
val unexpected = stringResource(Res.string.error_unexpected)
|
||||||
val nextLabel = stringResource(Res.string.settings_next)
|
val nextLabel = stringResource(Res.string.settings_next)
|
||||||
|
|
||||||
return ExpressiveStepPage(
|
return ExpressiveStepPage(
|
||||||
@@ -109,7 +115,21 @@ internal fun usernameStepPage(
|
|||||||
if (!probeCurrentServer()) {
|
if (!probeCurrentServer()) {
|
||||||
onSnackbar(serverFail, null)
|
onSnackbar(serverFail, null)
|
||||||
} else {
|
} else {
|
||||||
|
try {
|
||||||
|
ApiClient.authUsernameStep(trimmed)
|
||||||
onContinue()
|
onContinue()
|
||||||
|
} catch (e: ClientRequestException) {
|
||||||
|
val detail = if (e.response.status.value == 400) {
|
||||||
|
runCatching { e.response.body<ErrorResponse>().detail }
|
||||||
|
.getOrNull()
|
||||||
|
?.ifBlank { null }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
onSnackbar(detail ?: unexpected, e)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onSnackbar(unexpected, e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busy = false
|
busy = false
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ internal fun profileStepPage(
|
|||||||
bio: String,
|
bio: String,
|
||||||
onBioChange: (String) -> Unit,
|
onBioChange: (String) -> Unit,
|
||||||
password: String,
|
password: String,
|
||||||
|
registrationProof: String?,
|
||||||
onRegisterSuccess: () -> Unit,
|
onRegisterSuccess: () -> Unit,
|
||||||
onUsernameTaken: () -> Unit,
|
onUsernameTaken: () -> Unit,
|
||||||
onSnackbar: (String, Throwable?) -> Unit,
|
onSnackbar: (String, Throwable?) -> Unit,
|
||||||
@@ -147,6 +148,7 @@ internal fun profileStepPage(
|
|||||||
displayName = displayName.trim(),
|
displayName = displayName.trim(),
|
||||||
password = password,
|
password = password,
|
||||||
bio = bio.trim(),
|
bio = bio.trim(),
|
||||||
|
registrationProof = registrationProof,
|
||||||
unexpectedError = unexpected,
|
unexpectedError = unexpected,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package ru.fromchat.ui.auth.yandex
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.jetbrains.compose.resources.vectorResource
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
|
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
|
||||||
|
import ru.fromchat.auth.yandex.buildYandexAuthorizeUrl
|
||||||
|
import ru.fromchat.auth.yandex.generatePkcePair
|
||||||
|
import ru.fromchat.auth.yandex.resolveYandexClientId
|
||||||
|
import ru.fromchat.auth_step_yandex_body
|
||||||
|
import ru.fromchat.auth_step_yandex_cta
|
||||||
|
import ru.fromchat.auth_step_yandex_title
|
||||||
|
import ru.fromchat.auth_yandex_client_mismatch
|
||||||
|
import ru.fromchat.config.Settings
|
||||||
|
import ru.fromchat.error_unexpected
|
||||||
|
import ru.fromchat.ic_yandex
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.components.ActionButton
|
||||||
|
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepPageHeader
|
||||||
|
import ru.fromchat.ui.components.Text
|
||||||
|
import ru.fromchat.ui.isAppInDarkTheme
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun yandexIdStepPage(
|
||||||
|
yandex: YandexOAuthParams,
|
||||||
|
onProof: suspend (String) -> Unit,
|
||||||
|
onSnackbar: (String, Throwable?) -> Unit,
|
||||||
|
): ExpressiveStepPage {
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
val languageTag = Locale.current.toLanguageTag()
|
||||||
|
val darkTheme = isAppInDarkTheme()
|
||||||
|
val onProofState = rememberUpdatedState(onProof)
|
||||||
|
val onSnackbarState = rememberUpdatedState(onSnackbar)
|
||||||
|
val yandexIcon = vectorResource(Res.drawable.ic_yandex)
|
||||||
|
|
||||||
|
val title = stringResource(Res.string.auth_step_yandex_title)
|
||||||
|
val body = stringResource(Res.string.auth_step_yandex_body)
|
||||||
|
val cta = stringResource(Res.string.auth_step_yandex_cta)
|
||||||
|
val clientMismatch = stringResource(Res.string.auth_yandex_client_mismatch)
|
||||||
|
val unexpected = stringResource(Res.string.error_unexpected)
|
||||||
|
|
||||||
|
LaunchedEffect(navController) {
|
||||||
|
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||||
|
handle.getStateFlow<String?>(YandexOAuthNav.RESULT_PROOF, null).collect { proof ->
|
||||||
|
if (proof == null) return@collect
|
||||||
|
handle.remove<String>(YandexOAuthNav.RESULT_PROOF)
|
||||||
|
busy = true
|
||||||
|
try {
|
||||||
|
onProofState.value(proof)
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(navController) {
|
||||||
|
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||||
|
handle.getStateFlow<String?>(YandexOAuthNav.RESULT_ERROR, null).collect { message ->
|
||||||
|
if (message == null) return@collect
|
||||||
|
handle.remove<String>(YandexOAuthNav.RESULT_ERROR)
|
||||||
|
onSnackbarState.value(message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExpressiveStepPage(
|
||||||
|
hero = ExpressiveHeroSpec(
|
||||||
|
icon = yandexIcon,
|
||||||
|
polygon = MaterialShapes.Cookie9Sided.normalized(),
|
||||||
|
containerColor = colorScheme.primaryContainer,
|
||||||
|
contentColor = colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
content = {
|
||||||
|
ExpressiveStepPageHeader(title = title, body = body)
|
||||||
|
},
|
||||||
|
button = {
|
||||||
|
ActionButton(
|
||||||
|
onClick = {
|
||||||
|
if (busy) return@ActionButton
|
||||||
|
val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
|
||||||
|
onSnackbar(unexpected, it)
|
||||||
|
return@ActionButton
|
||||||
|
}
|
||||||
|
val clientId = resolveYandexClientId(yandex.client_id, serverIp)
|
||||||
|
if (clientId == null) {
|
||||||
|
onSnackbar(clientMismatch, null)
|
||||||
|
return@ActionButton
|
||||||
|
}
|
||||||
|
val pkce = generatePkcePair()
|
||||||
|
YandexOAuthNav.pending = YandexOAuthNav.Session(
|
||||||
|
authorizeUrl = buildYandexAuthorizeUrl(
|
||||||
|
authorizeUrl = yandex.authorize_url,
|
||||||
|
clientId = clientId,
|
||||||
|
redirectUri = yandex.redirect_uri.ifBlank { YANDEX_OAUTH_REDIRECT_URI },
|
||||||
|
scope = yandex.scope,
|
||||||
|
codeChallenge = pkce.codeChallenge,
|
||||||
|
languageTag = languageTag,
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
),
|
||||||
|
codeVerifier = pkce.codeVerifier,
|
||||||
|
)
|
||||||
|
navController.navigate(YandexOAuthNav.ROUTE)
|
||||||
|
},
|
||||||
|
enabled = !busy,
|
||||||
|
loading = busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(cta)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package ru.fromchat.ui.auth.yandex
|
||||||
|
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root [androidx.navigation.NavController] route for the Yandex OAuth WebView.
|
||||||
|
* Session is staged in [pending] before [navigate]; PKCE verifier must not go in the route.
|
||||||
|
* Prefer [SessionSaver] / rememberSaveable on the OAuth screen so pause/recreate keeps PKCE.
|
||||||
|
*/
|
||||||
|
internal object YandexOAuthNav {
|
||||||
|
const val ROUTE = "yandexOAuth"
|
||||||
|
const val RESULT_PROOF = "yandex_registration_proof"
|
||||||
|
const val RESULT_ERROR = "yandex_oauth_error"
|
||||||
|
|
||||||
|
data class Session(
|
||||||
|
val authorizeUrl: String,
|
||||||
|
val codeVerifier: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
val SessionSaver = listSaver<Session?, String>(
|
||||||
|
save = { session ->
|
||||||
|
if (session == null) emptyList()
|
||||||
|
else listOf(session.authorizeUrl, session.codeVerifier)
|
||||||
|
},
|
||||||
|
restore = { saved ->
|
||||||
|
if (saved.size < 2) null
|
||||||
|
else Session(authorizeUrl = saved[0], codeVerifier = saved[1])
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var pending: Session? = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package ru.fromchat.ui.auth.yandex
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.schema.core.ErrorResponse
|
||||||
|
import ru.fromchat.auth_yandex_failed
|
||||||
|
import ru.fromchat.back
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.isAppInDarkTheme
|
||||||
|
|
||||||
|
private const val LOG_TAG = "YandexOAuthScreen"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun YandexOAuthScreen() {
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// Survive Activity recreate / process restore — in-memory [YandexOAuthNav.pending] alone does not.
|
||||||
|
var session by rememberSaveable(stateSaver = YandexOAuthNav.SessionSaver) {
|
||||||
|
mutableStateOf(YandexOAuthNav.pending)
|
||||||
|
}
|
||||||
|
val fallbackColor = MaterialTheme.colorScheme.background
|
||||||
|
var chromeColor by remember { mutableStateOf(fallbackColor) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var webViewCanGoBack by remember { mutableStateOf(false) }
|
||||||
|
val failedMessage = stringResource(Res.string.auth_yandex_failed)
|
||||||
|
val backLabel = stringResource(Res.string.back)
|
||||||
|
val darkTheme = isAppInDarkTheme()
|
||||||
|
val screenId = remember { (100000..999999).random().toString(16) }
|
||||||
|
|
||||||
|
DisposableEffect(screenId) {
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"compose enter id=$screenId sessionNull=${session == null} " +
|
||||||
|
"pendingNull=${YandexOAuthNav.pending == null} darkTheme=$darkTheme " +
|
||||||
|
"route=${navController.currentBackStackEntry?.destination?.route}",
|
||||||
|
)
|
||||||
|
onDispose {
|
||||||
|
Logger.i(LOG_TAG, "compose dispose id=$screenId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(session) {
|
||||||
|
if (session == null) {
|
||||||
|
Logger.w(LOG_TAG, "session null → popBackStack id=$screenId")
|
||||||
|
navController.popBackStack()
|
||||||
|
} else {
|
||||||
|
YandexOAuthNav.pending = session
|
||||||
|
Logger.d(LOG_TAG, "session kept id=$screenId urlLen=${session!!.authorizeUrl.length}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val active = session ?: return
|
||||||
|
|
||||||
|
fun finishWithProof(proof: String) {
|
||||||
|
Logger.i(LOG_TAG, "finishWithProof id=$screenId")
|
||||||
|
YandexOAuthNav.pending = null
|
||||||
|
navController.previousBackStackEntry
|
||||||
|
?.savedStateHandle
|
||||||
|
?.set(YandexOAuthNav.RESULT_PROOF, proof)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishWithError(message: String) {
|
||||||
|
Logger.w(LOG_TAG, "finishWithError id=$screenId message=$message")
|
||||||
|
YandexOAuthNav.pending = null
|
||||||
|
navController.previousBackStackEntry
|
||||||
|
?.savedStateHandle
|
||||||
|
?.set(YandexOAuthNav.RESULT_ERROR, message)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
if (busy) return
|
||||||
|
Logger.i(LOG_TAG, "cancel id=$screenId")
|
||||||
|
YandexOAuthNav.pending = null
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(chromeColor),
|
||||||
|
) {
|
||||||
|
YandexOAuthWebView(
|
||||||
|
authorizeUrl = active.authorizeUrl,
|
||||||
|
languageTag = Locale.current.toLanguageTag(),
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
fallbackColor = fallbackColor,
|
||||||
|
onPageBackgroundColor = { chromeColor = it },
|
||||||
|
onHistoryBackAvailabilityChanged = { webViewCanGoBack = it },
|
||||||
|
onCode = { code ->
|
||||||
|
if (busy) return@YandexOAuthWebView
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
try {
|
||||||
|
val proof = ApiClient.authYandexExchange(code, active.codeVerifier).registration_proof
|
||||||
|
finishWithProof(proof)
|
||||||
|
} catch (e: ClientRequestException) {
|
||||||
|
val detail = if (e.response.status.value == 400) {
|
||||||
|
runCatching { e.response.body<ErrorResponse>().detail }
|
||||||
|
.getOrNull()
|
||||||
|
?.ifBlank { null }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
finishWithError(detail ?: failedMessage)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
finishWithError(failedMessage)
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = { finishWithError(it.ifBlank { failedMessage }) },
|
||||||
|
onCancel = { cancel() },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Visible when back exits to our Yandex ID step (WebView has no in-page history).
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !webViewCanGoBack && !busy,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.padding(start = 12.dp, top = 12.dp),
|
||||||
|
) {
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = { cancel() },
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = backLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package ru.fromchat.ui.auth.yandex
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform WebView that loads [authorizeUrl] and reports the OAuth redirect.
|
||||||
|
* Intercepts `fromchat://oauth/yandex` and returns the authorization code.
|
||||||
|
*
|
||||||
|
* @param languageTag BCP-47 tag for Accept-Language / WebView locale.
|
||||||
|
* @param darkTheme Pins WebView `prefers-color-scheme` via configuration night mode.
|
||||||
|
* @param fallbackColor Shown until the page background can be sampled.
|
||||||
|
* @param onPageBackgroundColor Reported whenever a non-transparent page background is detected.
|
||||||
|
* @param onHistoryBackAvailabilityChanged `true` when the WebView can go back in its own history.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
expect fun YandexOAuthWebView(
|
||||||
|
authorizeUrl: String,
|
||||||
|
languageTag: String,
|
||||||
|
darkTheme: Boolean,
|
||||||
|
fallbackColor: Color,
|
||||||
|
clearCookies: Boolean = false,
|
||||||
|
onPageBackgroundColor: (Color) -> Unit = {},
|
||||||
|
onHistoryBackAvailabilityChanged: (Boolean) -> Unit = {},
|
||||||
|
onCode: (String) -> Unit,
|
||||||
|
onError: (String) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
)
|
||||||
@@ -12,6 +12,7 @@ import ru.fromchat.api.ApiClient
|
|||||||
import ru.fromchat.api.local.AttachmentMediaLog
|
import ru.fromchat.api.local.AttachmentMediaLog
|
||||||
import ru.fromchat.api.local.messages.generateClientMessageId
|
import ru.fromchat.api.local.messages.generateClientMessageId
|
||||||
import ru.fromchat.api.local.messages.nowMessageTimestampIso
|
import ru.fromchat.api.local.messages.nowMessageTimestampIso
|
||||||
|
import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId
|
||||||
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
|
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
|
||||||
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
|
||||||
import ru.fromchat.api.schema.messages.Message
|
import ru.fromchat.api.schema.messages.Message
|
||||||
@@ -611,7 +612,7 @@ abstract class ChatPanel(
|
|||||||
|
|
||||||
val tempId = generateClientMessageId()
|
val tempId = generateClientMessageId()
|
||||||
val newOptimistic = message.copy(
|
val newOptimistic = message.copy(
|
||||||
id = uniqueOptimisticMessageId(),
|
id = optimisticMessageIdForClientMessageId(tempId),
|
||||||
client_message_id = tempId
|
client_message_id = tempId
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -698,7 +699,7 @@ abstract class ChatPanel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Unique negative id avoids duplicate LazyColumn keys and bad merge logic.
|
// Unique negative id avoids duplicate LazyColumn keys and bad merge logic.
|
||||||
val optimistic = tempMessage.copy(id = uniqueOptimisticMessageId())
|
val optimistic = tempMessage.copy(id = optimisticMessageIdForClientMessageId(tempId))
|
||||||
addMessage(optimistic)
|
addMessage(optimistic)
|
||||||
|
|
||||||
Logger.d(
|
Logger.d(
|
||||||
@@ -743,14 +744,6 @@ abstract class ChatPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun uniqueOptimisticMessageId(): Int = addMessageMutex.withLock {
|
|
||||||
var id: Int
|
|
||||||
do {
|
|
||||||
id = -kotlin.random.Random.nextInt(1, Int.MAX_VALUE)
|
|
||||||
} while (_state.messages.any { it.id == id })
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Persist optimistic row for offline / process death; no-op by default. */
|
/** Persist optimistic row for offline / process death; no-op by default. */
|
||||||
protected open suspend fun persistOptimisticMessage(message: Message) {}
|
protected open suspend fun persistOptimisticMessage(message: Message) {}
|
||||||
|
|
||||||
|
|||||||
@@ -71,11 +71,6 @@ import androidx.compose.ui.platform.LocalFocusManager
|
|||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.ktor.client.request.header
|
|
||||||
import io.ktor.client.request.post
|
|
||||||
import io.ktor.client.request.setBody
|
|
||||||
import io.ktor.http.ContentType
|
|
||||||
import io.ktor.http.contentType
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import ru.fromchat.api.local.messages.formatChatDateSeparator
|
import ru.fromchat.api.local.messages.formatChatDateSeparator
|
||||||
import ru.fromchat.chat_date_today
|
import ru.fromchat.chat_date_today
|
||||||
@@ -200,49 +195,9 @@ fun ChatScreen(
|
|||||||
LazyListState(0, 0)
|
LazyListState(0, 0)
|
||||||
}
|
}
|
||||||
val fakeScrollOffset = remember(panelId) { Animatable(0f) }
|
val fakeScrollOffset = remember(panelId) { Animatable(0f) }
|
||||||
LaunchedEffect(panelId) {
|
|
||||||
// #region agent log
|
|
||||||
agentDebugLog(
|
|
||||||
hypothesisId = "H4",
|
|
||||||
location = "ChatScreen.kt:188",
|
|
||||||
message = "chat_screen_opened",
|
|
||||||
data = mapOf(
|
|
||||||
"panelId" to panelId,
|
|
||||||
"listStateHash" to listState.hashCode(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// #endregion
|
|
||||||
}
|
|
||||||
val isNearBottom by remember(panelId) {
|
val isNearBottom by remember(panelId) {
|
||||||
derivedStateOf { listState.isChatNearBottom() }
|
derivedStateOf { listState.isChatNearBottom() }
|
||||||
}
|
}
|
||||||
LaunchedEffect(listState, panelId) {
|
|
||||||
snapshotFlow {
|
|
||||||
val info = listState.layoutInfo
|
|
||||||
Triple(
|
|
||||||
info.totalItemsCount,
|
|
||||||
listState.firstVisibleItemIndex,
|
|
||||||
listState.firstVisibleItemScrollOffset,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.distinctUntilChanged()
|
|
||||||
.collect { (totalItems, firstIndex, firstOffset) ->
|
|
||||||
// #region agent log
|
|
||||||
agentDebugLog(
|
|
||||||
hypothesisId = "H1",
|
|
||||||
location = "ChatScreen.kt:196",
|
|
||||||
message = "scroll_state_changed",
|
|
||||||
data = mapOf(
|
|
||||||
"panelId" to panelId,
|
|
||||||
"totalItems" to totalItems,
|
|
||||||
"firstVisibleItemIndex" to firstIndex,
|
|
||||||
"firstVisibleItemScrollOffset" to firstOffset,
|
|
||||||
"isNearBottom" to listState.isChatNearBottom(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// #endregion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } }
|
val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -360,11 +315,20 @@ fun ChatScreen(
|
|||||||
visitEnterSyncComplete = true
|
visitEnterSyncComplete = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// First non-empty load for this visit: never animate existing history.
|
// First non-empty load for this visit: seed history without animating.
|
||||||
|
// Exception: empty → single live/own message should animate (first bubble in chat).
|
||||||
if (!enterAnimationsSeeded || previousFingerprint.isEmpty()) {
|
if (!enterAnimationsSeeded || previousFingerprint.isEmpty()) {
|
||||||
|
val isLiveFirstMessage =
|
||||||
|
previousCount == 0 &&
|
||||||
|
messages.size == 1 &&
|
||||||
|
sizeDelta == 1
|
||||||
|
if (!isLiveFirstMessage) {
|
||||||
seedWithoutAnimating("seed_first_load")
|
seedWithoutAnimating("seed_first_load")
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
enterAnimationsSeeded = true
|
||||||
|
visitEnterSyncComplete = true
|
||||||
|
}
|
||||||
|
|
||||||
previousNewestFingerprint = fingerprint
|
previousNewestFingerprint = fingerprint
|
||||||
previousEnterMessageCount = messages.size
|
previousEnterMessageCount = messages.size
|
||||||
@@ -458,6 +422,7 @@ fun ChatScreen(
|
|||||||
val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
|
val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
|
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val profileUserId = panelState.profileUserId
|
val profileUserId = panelState.profileUserId
|
||||||
@@ -485,6 +450,7 @@ fun ChatScreen(
|
|||||||
userId = userId,
|
userId = userId,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
deleted = profile.deleted,
|
deleted = profile.deleted,
|
||||||
|
suspended = profile.suspended,
|
||||||
username = profile.username,
|
username = profile.username,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -745,14 +711,25 @@ fun ChatScreen(
|
|||||||
msg.id == menuMessage.id
|
msg.id == menuMessage.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (liveMessage == null) {
|
if (liveMessage == null) {
|
||||||
contextMenuState = contextMenuState.copy(isOpen = false, message = null)
|
contextMenuState = contextMenuState.copy(isOpen = false, message = null)
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
val liveAuthor = liveMessage.user_id == currentUserId
|
val liveAuthor = liveMessage.user_id == currentUserId
|
||||||
val liveFp = messageContextMenuFingerprint(liveMessage, liveAuthor, isReadOnly)
|
val liveFp = messageContextMenuFingerprint(
|
||||||
|
liveMessage,
|
||||||
|
liveAuthor,
|
||||||
|
liveAuthor || currentUserId == 1,
|
||||||
|
isReadOnly,
|
||||||
|
)
|
||||||
val menuAuthor = menuMessage.user_id == currentUserId
|
val menuAuthor = menuMessage.user_id == currentUserId
|
||||||
val menuFp = messageContextMenuFingerprint(menuMessage, menuAuthor, isReadOnly)
|
val menuFp = messageContextMenuFingerprint(
|
||||||
|
menuMessage,
|
||||||
|
menuAuthor,
|
||||||
|
menuAuthor || currentUserId == 1,
|
||||||
|
isReadOnly,
|
||||||
|
)
|
||||||
if (menuFp != liveFp || liveMessage.id != menuMessage.id) {
|
if (menuFp != liveFp || liveMessage.id != menuMessage.id) {
|
||||||
contextMenuState = contextMenuState.copy(message = liveMessage)
|
contextMenuState = contextMenuState.copy(message = liveMessage)
|
||||||
}
|
}
|
||||||
@@ -1351,23 +1328,6 @@ fun ChatScreen(
|
|||||||
val showScrollToBottomFab = !isNearBottom &&
|
val showScrollToBottomFab = !isNearBottom &&
|
||||||
panelState.messages.isNotEmpty() &&
|
panelState.messages.isNotEmpty() &&
|
||||||
!contextMenuState.isOpen
|
!contextMenuState.isOpen
|
||||||
LaunchedEffect(showScrollToBottomFab) {
|
|
||||||
// #region agent log
|
|
||||||
agentDebugLog(
|
|
||||||
hypothesisId = "H2",
|
|
||||||
location = "ChatScreen.kt:1338",
|
|
||||||
message = "show_scroll_button_changed",
|
|
||||||
data = mapOf(
|
|
||||||
"panelId" to panelId,
|
|
||||||
"showScrollToBottomFab" to showScrollToBottomFab,
|
|
||||||
"isNearBottom" to isNearBottom,
|
|
||||||
"messageCount" to panelState.messages.size,
|
|
||||||
"contextMenuOpen" to contextMenuState.isOpen,
|
|
||||||
"usesPublicGroupSubtitle" to panel.usesPublicGroupSubtitle,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// #endregion
|
|
||||||
}
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showScrollToBottomFab,
|
visible = showScrollToBottomFab,
|
||||||
enter = fadeIn(
|
enter = fadeIn(
|
||||||
@@ -1405,19 +1365,6 @@ fun ChatScreen(
|
|||||||
ChatScrollToBottomButton(
|
ChatScrollToBottomButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// #region agent log
|
|
||||||
agentDebugLog(
|
|
||||||
hypothesisId = "H5",
|
|
||||||
location = "ChatScreen.kt:1402",
|
|
||||||
message = "scroll_button_jump_to_bottom",
|
|
||||||
data = mapOf(
|
|
||||||
"panelId" to panelId,
|
|
||||||
"fromIndex" to listState.firstVisibleItemIndex,
|
|
||||||
"fromOffset" to listState.firstVisibleItemScrollOffset,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
// Snap list to bottom first so content is correct.
|
// Snap list to bottom first so content is correct.
|
||||||
listState.scrollChatToBottom()
|
listState.scrollChatToBottom()
|
||||||
|
|
||||||
@@ -1440,7 +1387,9 @@ fun ChatScreen(
|
|||||||
|
|
||||||
ChatTopBar(
|
ChatTopBar(
|
||||||
hazeState = hazeState,
|
hazeState = hazeState,
|
||||||
onBack = { navController.navigateUp() },
|
onBack = {
|
||||||
|
runNav { navController.navigateUp() }
|
||||||
|
},
|
||||||
backContentDescription = stringResource(Res.string.back),
|
backContentDescription = stringResource(Res.string.back),
|
||||||
showCallButton = panel.showCallButton() && !isReadOnly,
|
showCallButton = panel.showCallButton() && !isReadOnly,
|
||||||
onCallClick = {
|
onCallClick = {
|
||||||
@@ -1455,7 +1404,9 @@ fun ChatScreen(
|
|||||||
title = panelState.title,
|
title = panelState.title,
|
||||||
titleAvatar = panelState.titleAvatar,
|
titleAvatar = panelState.titleAvatar,
|
||||||
profileUserId = profileUserId,
|
profileUserId = profileUserId,
|
||||||
onTitleClick = onTitleClick,
|
onTitleClick = onTitleClick?.let { click ->
|
||||||
|
{ runNav(click) }
|
||||||
|
},
|
||||||
hideTitleBarAvatar = hideTitleBarAvatar,
|
hideTitleBarAvatar = hideTitleBarAvatar,
|
||||||
onAvatarSlotBounds = onAvatarSlotBounds,
|
onAvatarSlotBounds = onAvatarSlotBounds,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
@@ -1482,6 +1433,8 @@ fun ChatScreen(
|
|||||||
MessageContextMenu(
|
MessageContextMenu(
|
||||||
state = contextMenuState,
|
state = contextMenuState,
|
||||||
isAuthor = contextMenuState.message?.user_id == currentUserId,
|
isAuthor = contextMenuState.message?.user_id == currentUserId,
|
||||||
|
canDelete = contextMenuState.message?.user_id == currentUserId ||
|
||||||
|
currentUserId == 1,
|
||||||
isReadOnly = isReadOnly,
|
isReadOnly = isReadOnly,
|
||||||
screenWidthPx = screenWidthPx,
|
screenWidthPx = screenWidthPx,
|
||||||
screenHeightPx = screenHeightPx,
|
screenHeightPx = screenHeightPx,
|
||||||
@@ -1636,16 +1589,6 @@ private fun ChatScrollToBottomButton(
|
|||||||
.size(ChatScrollToBottomButtonSize)
|
.size(ChatScrollToBottomButtonSize)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable {
|
.clickable {
|
||||||
// #region agent log
|
|
||||||
agentDebugLog(
|
|
||||||
hypothesisId = "H3",
|
|
||||||
location = "ChatScreen.kt:1519",
|
|
||||||
message = "scroll_button_clicked",
|
|
||||||
data = mapOf(
|
|
||||||
"contentDescription" to contentDescription,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// #endregion
|
|
||||||
onClick()
|
onClick()
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -1665,70 +1608,6 @@ private fun ChatScrollToBottomButton(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun agentDebugLog(
|
|
||||||
hypothesisId: String,
|
|
||||||
location: String,
|
|
||||||
message: String,
|
|
||||||
data: Map<String, Any?> = emptyMap(),
|
|
||||||
runId: String = "initial",
|
|
||||||
) {
|
|
||||||
val timestamp = kotlin.time.Clock.System.now().toEpochMilliseconds()
|
|
||||||
fun escape(value: String): String {
|
|
||||||
return value
|
|
||||||
.replace("\\", "\\\\")
|
|
||||||
.replace("\"", "\\\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
val dataJson = buildString {
|
|
||||||
append("{")
|
|
||||||
data.entries.joinToString(",") { (key, rawValue) ->
|
|
||||||
val value = rawValue?.toString() ?: "null"
|
|
||||||
"\"${escape(key)}\":\"${escape(value)}\""
|
|
||||||
}.let { append(it) }
|
|
||||||
append("}")
|
|
||||||
}
|
|
||||||
|
|
||||||
val json = buildString {
|
|
||||||
append("{")
|
|
||||||
append("\"sessionId\":\"a042f7\",")
|
|
||||||
append("\"id\":\"log_")
|
|
||||||
append(timestamp)
|
|
||||||
append("_")
|
|
||||||
append(escape(hypothesisId))
|
|
||||||
append("\",")
|
|
||||||
append("\"timestamp\":")
|
|
||||||
append(timestamp)
|
|
||||||
append(",")
|
|
||||||
append("\"location\":\"")
|
|
||||||
append(escape(location))
|
|
||||||
append("\",")
|
|
||||||
append("\"message\":\"")
|
|
||||||
append(escape(message))
|
|
||||||
append("\",")
|
|
||||||
append("\"runId\":\"")
|
|
||||||
append(escape(runId))
|
|
||||||
append("\",")
|
|
||||||
append("\"hypothesisId\":\"")
|
|
||||||
append(escape(hypothesisId))
|
|
||||||
append("\",")
|
|
||||||
append("\"data\":")
|
|
||||||
append(dataJson)
|
|
||||||
append("}")
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.d("AgentDebug", json)
|
|
||||||
|
|
||||||
kotlinx.coroutines.GlobalScope.launch {
|
|
||||||
runCatching {
|
|
||||||
ApiClient.http.post("http://192.168.1.6:7629/ingest/edf4b1c4-ae73-4d46-9110-6235fc587a70") {
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
header("X-Debug-Session-Id", "a042f7")
|
|
||||||
setBody(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun LazyListState.scrollChatMessageToCenter(
|
private suspend fun LazyListState.scrollChatMessageToCenter(
|
||||||
lazyIndex: Int,
|
lazyIndex: Int,
|
||||||
topClearancePx: Int,
|
topClearancePx: Int,
|
||||||
|
|||||||
@@ -32,8 +32,14 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.navigation.NavController
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
@@ -68,6 +74,7 @@ import ru.fromchat.ui.profile.StatusBadge
|
|||||||
import ru.fromchat.ui.profile.peerIsDeleted
|
import ru.fromchat.ui.profile.peerIsDeleted
|
||||||
import ru.fromchat.ui.profile.resolveVerificationStatus
|
import ru.fromchat.ui.profile.resolveVerificationStatus
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import com.pr0gramm3r101.utils.scaleOnPress
|
import com.pr0gramm3r101.utils.scaleOnPress
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
@@ -258,8 +265,12 @@ fun ChatTopBarInner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
key == "typing" -> {
|
key == "typing" -> {
|
||||||
|
val me = ApiClient.user?.id
|
||||||
TypingIndicator(
|
TypingIndicator(
|
||||||
typingUsers = currentTypingUsers.map { it.username },
|
typingUsers = currentTypingUsers.map { user ->
|
||||||
|
ProfileCache.get(user.userId)?.visibleDisplayName(me)
|
||||||
|
?: user.username
|
||||||
|
},
|
||||||
showUsernames = typingShowsUsernames,
|
showUsernames = typingShowsUsernames,
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
)
|
)
|
||||||
@@ -447,3 +458,28 @@ fun rememberChatSurfaceContainerHazeStyle(): HazeStyle {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-flight gate for chat title/avatar navigate vs back during shared-element transitions.
|
||||||
|
* Ignores taps while a transition is running or the current destination is not resumed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberChatNavigationGate(
|
||||||
|
navController: NavController,
|
||||||
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
|
): (() -> Unit) -> Unit {
|
||||||
|
var locked by remember { mutableStateOf(false) }
|
||||||
|
val entry = navController.currentBackStackEntry
|
||||||
|
LaunchedEffect(entry) {
|
||||||
|
locked = false
|
||||||
|
}
|
||||||
|
return { action ->
|
||||||
|
val transitionRunning = animatedVisibilityScope?.transition?.isRunning == true
|
||||||
|
val lifecycleOk = navController.currentBackStackEntry?.lifecycle?.currentState
|
||||||
|
?.let { it == Lifecycle.State.RESUMED } != false
|
||||||
|
if (!locked && !transitionRunning && lifecycleOk) {
|
||||||
|
locked = true
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.offset
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.systemBars
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -715,7 +714,7 @@ fun ImageFullscreenPreview(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.windowInsetsPadding(WindowInsets.systemBars),
|
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = effectiveMenusVisible,
|
visible = effectiveMenusVisible,
|
||||||
@@ -796,6 +795,9 @@ fun ImageFullscreenPreview(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (currentUserId != null &&
|
||||||
|
(message.user_id == currentUserId || currentUserId == 1)
|
||||||
|
) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -808,20 +810,21 @@ fun ImageFullscreenPreview(
|
|||||||
menuExpanded = false
|
menuExpanded = false
|
||||||
onDelete(message)
|
onDelete(message)
|
||||||
dismissRequested = true
|
dismissRequested = true
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bottom: message text
|
// Bottom: message text
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.windowInsetsPadding(WindowInsets.systemBars),
|
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = effectiveMenusVisible && message.content.isNotBlank(),
|
visible = effectiveMenusVisible && message.content.isNotBlank(),
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ data class ContextMenuState(
|
|||||||
internal fun messageContextMenuFingerprint(
|
internal fun messageContextMenuFingerprint(
|
||||||
message: Message,
|
message: Message,
|
||||||
isAuthor: Boolean,
|
isAuthor: Boolean,
|
||||||
|
canDelete: Boolean,
|
||||||
isReadOnly: Boolean,
|
isReadOnly: Boolean,
|
||||||
): String {
|
): String {
|
||||||
val isQueued = message.isQueuedOutbound() && isAuthor
|
val isQueued = message.isQueuedOutbound() && isAuthor
|
||||||
@@ -88,7 +89,7 @@ internal fun messageContextMenuFingerprint(
|
|||||||
if (!isQueued && !isReadOnly) {
|
if (!isQueued && !isReadOnly) {
|
||||||
append("|reply=1")
|
append("|reply=1")
|
||||||
append("|edit=").append(isAuthor && !corrupted)
|
append("|edit=").append(isAuthor && !corrupted)
|
||||||
append("|del=").append(isAuthor)
|
append("|del=").append(canDelete)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,6 +98,7 @@ internal fun messageContextMenuFingerprint(
|
|||||||
fun MessageContextMenu(
|
fun MessageContextMenu(
|
||||||
state: ContextMenuState,
|
state: ContextMenuState,
|
||||||
isAuthor: Boolean,
|
isAuthor: Boolean,
|
||||||
|
canDelete: Boolean = isAuthor,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onReply: (Message) -> Unit,
|
onReply: (Message) -> Unit,
|
||||||
onEdit: (Message) -> Unit,
|
onEdit: (Message) -> Unit,
|
||||||
@@ -161,6 +163,7 @@ fun MessageContextMenu(
|
|||||||
ContextMenuContent(
|
ContextMenuContent(
|
||||||
message = state.message,
|
message = state.message,
|
||||||
isAuthor = isAuthor,
|
isAuthor = isAuthor,
|
||||||
|
canDelete = canDelete,
|
||||||
onReply = {},
|
onReply = {},
|
||||||
onEdit = {},
|
onEdit = {},
|
||||||
onDelete = {},
|
onDelete = {},
|
||||||
@@ -242,6 +245,7 @@ fun MessageContextMenu(
|
|||||||
ContextMenuContent(
|
ContextMenuContent(
|
||||||
message = state.message,
|
message = state.message,
|
||||||
isAuthor = isAuthor,
|
isAuthor = isAuthor,
|
||||||
|
canDelete = canDelete,
|
||||||
onReply = {
|
onReply = {
|
||||||
onReply(it)
|
onReply(it)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
@@ -287,6 +291,7 @@ fun MessageContextMenu(
|
|||||||
private fun ContextMenuContent(
|
private fun ContextMenuContent(
|
||||||
message: Message,
|
message: Message,
|
||||||
isAuthor: Boolean,
|
isAuthor: Boolean,
|
||||||
|
canDelete: Boolean,
|
||||||
onReply: (Message) -> Unit,
|
onReply: (Message) -> Unit,
|
||||||
onEdit: (Message) -> Unit,
|
onEdit: (Message) -> Unit,
|
||||||
onDelete: (Message) -> Unit,
|
onDelete: (Message) -> Unit,
|
||||||
@@ -402,7 +407,7 @@ private fun ContextMenuContent(
|
|||||||
onClick = { onEdit(message) }
|
onClick = { onEdit(message) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isAuthor) {
|
if (canDelete) {
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
icon = Icons.Rounded.Delete,
|
icon = Icons.Rounded.Delete,
|
||||||
text = labelDelete,
|
text = labelDelete,
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import ru.fromchat.api.local.db.store.visibleDisplayName
|
|||||||
import ru.fromchat.ui.profile.avatarLabelForInitials
|
import ru.fromchat.ui.profile.avatarLabelForInitials
|
||||||
import ru.fromchat.message_sender_you
|
import ru.fromchat.message_sender_you
|
||||||
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
|
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
|
||||||
import ru.fromchat.ui.profile.isDeletedAccount
|
|
||||||
import ru.fromchat.ui.profile.isDeletedAccountUsername
|
import ru.fromchat.ui.profile.isDeletedAccountUsername
|
||||||
|
import ru.fromchat.ui.profile.isRedactedPeerAccount
|
||||||
import ru.fromchat.ui.profile.peerIsDeleted
|
import ru.fromchat.ui.profile.peerIsDeleted
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +23,7 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
|
|||||||
return stringResource(Res.string.message_sender_you)
|
return stringResource(Res.string.message_sender_you)
|
||||||
}
|
}
|
||||||
ProfileCache.get(message.user_id)?.let { profile ->
|
ProfileCache.get(message.user_id)?.let { profile ->
|
||||||
if (profile.isDeletedAccount(currentUserId) || isDeletedAccountUsername(profile.username)) {
|
if (profile.isRedactedPeerAccount(currentUserId)) {
|
||||||
return deletedUserDisplayNameForUi()
|
return deletedUserDisplayNameForUi()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,10 @@ fun messageSenderProfilePicture(
|
|||||||
message: Message,
|
message: Message,
|
||||||
currentUserId: Int? = ApiClient.user?.id,
|
currentUserId: Int? = ApiClient.user?.id,
|
||||||
): String? {
|
): String? {
|
||||||
if (ProfileCache.get(message.user_id)?.isDeletedAccount(currentUserId) == true) {
|
if (ProfileCache.get(message.user_id)?.isRedactedPeerAccount(currentUserId) == true) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (isDeletedAccountUsername(message.username)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (currentUserId != null && message.user_id == currentUserId) {
|
if (currentUserId != null && message.user_id == currentUserId) {
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ fun MessageItem(
|
|||||||
isMessageCorrupted(message)
|
isMessageCorrupted(message)
|
||||||
}
|
}
|
||||||
val primaryIsImageMessage = remember(
|
val primaryIsImageMessage = remember(
|
||||||
message.reply_to,
|
|
||||||
message.pendingFileUri,
|
message.pendingFileUri,
|
||||||
message.pendingFilename,
|
message.pendingFilename,
|
||||||
message.files,
|
message.files,
|
||||||
@@ -219,10 +218,8 @@ fun MessageItem(
|
|||||||
)
|
)
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
message.reply_to == null && (
|
|
||||||
pendingIsImage ||
|
pendingIsImage ||
|
||||||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
|
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
|
||||||
)
|
|
||||||
}
|
}
|
||||||
val formattedTime = remember(message.timestamp) {
|
val formattedTime = remember(message.timestamp) {
|
||||||
formatMessageTimeLocal(message.timestamp)
|
formatMessageTimeLocal(message.timestamp)
|
||||||
@@ -569,10 +566,8 @@ fun MessageItem(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val primaryIsImageContent = message.reply_to == null && (
|
val primaryIsImageContent = pendingIsImage ||
|
||||||
pendingIsImage ||
|
|
||||||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
|
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
|
||||||
)
|
|
||||||
val hasImageCaption =
|
val hasImageCaption =
|
||||||
message.content.isNotBlank() &&
|
message.content.isNotBlank() &&
|
||||||
!isFilenameOnlyMessageCaption(message)
|
!isFilenameOnlyMessageCaption(message)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ru.fromchat.api.local.cache.CacheContext
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.ui.chat.rememberChatNavigationGate
|
||||||
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
import ru.fromchat.utils.haptic.rememberHapticFeedback
|
import ru.fromchat.utils.haptic.rememberHapticFeedback
|
||||||
@@ -46,6 +47,7 @@ fun DmChatRoute(
|
|||||||
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||||
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
|
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
|
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
|
||||||
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
|
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
|
||||||
|
|
||||||
DmScreen(
|
DmScreen(
|
||||||
@@ -54,8 +56,10 @@ fun DmChatRoute(
|
|||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
scrollToMessageId = scrollToMessageId,
|
scrollToMessageId = scrollToMessageId,
|
||||||
onTitleClick = {
|
onTitleClick = {
|
||||||
|
runNav {
|
||||||
haptic(HapticFeedbackEvent.ProfileOpened)
|
haptic(HapticFeedbackEvent.ProfileOpened)
|
||||||
navController.navigate(DmNav.profileRoute(otherUserId))
|
navController.navigate(DmNav.profileRoute(otherUserId))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
@@ -74,6 +78,7 @@ fun DmProfileRoute(
|
|||||||
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||||
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
|
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
|
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
|
||||||
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
|
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
|
||||||
val stateSnapshot = panel.getState()
|
val stateSnapshot = panel.getState()
|
||||||
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
|
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
|
||||||
@@ -92,12 +97,16 @@ fun DmProfileRoute(
|
|||||||
userId = otherUserId,
|
userId = otherUserId,
|
||||||
showBackButton = true,
|
showBackButton = true,
|
||||||
onBack = {
|
onBack = {
|
||||||
|
runNav {
|
||||||
haptic(HapticFeedbackEvent.ProfileClosed)
|
haptic(HapticFeedbackEvent.ProfileClosed)
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onChat = {
|
onChat = {
|
||||||
|
runNav {
|
||||||
haptic(HapticFeedbackEvent.ProfileClosed)
|
haptic(HapticFeedbackEvent.ProfileClosed)
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
|||||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
import ru.fromchat.api.schema.websocket.types.DmDeletedData
|
import ru.fromchat.api.schema.websocket.types.DmDeletedData
|
||||||
import ru.fromchat.ui.profile.displayNameText
|
import ru.fromchat.ui.profile.displayNameText
|
||||||
import ru.fromchat.ui.profile.isDeletedAccount
|
import ru.fromchat.ui.profile.isRedactedPeerAccount
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.config.ServerConfig
|
import ru.fromchat.config.ServerConfig
|
||||||
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
|
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
|
||||||
@@ -120,7 +120,7 @@ class DmPanel(
|
|||||||
try {
|
try {
|
||||||
val profile = ApiClient.getProfileById(otherUserId)
|
val profile = ApiClient.getProfileById(otherUserId)
|
||||||
if (
|
if (
|
||||||
!profile.isDeletedAccount(ApiClient.user?.id) &&
|
!profile.isRedactedPeerAccount(ApiClient.user?.id) &&
|
||||||
profile.username.isBlank() &&
|
profile.username.isBlank() &&
|
||||||
profile.displayName.isNullOrBlank()
|
profile.displayName.isNullOrBlank()
|
||||||
) {
|
) {
|
||||||
|
|||||||
+9
@@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||||
|
import ru.fromchat.ui.chat.rememberChatNavigationGate
|
||||||
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
|
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
|
||||||
import ru.fromchat.ui.profile.PublicChatProfileScreen
|
import ru.fromchat.ui.profile.PublicChatProfileScreen
|
||||||
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
||||||
@@ -31,6 +32,7 @@ fun PublicChatChatRoute(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
|
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
|
||||||
|
|
||||||
PublicChatScreen(
|
PublicChatScreen(
|
||||||
scrollToMessageId = scrollToMessageId,
|
scrollToMessageId = scrollToMessageId,
|
||||||
@@ -38,8 +40,10 @@ fun PublicChatChatRoute(
|
|||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY,
|
sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY,
|
||||||
onTitleClick = {
|
onTitleClick = {
|
||||||
|
runNav {
|
||||||
haptic(HapticFeedbackEvent.ProfileOpened)
|
haptic(HapticFeedbackEvent.ProfileOpened)
|
||||||
navController.navigate(PublicChatNav.PROFILE_ROUTE)
|
navController.navigate(PublicChatNav.PROFILE_ROUTE)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
@@ -57,6 +61,7 @@ fun PublicChatProfileRoute(
|
|||||||
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
|
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
|
||||||
}
|
}
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
|
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
|
||||||
val stateSnapshot = panel.getState()
|
val stateSnapshot = panel.getState()
|
||||||
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
|
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
|
||||||
?: stateSnapshot.title.takeIf { it.isNotBlank() }
|
?: stateSnapshot.title.takeIf { it.isNotBlank() }
|
||||||
@@ -65,12 +70,16 @@ fun PublicChatProfileRoute(
|
|||||||
PublicChatProfileScreen(
|
PublicChatProfileScreen(
|
||||||
showBackButton = true,
|
showBackButton = true,
|
||||||
onBack = {
|
onBack = {
|
||||||
|
runNav {
|
||||||
haptic(HapticFeedbackEvent.ProfileClosed)
|
haptic(HapticFeedbackEvent.ProfileClosed)
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onChat = {
|
onChat = {
|
||||||
|
runNav {
|
||||||
haptic(HapticFeedbackEvent.ProfileClosed)
|
haptic(HapticFeedbackEvent.ProfileClosed)
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
|
|||||||
+57
-5
@@ -236,6 +236,7 @@ class PublicChatPanel(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Match optimistic rows via [Message.client_message_id] from the server ack (never by text).
|
* Match optimistic rows via [Message.client_message_id] from the server ack (never by text).
|
||||||
|
* When the server omits client_message_id, fall back to a single pending own optimistic.
|
||||||
*/
|
*/
|
||||||
private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) {
|
private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) {
|
||||||
val laidOut = newMsg.resolvePublicAttachmentLayout()
|
val laidOut = newMsg.resolvePublicAttachmentLayout()
|
||||||
@@ -247,12 +248,56 @@ class PublicChatPanel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (laidOut.id > 0 && _state.messages.any { it.id == laidOut.id }) {
|
if (laidOut.id > 0 && _state.messages.any { it.id == laidOut.id }) {
|
||||||
|
// Already confirmed in UI — still try to clear a leftover optimistic.
|
||||||
|
matchPendingOptimisticClientId(laidOut)?.let { pendingCid ->
|
||||||
|
handleMessageConfirmed(
|
||||||
|
pendingCid,
|
||||||
|
laidOut.copy(client_message_id = pendingCid),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matchPendingOptimisticClientId(laidOut)?.let { pendingCid ->
|
||||||
|
handleMessageConfirmed(
|
||||||
|
pendingCid,
|
||||||
|
laidOut.copy(client_message_id = pendingCid),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ingestIncomingPublicMessage(laidOut)
|
ingestIncomingPublicMessage(laidOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Best-effort: unique pending own optimistic that looks like [confirmed]. */
|
||||||
|
private fun matchPendingOptimisticClientId(confirmed: Message): String? {
|
||||||
|
val pending = snapshotPendingOptimisticMessages().filter {
|
||||||
|
it.user_id == confirmed.user_id && it.id < 0
|
||||||
|
}
|
||||||
|
if (pending.isEmpty()) return null
|
||||||
|
val confirmedCid = confirmed.client_message_id?.trim().orEmpty()
|
||||||
|
if (confirmedCid.isNotEmpty()) {
|
||||||
|
pending.firstOrNull { it.client_message_id?.trim() == confirmedCid }
|
||||||
|
?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { return it }
|
||||||
|
}
|
||||||
|
if (pending.size == 1) {
|
||||||
|
return pending.first().client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
val confirmedTime = ru.fromchat.api.local.messages.parseMessageTimestampMillis(confirmed.timestamp)
|
||||||
|
val content = confirmed.content.trim()
|
||||||
|
val matches = pending.filter { opt ->
|
||||||
|
val optCid = opt.client_message_id?.trim().orEmpty()
|
||||||
|
if (optCid.isEmpty()) return@filter false
|
||||||
|
if (opt.content.trim() != content && confirmed.files.isNullOrEmpty() && opt.files.isNullOrEmpty()) {
|
||||||
|
return@filter false
|
||||||
|
}
|
||||||
|
val optTime = ru.fromchat.api.local.messages.parseMessageTimestampMillis(opt.timestamp)
|
||||||
|
confirmedTime == null || optTime == null ||
|
||||||
|
kotlin.math.abs(confirmedTime - optTime) <= 180_000L
|
||||||
|
}
|
||||||
|
return matches.singleOrNull()?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
|
private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
|
||||||
ProfileCache.mergePreviewFromPublicMessage(newMsg)
|
ProfileCache.mergePreviewFromPublicMessage(newMsg)
|
||||||
val withReply = attachPublicReplyReferences(_state.messages + newMsg).last()
|
val withReply = attachPublicReplyReferences(_state.messages + newMsg).last()
|
||||||
@@ -274,7 +319,12 @@ class PublicChatPanel(
|
|||||||
mergeMessageUiFields(net, local)
|
mergeMessageUiFields(net, local)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val ahead = shown.filter { it.id > 0 && it.id !in networkIds }
|
val minNetworkId = fromNetwork.minOfOrNull { it.id } ?: Int.MAX_VALUE
|
||||||
|
val maxNetworkId = fromNetwork.maxOfOrNull { it.id } ?: Int.MIN_VALUE
|
||||||
|
// Keep only messages strictly newer/older than the network page — not in-window gaps
|
||||||
|
// (those are server-side deletes that must not be resurrected from cache).
|
||||||
|
val ahead = shown.filter { it.id > 0 && it.id !in networkIds && it.id > maxNetworkId }
|
||||||
|
val older = shown.filter { it.id > 0 && it.id !in networkIds && it.id < minNetworkId }
|
||||||
val inFlight = shown.filter { msg ->
|
val inFlight = shown.filter { msg ->
|
||||||
msg.id < 0 && (
|
msg.id < 0 && (
|
||||||
!msg.client_message_id.isNullOrBlank() ||
|
!msg.client_message_id.isNullOrBlank() ||
|
||||||
@@ -282,8 +332,8 @@ class PublicChatPanel(
|
|||||||
!msg.uploadJobId.isNullOrBlank()
|
!msg.uploadJobId.isNullOrBlank()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (ahead.isEmpty() && inFlight.isEmpty()) return merged
|
if (ahead.isEmpty() && older.isEmpty() && inFlight.isEmpty()) return merged
|
||||||
val combined = merged + ahead + inFlight
|
val combined = merged + ahead + older + inFlight
|
||||||
return ru.fromchat.api.local.messages.sortMessagesForChatDisplay(
|
return ru.fromchat.api.local.messages.sortMessagesForChatDisplay(
|
||||||
ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(combined),
|
ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(combined),
|
||||||
)
|
)
|
||||||
@@ -493,8 +543,7 @@ class PublicChatPanel(
|
|||||||
removeMessage(deletedData.message_id)
|
removeMessage(deletedData.message_id)
|
||||||
clearReplyReferencesTo(deletedData.message_id)
|
clearReplyReferencesTo(deletedData.message_id)
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageRepository.markPublicMessageDeleted(deletedData.message_id)
|
MessageRepository.deletePublicMessageById(deletedData.message_id)
|
||||||
MessageCacheStore.replacePublicMessages(_state.messages)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"reactionUpdate" -> {
|
"reactionUpdate" -> {
|
||||||
@@ -572,6 +621,9 @@ class PublicChatPanel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
beginMessageDissolve(message)
|
beginMessageDissolve(message)
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
MessageRepository.deletePublicMessageById(messageId)
|
||||||
|
}
|
||||||
ApiClient.deleteMessage(messageId)
|
ApiClient.deleteMessage(messageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ internal fun messageDedupeKey(msg: Message): String {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drops optimistic rows already represented by a confirmed message (same client id),
|
* Drops optimistic rows already represented by a confirmed message (same client id),
|
||||||
* or legacy near-duplicate own rows that have no client id.
|
* or near-duplicate own rows when the confirmed ack omitted client_message_id.
|
||||||
*
|
*
|
||||||
* In-flight sends with a [Message.client_message_id] that is not yet confirmed must be kept —
|
* In-flight sends with a [Message.client_message_id] that is not yet confirmed must be kept
|
||||||
* time-based heuristics must not remove them (that aborted enter animations mid-spring).
|
* unless a confirmed own row without client id is a clear near-duplicate (1:1 pairing).
|
||||||
*/
|
*/
|
||||||
internal fun dropSupersededOptimisticMessages(
|
internal fun dropSupersededOptimisticMessages(
|
||||||
messages: List<Message>,
|
messages: List<Message>,
|
||||||
@@ -48,17 +48,27 @@ internal fun dropSupersededOptimisticMessages(
|
|||||||
val confirmed = messages.filter { it.id > 0 }
|
val confirmed = messages.filter { it.id > 0 }
|
||||||
val confirmedClientIds = confirmed.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet()
|
val confirmedClientIds = confirmed.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet()
|
||||||
val self = currentUserId
|
val self = currentUserId
|
||||||
|
val usedConfirmedIds = mutableSetOf<Int>()
|
||||||
return messages.filter { msg ->
|
return messages.filter { msg ->
|
||||||
if (msg.id >= 0) return@filter true
|
if (msg.id >= 0) return@filter true
|
||||||
val cid = msg.client_message_id?.trim().orEmpty()
|
val cid = msg.client_message_id?.trim().orEmpty()
|
||||||
if (cid.isNotEmpty() && cid in confirmedClientIds) return@filter false
|
if (cid.isNotEmpty() && cid in confirmedClientIds) return@filter false
|
||||||
// Stable client id still in flight — never drop via time heuristics.
|
|
||||||
if (cid.isNotEmpty()) return@filter true
|
|
||||||
// In-flight uploads (file or image): keep until a confirmed row shares the same client id.
|
|
||||||
if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true
|
|
||||||
if (self == null || msg.user_id != self) return@filter true
|
if (self == null || msg.user_id != self) return@filter true
|
||||||
|
|
||||||
|
// Match confirmed acks that omitted client_message_id (phantom duplicate).
|
||||||
|
if (cid.isNotEmpty()) {
|
||||||
|
val matched = findNearOwnConfirmedWithoutClientId(msg, confirmed, self, usedConfirmedIds)
|
||||||
|
if (matched != null) {
|
||||||
|
usedConfirmedIds.add(matched.id)
|
||||||
|
return@filter false
|
||||||
|
}
|
||||||
|
return@filter true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy: no client id on pending — time-based near-dup.
|
||||||
|
if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true
|
||||||
val msgTime = parseMessageTimestampMillis(msg.timestamp)
|
val msgTime = parseMessageTimestampMillis(msg.timestamp)
|
||||||
val nearOwnConfirmed = confirmed.filter { it.user_id == self }.any { confirmedMsg ->
|
val nearOwnConfirmed = confirmed.filter { it.user_id == self && it.id !in usedConfirmedIds }.any { confirmedMsg ->
|
||||||
val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp)
|
val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp)
|
||||||
msgTime != null && confirmedTime != null &&
|
msgTime != null && confirmedTime != null &&
|
||||||
abs(msgTime - confirmedTime) <= NEAR_DUPLICATE_MS
|
abs(msgTime - confirmedTime) <= NEAR_DUPLICATE_MS
|
||||||
@@ -77,6 +87,34 @@ internal fun dropSupersededOptimisticMessages(
|
|||||||
|
|
||||||
private const val NEAR_DUPLICATE_MS = 180_000L
|
private const val NEAR_DUPLICATE_MS = 180_000L
|
||||||
|
|
||||||
|
private fun findNearOwnConfirmedWithoutClientId(
|
||||||
|
pending: Message,
|
||||||
|
confirmed: List<Message>,
|
||||||
|
self: Int,
|
||||||
|
usedConfirmedIds: Set<Int>,
|
||||||
|
): Message? {
|
||||||
|
val msgTime = parseMessageTimestampMillis(pending.timestamp) ?: return null
|
||||||
|
val pendingIsAttachment = !pending.files.isNullOrEmpty() ||
|
||||||
|
pending.pendingFileUri != null ||
|
||||||
|
!pending.uploadJobId.isNullOrBlank()
|
||||||
|
val candidates = confirmed.filter { confirmedMsg ->
|
||||||
|
if (confirmedMsg.user_id != self) return@filter false
|
||||||
|
if (confirmedMsg.id in usedConfirmedIds) return@filter false
|
||||||
|
if (!confirmedMsg.client_message_id.isNullOrBlank()) return@filter false
|
||||||
|
val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp) ?: return@filter false
|
||||||
|
if (abs(msgTime - confirmedTime) > NEAR_DUPLICATE_MS) return@filter false
|
||||||
|
val confirmedHasAttachment = !confirmedMsg.files.isNullOrEmpty()
|
||||||
|
val contentMatches = pending.content.trim() == confirmedMsg.content.trim()
|
||||||
|
when {
|
||||||
|
pendingIsAttachment && confirmedHasAttachment -> true
|
||||||
|
!pendingIsAttachment && !confirmedHasAttachment && contentMatches -> true
|
||||||
|
!pendingIsAttachment && confirmedHasAttachment && contentMatches -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates.minByOrNull { abs((parseMessageTimestampMillis(it.timestamp) ?: 0L) - msgTime) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun preferMessageForDedupe(existing: Message, incoming: Message): Message {
|
private fun preferMessageForDedupe(existing: Message, incoming: Message): Message {
|
||||||
val preferred = when {
|
val preferred = when {
|
||||||
incoming.id > 0 && existing.id < 0 -> incoming
|
incoming.id > 0 && existing.id < 0 -> incoming
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ internal fun attachPublicReplyReferences(
|
|||||||
return messages.map { msg ->
|
return messages.map { msg ->
|
||||||
val replyId = parsedReplyIds[msg.id] ?: resolvePublicReplyToId(msg) ?: return@map msg
|
val replyId = parsedReplyIds[msg.id] ?: resolvePublicReplyToId(msg) ?: return@map msg
|
||||||
val nested = msg.reply_to
|
val nested = msg.reply_to
|
||||||
if (nested != null && nested.content.isNotBlank()) return@map msg
|
if (
|
||||||
|
nested != null &&
|
||||||
|
(nested.content.isNotBlank() || !nested.files.isNullOrEmpty())
|
||||||
|
) {
|
||||||
|
return@map msg
|
||||||
|
}
|
||||||
byId[replyId]?.let { msg.copy(reply_to = it, replyToId = replyId) } ?: msg
|
byId[replyId]?.let { msg.copy(reply_to = it, replyToId = replyId) } ?: msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ internal fun SearchConversationsList(
|
|||||||
userId = user.id,
|
userId = user.id,
|
||||||
currentUserId = ApiClient.user?.id,
|
currentUserId = ApiClient.user?.id,
|
||||||
deleted = user.deleted ?: cached?.deleted,
|
deleted = user.deleted ?: cached?.deleted,
|
||||||
|
suspended = user.suspended ?: cached?.suspended,
|
||||||
username = user.username,
|
username = user.username,
|
||||||
)
|
)
|
||||||
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture ?: user.profile_picture
|
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture ?: user.profile_picture
|
||||||
@@ -926,6 +927,7 @@ internal fun DmConversationRowContent(
|
|||||||
userId = conversation.otherUserId,
|
userId = conversation.otherUserId,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
deleted = cached?.deleted,
|
deleted = cached?.deleted,
|
||||||
|
suspended = cached?.suspended,
|
||||||
username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() },
|
username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
|
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.imePadding
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
@@ -247,6 +248,7 @@ fun ChatsSearchScreen(
|
|||||||
placeholder = searchBarHint,
|
placeholder = searchBarHint,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
|
|||||||
@@ -654,28 +654,12 @@ fun ChatsTab(
|
|||||||
|
|
||||||
fun deleteChats(userIds: Set<Int>) {
|
fun deleteChats(userIds: Set<Int>) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
var failures = 0
|
|
||||||
|
|
||||||
userIds.forEach { otherUserId ->
|
userIds.forEach { otherUserId ->
|
||||||
var ok = true
|
|
||||||
|
|
||||||
val messages = runCatching { MessageRepository.loadDmMessages(otherUserId) }
|
|
||||||
.getOrDefault(emptyList())
|
|
||||||
.filter { it.id > 0 }
|
|
||||||
|
|
||||||
messages.forEach { msg ->
|
|
||||||
runCatching {
|
runCatching {
|
||||||
ApiClient.deleteDm(msg.id, otherUserId)
|
ApiClient.archiveDmConversation(otherUserId, archived = true)
|
||||||
}.onFailure {
|
MessageRepository.archiveDmConversation(otherUserId)
|
||||||
ok = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runCatching {
|
|
||||||
MessageRepository.deleteDmConversation(otherUserId)
|
|
||||||
}.onFailure { ok = false }
|
|
||||||
|
|
||||||
if (!ok) failures++
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshDmList()
|
refreshDmList()
|
||||||
exitSelectionMode()
|
exitSelectionMode()
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ fun DevicesScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val hadContent = devices.isNotEmpty()
|
val hadContent = devices.isNotEmpty()
|
||||||
if (hadContent) refreshing = true
|
if (!hadContent) refreshing = true
|
||||||
|
|
||||||
runCatching { ApiClient.listDevices() }
|
runCatching { ApiClient.listDevices() }
|
||||||
.onSuccess { list ->
|
.onSuccess { list ->
|
||||||
@@ -570,11 +570,8 @@ fun DevicesScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
AnimatedVisibility(
|
// Initial load only — background poll must not add/remove list height (overscroll jump).
|
||||||
visible = refreshing,
|
if (refreshing && devices.isEmpty()) {
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ object SettingsRoutes {
|
|||||||
const val SecurityPasswordFlow = "settings/security/password"
|
const val SecurityPasswordFlow = "settings/security/password"
|
||||||
const val Account = "settings/account"
|
const val Account = "settings/account"
|
||||||
const val AccountDeleteFlow = "settings/account/delete"
|
const val AccountDeleteFlow = "settings/account/delete"
|
||||||
|
/** Confirm → OAuth WebView → Done (Done pops confirm+OAuth). */
|
||||||
|
const val AccountYandexFlow = "settings/account/yandex"
|
||||||
|
const val AccountYandexOAuth = "settings/account/yandex/oauth"
|
||||||
|
const val AccountYandexDone = "settings/account/yandex/done"
|
||||||
const val ServerConfig = "serverConfig"
|
const val ServerConfig = "serverConfig"
|
||||||
const val About = "about"
|
const val About = "about"
|
||||||
const val Logs = "settings/logs"
|
const val Logs = "settings/logs"
|
||||||
|
|||||||
+30
@@ -25,6 +25,7 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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
|
||||||
@@ -37,11 +38,15 @@ import com.pr0gramm3r101.components.Category
|
|||||||
import com.pr0gramm3r101.components.ListItem
|
import com.pr0gramm3r101.components.ListItem
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.jetbrains.compose.resources.vectorResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.back
|
import ru.fromchat.back
|
||||||
import ru.fromchat.cancel
|
import ru.fromchat.cancel
|
||||||
|
import ru.fromchat.ic_yandex
|
||||||
import ru.fromchat.logout
|
import ru.fromchat.logout
|
||||||
|
import ru.fromchat.settings_account_change_yandex
|
||||||
|
import ru.fromchat.settings_account_change_yandex_d
|
||||||
import ru.fromchat.settings_account_delete
|
import ru.fromchat.settings_account_delete
|
||||||
import ru.fromchat.settings_account_delete_d
|
import ru.fromchat.settings_account_delete_d
|
||||||
import ru.fromchat.settings_account_logout_confirm_body
|
import ru.fromchat.settings_account_logout_confirm_body
|
||||||
@@ -57,11 +62,18 @@ fun AccountScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onChangePassword: () -> Unit,
|
onChangePassword: () -> Unit,
|
||||||
|
onChangeYandexId: () -> Unit,
|
||||||
onDeleteAccount: () -> Unit,
|
onDeleteAccount: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showLogoutConfirm by remember { mutableStateOf(false) }
|
var showLogoutConfirm by remember { mutableStateOf(false) }
|
||||||
|
var yandexAvailable by remember { mutableStateOf(false) }
|
||||||
|
val yandexIcon = vectorResource(Res.drawable.ic_yandex)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
yandexAvailable = runCatching { ApiClient.getAccountYandex() }.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
@@ -103,6 +115,24 @@ fun AccountScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (yandexAvailable) {
|
||||||
|
ListItem(
|
||||||
|
headline = stringResource(Res.string.settings_account_change_yandex),
|
||||||
|
supportingText = stringResource(Res.string.settings_account_change_yandex_d),
|
||||||
|
onClick = onChangeYandexId,
|
||||||
|
leadingContent = { Icon(yandexIcon, null) },
|
||||||
|
divider = true,
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.settings_account_delete),
|
headline = stringResource(Res.string.settings_account_delete),
|
||||||
supportingText = stringResource(Res.string.settings_account_delete_d),
|
supportingText = stringResource(Res.string.settings_account_delete_d),
|
||||||
|
|||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
package ru.fromchat.ui.main.settings.account.changeyandex
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.jetbrains.compose.resources.vectorResource
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
|
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
|
||||||
|
import ru.fromchat.auth.yandex.buildYandexAuthorizeUrl
|
||||||
|
import ru.fromchat.auth.yandex.generatePkcePair
|
||||||
|
import ru.fromchat.auth.yandex.resolveYandexClientId
|
||||||
|
import ru.fromchat.auth_yandex_client_mismatch
|
||||||
|
import ru.fromchat.config.Settings
|
||||||
|
import ru.fromchat.error_unexpected
|
||||||
|
import ru.fromchat.ic_yandex
|
||||||
|
import ru.fromchat.settings_next
|
||||||
|
import ru.fromchat.settings_yandex_step_confirm_body
|
||||||
|
import ru.fromchat.settings_yandex_step_confirm_cta
|
||||||
|
import ru.fromchat.settings_yandex_step_confirm_title
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.components.ActionButton
|
||||||
|
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepPageHeader
|
||||||
|
import ru.fromchat.ui.components.Text
|
||||||
|
import ru.fromchat.ui.components.rememberExpressiveStepFlow
|
||||||
|
import ru.fromchat.ui.components.showReplacingSnackbar
|
||||||
|
import ru.fromchat.ui.isAppInDarkTheme
|
||||||
|
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ChangeYandexConfirmScreen(onBack: () -> Unit) {
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val flowState = rememberExpressiveStepFlow(1)
|
||||||
|
var yandex by remember { mutableStateOf<YandexOAuthParams?>(null) }
|
||||||
|
var loadingParams by remember { mutableStateOf(true) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
val darkTheme = isAppInDarkTheme()
|
||||||
|
val languageTag = Locale.current.toLanguageTag()
|
||||||
|
val yandexIcon = vectorResource(Res.drawable.ic_yandex)
|
||||||
|
|
||||||
|
val title = stringResource(Res.string.settings_yandex_step_confirm_title)
|
||||||
|
val body = stringResource(Res.string.settings_yandex_step_confirm_body)
|
||||||
|
val cta = stringResource(Res.string.settings_yandex_step_confirm_cta)
|
||||||
|
val next = stringResource(Res.string.settings_next)
|
||||||
|
val clientMismatch = stringResource(Res.string.auth_yandex_client_mismatch)
|
||||||
|
val unexpected = stringResource(Res.string.error_unexpected)
|
||||||
|
|
||||||
|
fun showSnack(text: String) {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showReplacingSnackbar(
|
||||||
|
message = text,
|
||||||
|
withDismissAction = false,
|
||||||
|
duration = SnackbarDuration.Short,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
ChangeYandexDraft.clear()
|
||||||
|
loadingParams = true
|
||||||
|
try {
|
||||||
|
yandex = ApiClient.getAccountYandex().yandex
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showSnack(e.message ?: unexpected)
|
||||||
|
} finally {
|
||||||
|
loadingParams = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
ExpressiveStepFlowScaffold(
|
||||||
|
flowState = flowState,
|
||||||
|
pages = listOf(
|
||||||
|
ExpressiveStepPage(
|
||||||
|
hero = ExpressiveHeroSpec(
|
||||||
|
icon = yandexIcon,
|
||||||
|
polygon = MaterialShapes.Cookie9Sided.normalized(),
|
||||||
|
containerColor = colorScheme.primaryContainer,
|
||||||
|
contentColor = colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
content = {
|
||||||
|
ExpressiveStepPageHeader(title = title, body = body)
|
||||||
|
},
|
||||||
|
button = {
|
||||||
|
ActionButton(
|
||||||
|
onClick = {
|
||||||
|
if (busy || loadingParams) return@ActionButton
|
||||||
|
val params = yandex
|
||||||
|
if (params == null) {
|
||||||
|
showSnack(unexpected)
|
||||||
|
return@ActionButton
|
||||||
|
}
|
||||||
|
busy = true
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
|
||||||
|
showSnack(it.message ?: unexpected)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val clientId = resolveYandexClientId(params.client_id, serverIp)
|
||||||
|
if (clientId == null) {
|
||||||
|
showSnack(clientMismatch)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val pkce = generatePkcePair()
|
||||||
|
ChangeYandexDraft.authorizeUrl = buildYandexAuthorizeUrl(
|
||||||
|
authorizeUrl = params.authorize_url,
|
||||||
|
clientId = clientId,
|
||||||
|
redirectUri = params.redirect_uri.ifBlank { YANDEX_OAUTH_REDIRECT_URI },
|
||||||
|
scope = params.scope,
|
||||||
|
codeChallenge = pkce.codeChallenge,
|
||||||
|
languageTag = languageTag,
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
)
|
||||||
|
ChangeYandexDraft.codeVerifier = pkce.codeVerifier
|
||||||
|
navController.navigate(SettingsRoutes.AccountYandexOAuth)
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !busy && !loadingParams && yandex != null,
|
||||||
|
loading = busy || loadingParams,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(if (busy || loadingParams) next else cta)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
onBackAtFirstPage = onBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package ru.fromchat.ui.main.settings.account.changeyandex
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.settings_done
|
||||||
|
import ru.fromchat.settings_yandex_step_done_body
|
||||||
|
import ru.fromchat.settings_yandex_step_done_title
|
||||||
|
import ru.fromchat.ui.components.ActionButton
|
||||||
|
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepPageHeader
|
||||||
|
import ru.fromchat.ui.components.Text
|
||||||
|
import ru.fromchat.ui.components.rememberExpressiveStepFlow
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ChangeYandexDoneScreen(onDone: () -> Unit) {
|
||||||
|
val flowState = rememberExpressiveStepFlow(1)
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val title = stringResource(Res.string.settings_yandex_step_done_title)
|
||||||
|
val body = stringResource(Res.string.settings_yandex_step_done_body)
|
||||||
|
val done = stringResource(Res.string.settings_done)
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
ExpressiveStepFlowScaffold(
|
||||||
|
flowState = flowState,
|
||||||
|
pages = listOf(
|
||||||
|
ExpressiveStepPage(
|
||||||
|
hero = ExpressiveHeroSpec(
|
||||||
|
icon = Icons.Filled.CheckCircle,
|
||||||
|
polygon = MaterialShapes.Cookie9Sided.normalized(),
|
||||||
|
containerColor = colorScheme.tertiaryContainer,
|
||||||
|
contentColor = colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
content = {
|
||||||
|
ExpressiveStepPageHeader(title = title, body = body)
|
||||||
|
},
|
||||||
|
button = {
|
||||||
|
ActionButton(
|
||||||
|
onClick = onDone,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(done)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
onBackAtFirstPage = onDone,
|
||||||
|
)
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package ru.fromchat.ui.main.settings.account.changeyandex
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages PKCE + authorize URL while the change-Yandex OAuth WebView is open
|
||||||
|
* (settings composition can leave the confirm screen).
|
||||||
|
*/
|
||||||
|
internal object ChangeYandexDraft {
|
||||||
|
var authorizeUrl: String? = null
|
||||||
|
var codeVerifier: String? = null
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
authorizeUrl = null
|
||||||
|
codeVerifier = null
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
package ru.fromchat.ui.main.settings.account.changeyandex
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.back
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.auth.yandex.YandexOAuthWebView
|
||||||
|
import ru.fromchat.ui.isAppInDarkTheme
|
||||||
|
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChangeYandexOAuthScreen(onBack: () -> Unit) {
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val authorizeUrl = remember { ChangeYandexDraft.authorizeUrl }
|
||||||
|
val codeVerifier = remember { ChangeYandexDraft.codeVerifier }
|
||||||
|
val fallbackColor = MaterialTheme.colorScheme.background
|
||||||
|
var chromeColor by remember { mutableStateOf(fallbackColor) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var webViewCanGoBack by remember { mutableStateOf(false) }
|
||||||
|
val backLabel = stringResource(Res.string.back)
|
||||||
|
|
||||||
|
LaunchedEffect(authorizeUrl, codeVerifier) {
|
||||||
|
if (authorizeUrl.isNullOrBlank() || codeVerifier.isNullOrBlank()) {
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = authorizeUrl ?: return
|
||||||
|
val verifier = codeVerifier ?: return
|
||||||
|
|
||||||
|
fun finishSuccess() {
|
||||||
|
ChangeYandexDraft.clear()
|
||||||
|
navController.navigate(SettingsRoutes.AccountYandexDone) {
|
||||||
|
popUpTo(SettingsRoutes.AccountYandexFlow) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
if (busy) return
|
||||||
|
ChangeYandexDraft.clear()
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(chromeColor),
|
||||||
|
) {
|
||||||
|
YandexOAuthWebView(
|
||||||
|
authorizeUrl = url,
|
||||||
|
languageTag = Locale.current.toLanguageTag(),
|
||||||
|
darkTheme = isAppInDarkTheme(),
|
||||||
|
fallbackColor = fallbackColor,
|
||||||
|
clearCookies = true,
|
||||||
|
onPageBackgroundColor = { chromeColor = it },
|
||||||
|
onHistoryBackAvailabilityChanged = { webViewCanGoBack = it },
|
||||||
|
onCode = { code ->
|
||||||
|
if (busy) return@YandexOAuthWebView
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
try {
|
||||||
|
val proof = ApiClient.authYandexExchange(code, verifier).registration_proof
|
||||||
|
ApiClient.changeAccountYandex(proof)
|
||||||
|
finishSuccess()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
cancel()
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = { cancel() },
|
||||||
|
onCancel = { cancel() },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Same as register: show when back exits the flow (WebView has no in-page history).
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !webViewCanGoBack && !busy,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.padding(start = 12.dp, top = 12.dp),
|
||||||
|
) {
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = { cancel() },
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = backLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,10 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
import androidx.compose.material.icons.filled.AlternateEmail
|
import androidx.compose.material.icons.filled.AlternateEmail
|
||||||
|
import androidx.compose.material.icons.filled.Block
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.DeleteForever
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Link
|
import androidx.compose.material.icons.filled.Link
|
||||||
@@ -79,11 +82,14 @@ import androidx.compose.material.icons.filled.Verified
|
|||||||
import androidx.compose.material.icons.rounded.Call
|
import androidx.compose.material.icons.rounded.Call
|
||||||
import androidx.compose.material.icons.rounded.ContentCopy
|
import androidx.compose.material.icons.rounded.ContentCopy
|
||||||
import androidx.compose.material.icons.rounded.Edit
|
import androidx.compose.material.icons.rounded.Edit
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -137,6 +143,8 @@ import ru.fromchat.api.local.db.store.UserStatusStore
|
|||||||
import ru.fromchat.api.local.db.store.visibleDisplayName
|
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||||
import ru.fromchat.api.local.db.store.visibleUsername
|
import ru.fromchat.api.local.db.store.visibleUsername
|
||||||
import ru.fromchat.api.schema.user.profile.UserProfile
|
import ru.fromchat.api.schema.user.profile.UserProfile
|
||||||
|
import ru.fromchat.api.schema.user.profile.VerificationStatus
|
||||||
|
import ru.fromchat.cancel
|
||||||
import ru.fromchat.config.ServerConfig
|
import ru.fromchat.config.ServerConfig
|
||||||
import ru.fromchat.feature_not_implemented
|
import ru.fromchat.feature_not_implemented
|
||||||
import ru.fromchat.presence_online
|
import ru.fromchat.presence_online
|
||||||
@@ -146,6 +154,25 @@ import ru.fromchat.profile_action_chat
|
|||||||
import ru.fromchat.profile_action_link
|
import ru.fromchat.profile_action_link
|
||||||
import ru.fromchat.profile_action_search
|
import ru.fromchat.profile_action_search
|
||||||
import ru.fromchat.profile_action_settings
|
import ru.fromchat.profile_action_settings
|
||||||
|
import ru.fromchat.profile_admin_actions_category
|
||||||
|
import ru.fromchat.profile_admin_delete
|
||||||
|
import ru.fromchat.profile_admin_delete_confirm
|
||||||
|
import ru.fromchat.profile_admin_delete_confirm_body
|
||||||
|
import ru.fromchat.profile_admin_delete_confirm_title
|
||||||
|
import ru.fromchat.profile_admin_suspend
|
||||||
|
import ru.fromchat.profile_admin_suspend_confirm
|
||||||
|
import ru.fromchat.profile_admin_suspend_confirm_body
|
||||||
|
import ru.fromchat.profile_admin_suspend_confirm_title
|
||||||
|
import ru.fromchat.profile_admin_suspend_reason_label
|
||||||
|
import ru.fromchat.profile_admin_unsuspend
|
||||||
|
import ru.fromchat.profile_admin_unsuspend_confirm_body
|
||||||
|
import ru.fromchat.profile_admin_unsuspend_confirm_title
|
||||||
|
import ru.fromchat.profile_admin_unverify
|
||||||
|
import ru.fromchat.profile_admin_unverify_confirm_body
|
||||||
|
import ru.fromchat.profile_admin_unverify_confirm_title
|
||||||
|
import ru.fromchat.profile_admin_verify
|
||||||
|
import ru.fromchat.profile_admin_verify_confirm_body
|
||||||
|
import ru.fromchat.profile_admin_verify_confirm_title
|
||||||
import ru.fromchat.profile_headline_bio
|
import ru.fromchat.profile_headline_bio
|
||||||
import ru.fromchat.profile_headline_member_since
|
import ru.fromchat.profile_headline_member_since
|
||||||
import ru.fromchat.profile_headline_username
|
import ru.fromchat.profile_headline_username
|
||||||
@@ -153,7 +180,6 @@ import ru.fromchat.profile_headline_verification
|
|||||||
import ru.fromchat.profile_load_failed
|
import ru.fromchat.profile_load_failed
|
||||||
import ru.fromchat.profile_not_found
|
import ru.fromchat.profile_not_found
|
||||||
import ru.fromchat.profile_verified_support
|
import ru.fromchat.profile_verified_support
|
||||||
import ru.fromchat.profile_verify_prompt_support
|
|
||||||
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
|
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
|
||||||
import ru.fromchat.ui.profile.avatarLabelForInitials
|
import ru.fromchat.ui.profile.avatarLabelForInitials
|
||||||
import ru.fromchat.ui.profile.displayNameForUi
|
import ru.fromchat.ui.profile.displayNameForUi
|
||||||
@@ -501,7 +527,12 @@ fun ProfileScreen(
|
|||||||
val headlineBio = stringResource(Res.string.profile_headline_bio)
|
val headlineBio = stringResource(Res.string.profile_headline_bio)
|
||||||
val headlineVerification = stringResource(Res.string.profile_headline_verification)
|
val headlineVerification = stringResource(Res.string.profile_headline_verification)
|
||||||
val verifiedSupport = stringResource(Res.string.profile_verified_support)
|
val verifiedSupport = stringResource(Res.string.profile_verified_support)
|
||||||
val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support)
|
val adminActionsCategory = stringResource(Res.string.profile_admin_actions_category)
|
||||||
|
val adminVerifyLabel = stringResource(Res.string.profile_admin_verify)
|
||||||
|
val adminUnverifyLabel = stringResource(Res.string.profile_admin_unverify)
|
||||||
|
val adminSuspendLabel = stringResource(Res.string.profile_admin_suspend)
|
||||||
|
val adminUnsuspendLabel = stringResource(Res.string.profile_admin_unsuspend)
|
||||||
|
val adminDeleteLabel = stringResource(Res.string.profile_admin_delete)
|
||||||
|
|
||||||
val profile = liveProfile
|
val profile = liveProfile
|
||||||
?: state.profile
|
?: state.profile
|
||||||
@@ -531,6 +562,7 @@ fun ProfileScreen(
|
|||||||
userId = resolvedUserId,
|
userId = resolvedUserId,
|
||||||
currentUserId = viewerUserId,
|
currentUserId = viewerUserId,
|
||||||
deleted = resolvedProfile?.deleted,
|
deleted = resolvedProfile?.deleted,
|
||||||
|
suspended = resolvedProfile?.suspended,
|
||||||
username = resolvedProfile?.username,
|
username = resolvedProfile?.username,
|
||||||
)
|
)
|
||||||
val displayName = when {
|
val displayName = when {
|
||||||
@@ -655,9 +687,11 @@ fun ProfileScreen(
|
|||||||
val showDetailsUsername = usernameForLinks != null
|
val showDetailsUsername = usernameForLinks != null
|
||||||
val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank()
|
val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank()
|
||||||
val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank()
|
val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank()
|
||||||
val showDetailsVerify = !isDeletedProfile && (
|
val showDetailsVerify = !isDeletedProfile && resolvedProfile?.verified == true
|
||||||
resolvedProfile?.verified == true || ApiClient.user?.id == 1
|
val showAdminActions = !isOwnProfile &&
|
||||||
)
|
!isDeletedProfile &&
|
||||||
|
resolvedProfile != null &&
|
||||||
|
ApiClient.user?.id == 1
|
||||||
val showDetailsSection = resolvedProfile != null && !isDeletedProfile && (
|
val showDetailsSection = resolvedProfile != null && !isDeletedProfile && (
|
||||||
showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify
|
showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify
|
||||||
)
|
)
|
||||||
@@ -779,13 +813,19 @@ fun ProfileScreen(
|
|||||||
showDetailsMemberSince = showDetailsMemberSince,
|
showDetailsMemberSince = showDetailsMemberSince,
|
||||||
showDetailsBio = showDetailsBio,
|
showDetailsBio = showDetailsBio,
|
||||||
showDetailsVerify = showDetailsVerify,
|
showDetailsVerify = showDetailsVerify,
|
||||||
|
showAdminActions = showAdminActions,
|
||||||
headlineUsername = headlineUsername,
|
headlineUsername = headlineUsername,
|
||||||
headlineMemberSince = headlineMemberSince,
|
headlineMemberSince = headlineMemberSince,
|
||||||
headlineBio = headlineBio,
|
headlineBio = headlineBio,
|
||||||
headlineVerification = headlineVerification,
|
headlineVerification = headlineVerification,
|
||||||
usernameForLinks = usernameForLinks,
|
usernameForLinks = usernameForLinks,
|
||||||
verifiedSupport = verifiedSupport,
|
verifiedSupport = verifiedSupport,
|
||||||
verifyPromptSupport = verifyPromptSupport,
|
adminActionsCategory = adminActionsCategory,
|
||||||
|
adminVerifyLabel = adminVerifyLabel,
|
||||||
|
adminUnverifyLabel = adminUnverifyLabel,
|
||||||
|
adminSuspendLabel = adminSuspendLabel,
|
||||||
|
adminUnsuspendLabel = adminUnsuspendLabel,
|
||||||
|
adminDeleteLabel = adminDeleteLabel,
|
||||||
registrationDateStrings = registrationDateStrings,
|
registrationDateStrings = registrationDateStrings,
|
||||||
listItemIconTint = listItemIconTint,
|
listItemIconTint = listItemIconTint,
|
||||||
labelCopy = labelCopy,
|
labelCopy = labelCopy,
|
||||||
@@ -796,6 +836,7 @@ fun ProfileScreen(
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
openContextMenuHaptic = openContextMenuHaptic,
|
openContextMenuHaptic = openContextMenuHaptic,
|
||||||
|
onBack = onBack,
|
||||||
onProfileUpdated = { updated ->
|
onProfileUpdated = { updated ->
|
||||||
state = state.copy(profile = updated)
|
state = state.copy(profile = updated)
|
||||||
ProfileCache.put(updated)
|
ProfileCache.put(updated)
|
||||||
@@ -1261,13 +1302,19 @@ private fun ProfileLoadedBody(
|
|||||||
showDetailsMemberSince: Boolean,
|
showDetailsMemberSince: Boolean,
|
||||||
showDetailsBio: Boolean,
|
showDetailsBio: Boolean,
|
||||||
showDetailsVerify: Boolean,
|
showDetailsVerify: Boolean,
|
||||||
|
showAdminActions: Boolean,
|
||||||
headlineUsername: String,
|
headlineUsername: String,
|
||||||
headlineMemberSince: String,
|
headlineMemberSince: String,
|
||||||
headlineBio: String,
|
headlineBio: String,
|
||||||
headlineVerification: String,
|
headlineVerification: String,
|
||||||
usernameForLinks: String?,
|
usernameForLinks: String?,
|
||||||
verifiedSupport: String,
|
verifiedSupport: String,
|
||||||
verifyPromptSupport: String,
|
adminActionsCategory: String,
|
||||||
|
adminVerifyLabel: String,
|
||||||
|
adminUnverifyLabel: String,
|
||||||
|
adminSuspendLabel: String,
|
||||||
|
adminUnsuspendLabel: String,
|
||||||
|
adminDeleteLabel: String,
|
||||||
registrationDateStrings: RegistrationDateFormatStrings,
|
registrationDateStrings: RegistrationDateFormatStrings,
|
||||||
listItemIconTint: Color,
|
listItemIconTint: Color,
|
||||||
labelCopy: String,
|
labelCopy: String,
|
||||||
@@ -1278,9 +1325,17 @@ private fun ProfileLoadedBody(
|
|||||||
navController: NavController,
|
navController: NavController,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
openContextMenuHaptic: () -> Unit,
|
openContextMenuHaptic: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
onProfileUpdated: (UserProfile) -> Unit,
|
onProfileUpdated: (UserProfile) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
var showVerifyConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showUnverifyConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showSuspendConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showUnsuspendConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
|
var suspendReason by remember { mutableStateOf("") }
|
||||||
|
var adminActionInProgress by remember { mutableStateOf(false) }
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -1486,11 +1541,7 @@ private fun ProfileLoadedBody(
|
|||||||
detailIndex++
|
detailIndex++
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = headlineVerification,
|
headline = headlineVerification,
|
||||||
supportingText = if (resolvedProfile.verified == true) {
|
supportingText = verifiedSupport,
|
||||||
verifiedSupport
|
|
||||||
} else {
|
|
||||||
verifyPromptSupport
|
|
||||||
},
|
|
||||||
divider = true,
|
divider = true,
|
||||||
position = position,
|
position = position,
|
||||||
groupItemCount = detailCount,
|
groupItemCount = detailCount,
|
||||||
@@ -1501,36 +1552,312 @@ private fun ProfileLoadedBody(
|
|||||||
tint = listItemIconTint,
|
tint = listItemIconTint,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = if (ApiClient.user?.id == 1) {
|
)
|
||||||
{
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAdminActions) {
|
||||||
|
val isVerified = resolvedProfile.verified == true
|
||||||
|
val isSuspended = resolvedProfile.suspended == true
|
||||||
|
Category(
|
||||||
|
title = adminActionsCategory,
|
||||||
|
margin = PaddingValues(
|
||||||
|
start = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = if (showDetailsSection) 8.dp else 28.dp,
|
||||||
|
bottom = 20.dp,
|
||||||
|
),
|
||||||
|
roundedCorners = false,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headline = if (isVerified) adminUnverifyLabel else adminVerifyLabel,
|
||||||
|
divider = true,
|
||||||
|
position = ListItemPosition.START,
|
||||||
|
groupItemCount = 3,
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Verified,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = listItemIconTint,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (isVerified) showUnverifyConfirm = true else showVerifyConfirm = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
headline = if (isSuspended) adminUnsuspendLabel else adminSuspendLabel,
|
||||||
|
divider = true,
|
||||||
|
position = ListItemPosition.MIDDLE,
|
||||||
|
groupItemCount = 3,
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isSuspended) {
|
||||||
|
Icons.Filled.CheckCircle
|
||||||
|
} else {
|
||||||
|
Icons.Filled.Block
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (isSuspended) {
|
||||||
|
showUnsuspendConfirm = true
|
||||||
|
} else {
|
||||||
|
suspendReason = ""
|
||||||
|
showSuspendConfirm = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
headline = adminDeleteLabel,
|
||||||
|
divider = true,
|
||||||
|
position = ListItemPosition.END,
|
||||||
|
groupItemCount = 3,
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.DeleteForever,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { showDeleteConfirm = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showVerifyConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { if (!adminActionInProgress) showVerifyConfirm = false },
|
||||||
|
title = { Text(stringResource(Res.string.profile_admin_verify_confirm_title)) },
|
||||||
|
text = { Text(stringResource(Res.string.profile_admin_verify_confirm_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
onClick = {
|
||||||
|
adminActionInProgress = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val result = withContext(Dispatchers.Default) {
|
val result = withContext(Dispatchers.Default) {
|
||||||
runCatching {
|
runCatching { ApiClient.verifyUser(resolvedProfile.id) }.getOrNull()
|
||||||
ApiClient.verifyUser(resolvedProfile.id)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
}
|
||||||
|
adminActionInProgress = false
|
||||||
|
showVerifyConfirm = false
|
||||||
result?.let { response ->
|
result?.let { response ->
|
||||||
onProfileUpdated(
|
onProfileUpdated(
|
||||||
resolvedProfile.copy(
|
resolvedProfile.copy(
|
||||||
verified = response.verified,
|
verified = response.verified,
|
||||||
verificationStatus = response.verificationStatus
|
verificationStatus = response.verificationStatus
|
||||||
?: response.verified.let { verified ->
|
?: if (response.verified) {
|
||||||
if (verified) {
|
VerificationStatus.Verified
|
||||||
ru.fromchat.api.schema.user.profile.VerificationStatus.Verified
|
|
||||||
} else {
|
} else {
|
||||||
ru.fromchat.api.schema.user.profile.VerificationStatus.None
|
VerificationStatus.None
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(Res.string.profile_admin_verify))
|
||||||
}
|
}
|
||||||
} else null,
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
onClick = { showVerifyConfirm = false },
|
||||||
|
) {
|
||||||
|
Text(stringResource(Res.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showUnverifyConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { if (!adminActionInProgress) showUnverifyConfirm = false },
|
||||||
|
title = { Text(stringResource(Res.string.profile_admin_unverify_confirm_title)) },
|
||||||
|
text = { Text(stringResource(Res.string.profile_admin_unverify_confirm_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
onClick = {
|
||||||
|
adminActionInProgress = true
|
||||||
|
scope.launch {
|
||||||
|
val result = withContext(Dispatchers.Default) {
|
||||||
|
runCatching { ApiClient.verifyUser(resolvedProfile.id) }.getOrNull()
|
||||||
|
}
|
||||||
|
adminActionInProgress = false
|
||||||
|
showUnverifyConfirm = false
|
||||||
|
result?.let { response ->
|
||||||
|
onProfileUpdated(
|
||||||
|
resolvedProfile.copy(
|
||||||
|
verified = response.verified,
|
||||||
|
verificationStatus = response.verificationStatus
|
||||||
|
?: if (response.verified) {
|
||||||
|
VerificationStatus.Verified
|
||||||
|
} else {
|
||||||
|
VerificationStatus.None
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(Res.string.profile_admin_unverify))
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
onClick = { showUnverifyConfirm = false },
|
||||||
|
) {
|
||||||
|
Text(stringResource(Res.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSuspendConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { if (!adminActionInProgress) showSuspendConfirm = false },
|
||||||
|
title = { Text(stringResource(Res.string.profile_admin_suspend_confirm_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(stringResource(Res.string.profile_admin_suspend_confirm_body))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = suspendReason,
|
||||||
|
onValueChange = { suspendReason = it },
|
||||||
|
label = { Text(stringResource(Res.string.profile_admin_suspend_reason_label)) },
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
singleLine = false,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !adminActionInProgress && suspendReason.trim().isNotEmpty(),
|
||||||
|
onClick = {
|
||||||
|
val reason = suspendReason.trim()
|
||||||
|
adminActionInProgress = true
|
||||||
|
scope.launch {
|
||||||
|
val result = withContext(Dispatchers.Default) {
|
||||||
|
runCatching {
|
||||||
|
ApiClient.suspendUser(resolvedProfile.id, reason)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
adminActionInProgress = false
|
||||||
|
showSuspendConfirm = false
|
||||||
|
if (result != null) {
|
||||||
|
onProfileUpdated(
|
||||||
|
resolvedProfile.copy(
|
||||||
|
suspended = true,
|
||||||
|
suspensionReason = reason,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(Res.string.profile_admin_suspend_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
onClick = { showSuspendConfirm = false },
|
||||||
|
) {
|
||||||
|
Text(stringResource(Res.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showUnsuspendConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { if (!adminActionInProgress) showUnsuspendConfirm = false },
|
||||||
|
title = { Text(stringResource(Res.string.profile_admin_unsuspend_confirm_title)) },
|
||||||
|
text = { Text(stringResource(Res.string.profile_admin_unsuspend_confirm_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
onClick = {
|
||||||
|
adminActionInProgress = true
|
||||||
|
scope.launch {
|
||||||
|
val result = withContext(Dispatchers.Default) {
|
||||||
|
runCatching { ApiClient.unsuspendUser(resolvedProfile.id) }.getOrNull()
|
||||||
|
}
|
||||||
|
adminActionInProgress = false
|
||||||
|
showUnsuspendConfirm = false
|
||||||
|
if (result != null) {
|
||||||
|
onProfileUpdated(
|
||||||
|
resolvedProfile.copy(
|
||||||
|
suspended = false,
|
||||||
|
suspensionReason = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(Res.string.profile_admin_unsuspend))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
onClick = { showUnsuspendConfirm = false },
|
||||||
|
) {
|
||||||
|
Text(stringResource(Res.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDeleteConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { if (!adminActionInProgress) showDeleteConfirm = false },
|
||||||
|
title = { Text(stringResource(Res.string.profile_admin_delete_confirm_title)) },
|
||||||
|
text = { Text(stringResource(Res.string.profile_admin_delete_confirm_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
onClick = {
|
||||||
|
adminActionInProgress = true
|
||||||
|
scope.launch {
|
||||||
|
val result = withContext(Dispatchers.Default) {
|
||||||
|
runCatching { ApiClient.adminDeleteUser(resolvedProfile.id) }.getOrNull()
|
||||||
|
}
|
||||||
|
adminActionInProgress = false
|
||||||
|
showDeleteConfirm = false
|
||||||
|
if (result != null) {
|
||||||
|
onProfileUpdated(
|
||||||
|
resolvedProfile.copy(deleted = true),
|
||||||
|
)
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(Res.string.profile_admin_delete_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !adminActionInProgress,
|
||||||
|
onClick = { showDeleteConfirm = false },
|
||||||
|
) {
|
||||||
|
Text(stringResource(Res.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ fun UserProfile.isDeletedAccount(currentUserId: Int? = null): Boolean =
|
|||||||
fun UserProfile.isSuspendedAccount(currentUserId: Int? = null): Boolean =
|
fun UserProfile.isSuspendedAccount(currentUserId: Int? = null): Boolean =
|
||||||
suspended == true && deleted != true && id != currentUserId
|
suspended == true && deleted != true && id != currentUserId
|
||||||
|
|
||||||
|
/** True when peers should see this profile as a deleted/hidden account. */
|
||||||
|
fun UserProfile.isRedactedPeerAccount(currentUserId: Int? = null): Boolean =
|
||||||
|
id != currentUserId && (
|
||||||
|
deleted == true ||
|
||||||
|
isDeletedAccountUsername(username) ||
|
||||||
|
(suspended == true && currentUserId != 1)
|
||||||
|
)
|
||||||
|
|
||||||
fun isDeletedAccountUsername(username: String?): Boolean =
|
fun isDeletedAccountUsername(username: String?): Boolean =
|
||||||
username?.startsWith("#deleted") == true
|
username?.startsWith("#deleted") == true
|
||||||
|
|
||||||
@@ -25,13 +33,15 @@ fun peerIsDeleted(
|
|||||||
currentUserId: Int? = null,
|
currentUserId: Int? = null,
|
||||||
deleted: Boolean? = null,
|
deleted: Boolean? = null,
|
||||||
username: String? = null,
|
username: String? = null,
|
||||||
|
suspended: Boolean? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (userId <= 0 || userId == currentUserId) return false
|
if (userId <= 0 || userId == currentUserId) return false
|
||||||
if (deleted == true) return true
|
if (deleted == true) return true
|
||||||
if (isDeletedAccountUsername(username)) return true
|
if (isDeletedAccountUsername(username)) return true
|
||||||
|
// Suspended peers look deleted to everyone except admin (id 1).
|
||||||
|
if (suspended == true && currentUserId != 1) return true
|
||||||
ProfileCache.get(userId)?.let { profile ->
|
ProfileCache.get(userId)?.let { profile ->
|
||||||
if (profile.isDeletedAccount(currentUserId)) return true
|
if (profile.isRedactedPeerAccount(currentUserId)) return true
|
||||||
if (isDeletedAccountUsername(profile.username)) return true
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -44,7 +54,7 @@ fun deletedUserDisplayNameForUi(): String =
|
|||||||
stringResource(Res.string.deleted_account)
|
stringResource(Res.string.deleted_account)
|
||||||
|
|
||||||
suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String {
|
suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String {
|
||||||
if (isDeletedAccount(currentUserId)) {
|
if (isRedactedPeerAccount(currentUserId)) {
|
||||||
return deletedUserDisplayName()
|
return deletedUserDisplayName()
|
||||||
}
|
}
|
||||||
return visibleDisplayName(currentUserId).orEmpty()
|
return visibleDisplayName(currentUserId).orEmpty()
|
||||||
@@ -52,7 +62,7 @@ suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UserProfile.displayNameForUi(currentUserId: Int? = null): String =
|
fun UserProfile.displayNameForUi(currentUserId: Int? = null): String =
|
||||||
if (isDeletedAccount(currentUserId)) {
|
if (isRedactedPeerAccount(currentUserId)) {
|
||||||
deletedUserDisplayNameForUi()
|
deletedUserDisplayNameForUi()
|
||||||
} else {
|
} else {
|
||||||
visibleDisplayName(currentUserId).orEmpty()
|
visibleDisplayName(currentUserId).orEmpty()
|
||||||
@@ -60,7 +70,7 @@ fun UserProfile.displayNameForUi(currentUserId: Int? = null): String =
|
|||||||
|
|
||||||
/** Display name for avatar initials/gradient only; never falls back to username. */
|
/** Display name for avatar initials/gradient only; never falls back to username. */
|
||||||
fun UserProfile.avatarLabelForInitials(currentUserId: Int? = null): String =
|
fun UserProfile.avatarLabelForInitials(currentUserId: Int? = null): String =
|
||||||
if (isDeletedAccount(currentUserId)) {
|
if (isRedactedPeerAccount(currentUserId)) {
|
||||||
""
|
""
|
||||||
} else {
|
} else {
|
||||||
displayName?.trim().orEmpty()
|
displayName?.trim().orEmpty()
|
||||||
|
|||||||
+8
-4
@@ -18,13 +18,17 @@ fun resolveVerificationStatus(
|
|||||||
message: Message? = null,
|
message: Message? = null,
|
||||||
user: User? = null,
|
user: User? = null,
|
||||||
): VerificationStatus? {
|
): VerificationStatus? {
|
||||||
ProfileCache.get(userId)?.effectiveVerificationStatus()?.let { cached ->
|
ProfileCache.get(userId)?.let { cached ->
|
||||||
if (cached != VerificationStatus.None || ProfileCache.get(userId)?.verificationStatus != null) {
|
if (cached.verificationStatus != null || cached.verified != null) {
|
||||||
return cached
|
return cached.effectiveVerificationStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
user?.effectiveVerificationStatus()?.let { return it }
|
user?.let { u ->
|
||||||
|
if (u.verificationStatus != null || u.verified != null) {
|
||||||
|
return u.effectiveVerificationStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
message?.verificationStatus?.let { return it }
|
message?.verificationStatus?.let { return it }
|
||||||
message?.verified?.let { return if (it) VerificationStatus.Verified else null }
|
message?.verified?.let { return if (it) VerificationStatus.Verified else null }
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package ru.fromchat.notifications
|
||||||
|
|
||||||
|
actual object ChatNotificationDismissals {
|
||||||
|
actual fun dismissAllMessageNotifications() = Unit
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package ru.fromchat.ui.auth.yandex
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun YandexOAuthWebView(
|
||||||
|
authorizeUrl: String,
|
||||||
|
languageTag: String,
|
||||||
|
darkTheme: Boolean,
|
||||||
|
fallbackColor: Color,
|
||||||
|
clearCookies: Boolean,
|
||||||
|
onPageBackgroundColor: (Color) -> Unit,
|
||||||
|
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
|
||||||
|
onCode: (String) -> Unit,
|
||||||
|
onError: (String) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
LaunchedEffect(authorizeUrl) {
|
||||||
|
onError("Yandex sign-in is not available on this platform yet.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ livekitAndroid = "2.25.2"
|
|||||||
livekitAndroidComposeComponents = "2.3.0"
|
livekitAndroidComposeComponents = "2.3.0"
|
||||||
markdownRendererM3 = "0.41.0"
|
markdownRendererM3 = "0.41.0"
|
||||||
bouncycastle = "1.79"
|
bouncycastle = "1.79"
|
||||||
|
webkit = "1.14.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
||||||
@@ -110,6 +111,7 @@ krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" }
|
|||||||
cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography-kotlin" }
|
cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography-kotlin" }
|
||||||
cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography-kotlin" }
|
cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography-kotlin" }
|
||||||
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidxWork" }
|
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidxWork" }
|
||||||
|
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
|
||||||
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
||||||
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
|
||||||
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
|
||||||
|
|||||||
Reference in New Issue
Block a user