18 Commits

Author SHA1 Message Date
Codex ff8e2fdd49 refactor: simplify app bootstrap and root errors 2026-05-18 20:46:46 +03:00
Codex 514b8859c7 refactor: trim message history orchestration 2026-05-18 20:46:18 +03:00
Codex c18a7963bf fix: harden captcha and long poll parsing 2026-05-18 20:45:41 +03:00
Codex 255a194c25 docs: add tech debt audit 2026-05-18 20:45:12 +03:00
Codex 6b7f8f2397 refactor: split messages history actions 2026-05-14 21:05:20 +03:00
Codex 0ffd92b875 refactor: extract message composer actions 2026-05-14 20:53:34 +03:00
Codex 68fff3ebee refactor: extract message interactions 2026-05-14 20:48:50 +03:00
Codex f6c6ed59f3 refactor: unify db refresh flows 2026-05-14 20:45:24 +03:00
Codex f24eae8209 refactor: split message transport actions 2026-05-14 18:24:46 +03:00
Codex 96f45aef6a refactor: extract pinned message handling 2026-05-14 18:21:03 +03:00
Codex 5dc000341b refactor: extract message loading 2026-05-14 18:16:43 +03:00
Codex 6961ac7240 refactor: split message actions and parsers 2026-05-14 18:14:10 +03:00
Codex c380c1a73d refactor: split message event handlers 2026-05-14 18:06:54 +03:00
Codex 2bf81c60d6 refactor: extract message ui mapper 2026-05-14 18:03:30 +03:00
Codex 2e472733d9 refactor: extract paging helpers 2026-05-14 18:01:38 +03:00
Codex d91b726b9d refactor: reduce long poll dispatch noise 2026-05-14 17:56:34 +03:00
Codex 3bb4de24a7 refactor: harden main shell state 2026-05-14 17:51:48 +03:00
Codex 22d13fcbe5 refactor: centralize shared error handling 2026-05-14 17:48:56 +03:00
110 changed files with 3764 additions and 3465 deletions
-56
View File
@@ -1,56 +0,0 @@
name: Android CI
on:
workflow_dispatch:
permissions:
contents: read
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
RELEASE_SIGN_KEY_ALIAS: ${{ secrets.RELEASE_SIGN_KEY_ALIAS }}
RELEASE_SIGN_KEY_PASSWORD: ${{ secrets.RELEASE_SIGN_KEY_PASSWORD }}
jobs:
android:
runs-on: android-jdk21
name: Build artifacts
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Make Gradle executable
run: chmod +x ./gradlew
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Find generated release APK name
id: find_apk_release
run: |
APK_PATH=$(find app/build/outputs/apk/release -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITEA_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITEA_ENV
- name: Upload APK with original name
uses: christopherhx/gitea-upload-artifact@v4
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
- name: Build and sign debug APK
run: ./gradlew assembleDebug
- name: Find generated debug APK name
id: find_apk_debug
run: |
APK_PATH=$(find app/build/outputs/apk/debug -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITEA_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITEA_ENV
- name: Upload APK with original name
uses: christopherhx/gitea-upload-artifact@v4
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
-2
View File
@@ -15,5 +15,3 @@ build/
local.properties local.properties
.idea .idea
/.kotlin /.kotlin
.hotswan/
.java-version
-1
View File
@@ -43,7 +43,6 @@ Unofficial messenger for russian social network VKontakte
- [ ] Forwarded messages - [ ] Forwarded messages
- [ ] Wall post - [ ] Wall post
- [ ] Comment in wall post - [ ] Comment in wall post
- [ ] Comment in channel
- [ ] Poll - [ ] Poll
- [ ] TODO - [ ] TODO
- [x] Send messages - [x] Send messages
+131
View File
@@ -0,0 +1,131 @@
# Fast Messenger Tech Debt Audit
## Critical
### `core/network/src/main/kotlin/dev/meloda/fast/network/interceptor/Error14HandlingInterceptor.kt`
- captcha flow is built around `wait/notify`, a raw executor, and shared mutable state.
- risk: deadlocks, leaked jobs, hard-to-reproduce auth hangs.
- fix: rewrite as suspend-based flow with timeout and explicit cancellation.
### `core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollEventParser.kt`
- file mixes parsing, dispatching, IO loading, and concurrency orchestration.
- risk: regressions when VK event format changes.
- fix: split by event family and add parser tests.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt`
- view model is doing too much: loaders, navigation, selection, long poll hooks, read peers, dialog flow.
- risk: brittle state transitions and untestable branching.
- fix: extract coordinators/handlers per concern.
### `app/src/main/kotlin/dev/meloda/fast/MainViewModel.kt`
- root bootstrap handles auth, locale, long poll, permissions, profile, and start destination in one class.
- risk: startup bugs and hidden coupling between flows.
- fix: move startup/permission orchestration into dedicated controllers.
### `app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt`
- root composable owns too many top-level flows and dialogs.
- risk: UI orchestration drift and hard-to-read navigation logic.
- fix: split dialogs, bootstrap, and navigation concern into smaller composables.
## High
### `core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollEventParser.kt`
- uses many `Log.d` calls and large `when` branches.
- fix: reduce logging noise and add structured tracing only where needed.
### `core/network/src/main/kotlin/dev/meloda/fast/network/ResponseConverterFactory.kt`
- double-parsing response bodies and only logging malformed payloads.
- risk: opaque failures and harder debugging.
- fix: normalize error conversion and surface typed failures.
### `feature/profile/src/main/kotlin/dev/meloda/fast/profile/ProfileViewModel.kt`
- `loadAccountInfo()` has an empty error branch.
- risk: profile can fail silently.
- fix: set `baseError` and show fallback UI.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/attachments/Attachments.kt`
- attachment preview logic still sits in UI layer and mixes fallback behavior.
- risk: silent drops of unsupported attachments.
- fix: move preview mapping to domain/ui-model layer.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/Link.kt`
- title handling is nullable and duplicated with preview logic.
- fix: create a small UI model for link rendering.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/File.kt`
- preview extraction is inline and branches on raw model internals.
- fix: extract to mapper/UI model.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/presentation/MessagesList.kt`
- message-item interaction is dense and repeated.
- fix: normalize scroll/reply handlers and reduce nested callbacks.
## Medium
### `core/data/src/main/kotlin/dev/meloda/fast/data/VkUsersMap.kt`
- lookup helpers were using unsafe assumptions on external data.
- status: already improved, but should stay covered by tests.
### `core/data/src/main/kotlin/dev/meloda/fast/data/VkGroupsMap.kt`
- same risk profile as users map.
- status: already improved, still needs tests.
### `core/domain/src/main/kotlin/dev/meloda/fast/domain/OAuthUseCaseImpl.kt`
- auth success mapping used forced unwraps on server data.
- status: already improved, but auth contract should be validated.
### `app/src/main/kotlin/dev/meloda/fast/presentation/MainActivity.kt`
- service lifecycle now respects config changes, but app exit semantics should be documented.
- fix: keep explicit separation of app-close vs config-change behavior.
### `build-logic/convention/src/main/kotlin/dev/meloda/fast/KotlinAndroid.kt`
- build-tools are pinned to the local environment.
- risk: portable builds may drift between machines.
- fix: prefer SDK-managed consistency or document required SDK version.
### `app/src/main/kotlin/dev/meloda/fast/presentation/RootErrorDialog.kt`
- currently hardcodes English strings for some errors.
- fix: localize all texts through resources.
### `app/src/main/kotlin/dev/meloda/fast/presentation/RootScreen.kt`
- root-level error dialog is better, but some orchestration still remains in the root composable.
- fix: split into smaller root flows over time.
## Low
### `core/ui/src/main/kotlin/dev/meloda/fast/ui/components/AnimatedDots.kt`
- marked TODO rewrite.
- fix when touching related loading UI.
### `core/ui/src/main/kotlin/dev/meloda/fast/ui/theme/AppTheme.kt`
- color picker TODO suggests unfinished theme customization.
### `core/model/src/main/kotlin/dev/meloda/fast/model/api/data/LongPollUpdates.kt`
- `List<List<Any>>` is a weakly typed API boundary.
- fix: introduce explicit event DTOs.
### `core/model/src/main/kotlin/dev/meloda/fast/model/api/domain/VkMessage.kt`
- attachment persistence is still a TODO area.
### `core/model/src/main/kotlin/dev/meloda/fast/model/database/VkMessageEntity.kt`
- attachment storage/restoration is unresolved.
### `core/common/src/main/kotlin/dev/meloda/fast/common/model/LongPollState.kt`
- Android 15 support TODO.
### `feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt`
- large commented-out legacy block should be removed once upload flow is reimplemented.
### `feature/auth/src/main/kotlin/dev/meloda/fast/auth/login/LoginViewModel.kt`
- debug/auth token acquisition TODO indicates unfinished auth path.
## What To Do First
1. Rewrite captcha interceptor.
2. Split `LongPollEventParser`.
3. Extract `MessagesHistoryViewModelImpl` orchestration.
4. Localize `RootErrorDialog` strings.
5. Add tests for auth, long poll parsing, and attachment mapping.
## Note
- Current code is already better on crash-prone nullable handling and service lifecycle.
- Remaining work is mostly structural and testability-focused.
+4 -1
View File
@@ -47,7 +47,7 @@ android {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
} }
named("release") { named("release") {
signingConfig = signingConfigs.getByName("debugSigning") signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -79,6 +79,9 @@ android {
} }
dependencies { dependencies {
implementation(libs.acra.email)
implementation(libs.acra.dialog)
implementation(projects.feature.auth) implementation(projects.feature.auth)
implementation(projects.feature.chatmaterials) implementation(projects.feature.chatmaterials)
+2 -8
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:tools="http://schemas.android.com/tools"> xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -37,12 +37,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="dev.meloda.fast.presentation.CrashActivity"
android:exported="false"
android:process=":error_handler"
android:theme="@style/CrashDialogTheme" />
<service <service
android:name="dev.meloda.fast.service.longpolling.LongPollingService" android:name="dev.meloda.fast.service.longpolling.LongPollingService"
android:enabled="true" android:enabled="true"
@@ -2,7 +2,6 @@ package dev.meloda.fast
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
@@ -38,6 +37,7 @@ interface MainViewModel {
val startDestination: StateFlow<Any?> val startDestination: StateFlow<Any?>
val isNeedToReplaceWithAuth: StateFlow<Boolean> val isNeedToReplaceWithAuth: StateFlow<Boolean>
val currentUser: StateFlow<VkUser?> val currentUser: StateFlow<VkUser?>
val baseError: StateFlow<BaseError?>
val isNeedToShowNotificationsDeniedDialog: StateFlow<Boolean> val isNeedToShowNotificationsDeniedDialog: StateFlow<Boolean>
val isNeedToShowNotificationsRationaleDialog: StateFlow<Boolean> val isNeedToShowNotificationsRationaleDialog: StateFlow<Boolean>
@@ -45,6 +45,7 @@ interface MainViewModel {
val isNeedToRequestNotifications: StateFlow<Boolean> val isNeedToRequestNotifications: StateFlow<Boolean>
fun onError(error: BaseError) fun onError(error: BaseError)
fun onErrorConsumed()
fun onNavigatedToAuth() fun onNavigatedToAuth()
@@ -73,6 +74,7 @@ class MainViewModelImpl(
override val startDestination = MutableStateFlow<Any?>(null) override val startDestination = MutableStateFlow<Any?>(null)
override val isNeedToReplaceWithAuth = MutableStateFlow(false) override val isNeedToReplaceWithAuth = MutableStateFlow(false)
override val currentUser = MutableStateFlow<VkUser?>(null) override val currentUser = MutableStateFlow<VkUser?>(null)
override val baseError = MutableStateFlow<BaseError?>(null)
override val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false) override val isNeedToShowNotificationsDeniedDialog = MutableStateFlow(false)
override val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false) override val isNeedToShowNotificationsRationaleDialog = MutableStateFlow(false)
@@ -82,6 +84,10 @@ class MainViewModelImpl(
private var openNotificationsSettings = false private var openNotificationsSettings = false
private var openAppSettings = false private var openAppSettings = false
init {
listenLongPollState()
}
override fun onError(error: BaseError) { override fun onError(error: BaseError) {
when (error) { when (error) {
BaseError.SessionExpired, BaseError.SessionExpired,
@@ -89,10 +95,16 @@ class MainViewModelImpl(
isNeedToReplaceWithAuth.update { true } isNeedToReplaceWithAuth.update { true }
} }
else -> Unit // TODO: 21-Mar-25, Danil Nikolaev: show error in ui else -> {
baseError.update { error }
}
} }
} }
override fun onErrorConsumed() {
baseError.update { null }
}
override fun onNavigatedToAuth() { override fun onNavigatedToAuth() {
isNeedToReplaceWithAuth.update { false } isNeedToReplaceWithAuth.update { false }
} }
@@ -203,10 +215,6 @@ class MainViewModelImpl(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val currentAccount = getCurrentAccountUseCase() val currentAccount = getCurrentAccountUseCase()
Log.d("MainViewModel", "currentAccount: $currentAccount")
listenLongPollState()
if (currentAccount != null) { if (currentAccount != null) {
UserConfig.apply { UserConfig.apply {
this.userId = currentAccount.userId this.userId = currentAccount.userId
@@ -1,8 +1,6 @@
package dev.meloda.fast.common package dev.meloda.fast.common
import android.app.Application import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
@@ -10,14 +8,14 @@ import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer
import dev.meloda.fast.auth.BuildConfig import dev.meloda.fast.auth.BuildConfig
import dev.meloda.fast.common.di.applicationModule import dev.meloda.fast.common.di.applicationModule
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.presentation.CrashActivity import org.acra.config.dialog
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.GlobalContext.startKoin import org.koin.core.context.GlobalContext.startKoin
import java.io.File
import java.io.FileOutputStream
import kotlin.system.exitProcess
class AppGlobal : Application(), ImageLoaderFactory { class AppGlobal : Application(), ImageLoaderFactory {
@@ -29,7 +27,7 @@ class AppGlobal : Application(), ImageLoaderFactory {
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG) ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
initKoin() initKoin()
initCrashHandler() initAcra()
} }
override fun newImageLoader(): ImageLoader = get() override fun newImageLoader(): ImageLoader = get()
@@ -42,36 +40,20 @@ class AppGlobal : Application(), ImageLoaderFactory {
} }
} }
private fun initCrashHandler() { private fun initAcra() {
val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() initAcra {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> buildConfigClass = BuildConfig::class.java
val crashLogsDirectory = File(filesDir.absolutePath + "/crashlogs") reportFormat = StringFormat.JSON
if (!crashLogsDirectory.exists()) {
crashLogsDirectory.mkdirs() mailSender {
mailTo = "lischenkodev@gmail.com"
reportAsFile = true
reportFileName = "Crash.txt"
} }
val crashLogFileName = "crash_" + System.currentTimeMillis() + ".txt" dialog {
val crashLogFile = File(crashLogsDirectory.absolutePath + "/" + crashLogFileName) text = "App crashed"
enabled = true
FileOutputStream(crashLogFile).use { stream ->
stream.write(throwable.stackTraceToString().toByteArray())
}
if (AppSettings.Debug.showAlertAfterCrash) {
try {
val intent = Intent(this, CrashActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
intent.putExtra("CRASH_LOG_FILE_URI", Uri.fromFile(crashLogFile))
startActivity(intent)
exitProcess(0)
} catch (e: Exception) {
if (e !is RuntimeException) {
defaultExceptionHandler?.uncaughtException(thread, throwable)
}
}
} else {
defaultExceptionHandler?.uncaughtException(thread, throwable)
} }
} }
} }
@@ -1,35 +0,0 @@
package dev.meloda.fast.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog
@Composable
fun AppCrashedDialog(
stacktrace: String,
onDismiss: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier,
) {
var showTrace by rememberSaveable { mutableStateOf(false) }
MaterialDialog(
modifier = modifier,
onDismissRequest = onDismiss,
title = stringResource(R.string.title_error),
text = if (showTrace) stacktrace else stringResource(R.string.error_occurred),
confirmText = stringResource(R.string.action_share),
confirmAction = onShare,
cancelText = stringResource(if (showTrace) R.string.action_hide_stacktrace else R.string.action_show_stacktrace),
cancelAction = { showTrace = !showTrace },
neutralText = stringResource(R.string.action_close),
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)
}
@@ -1,71 +0,0 @@
package dev.meloda.fast.presentation
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.compose.koinInject
import java.io.File
class CrashActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val crashLogFileUri = intent.getParcelableExtra<Uri>("CRASH_LOG_FILE_URI") ?: run {
finish()
return
}
val crashLogFile = crashLogFileUri.toFile().takeIf(File::exists) ?: run {
finish()
return
}
val stacktrace = crashLogFile.bufferedReader().readText()
setContent {
val userSettings: UserSettings = koinInject()
AppTheme(
useDarkTheme = isNeedToEnableDarkMode(darkMode = userSettings.darkMode.collectAsState().value),
useDynamicColors = userSettings.enableDynamicColors.collectAsState().value,
selectedColorScheme = 0,
useAmoledBackground = userSettings.enableAmoledDark.collectAsState().value,
useSystemFont = userSettings.useSystemFont.collectAsState().value
) {
AppCrashedDialog(
stacktrace = stacktrace,
onDismiss = { finish() },
onShare = {
val uri = FileProvider.getUriForFile(
this,
"$packageName.provider",
crashLogFile
)
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri(null, uri)
}
val chooserIntent = Intent.createChooser(sendIntent, null)
chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
startActivity(chooserIntent)
}
)
}
}
}
}
@@ -9,7 +9,6 @@ import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -65,9 +64,6 @@ class MainActivity : AppCompatActivity() {
setContent { setContent {
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>() val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LaunchedEffect(viewModel) {
Log.d("VM_CREATE", "onCreate: viewModel: $viewModel")
}
LifecycleResumeEffect(true) { LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent) viewModel.onAppResumed(intent)
@@ -38,13 +38,14 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.model.ConvoNavigationIntent import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.convos.navigation.convosGraph import dev.meloda.fast.convos.navigation.convosGraph
import dev.meloda.fast.friends.navigation.Friends import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen import dev.meloda.fast.friends.navigation.friendsScreen
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.BottomNavigationItem import dev.meloda.fast.model.BottomNavigationItem
import dev.meloda.fast.navigation.MainGraph import dev.meloda.fast.navigation.MainGraph
import dev.meloda.fast.profile.navigation.Profile
import dev.meloda.fast.profile.navigation.profileScreen import dev.meloda.fast.profile.navigation.profileScreen
import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
@@ -69,15 +70,18 @@ fun MainScreen(
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val hazeState = remember { HazeState(true) } val hazeState = remember { HazeState(true) }
val navController = rememberNavController() val navController = rememberNavController()
val defaultTabIndex = remember(navigationItems) {
var selectedItemIndex by rememberSaveable { navigationItems.indexOfFirst { it.route == ConvoGraph }.takeIf { it >= 0 } ?: 0
mutableIntStateOf(1)
} }
BackHandler(enabled = selectedItemIndex != 1) { var selectedItemIndex by rememberSaveable {
mutableIntStateOf(defaultTabIndex)
}
BackHandler(enabled = selectedItemIndex != defaultTabIndex) {
val currentRoute = navigationItems[selectedItemIndex].route val currentRoute = navigationItems[selectedItemIndex].route
selectedItemIndex = 1 selectedItemIndex = defaultTabIndex
navController.navigate(navigationItems[selectedItemIndex].route) { navController.navigate(navigationItems[selectedItemIndex].route) {
popUpTo(route = currentRoute) { popUpTo(route = currentRoute) {
inclusive = true inclusive = true
@@ -127,7 +131,7 @@ fun MainScreen(
} }
}, },
icon = { icon = {
if (index == navigationItems.size - 1) { if (item.route == Profile) {
var isLoading by remember { var isLoading by remember {
mutableStateOf(true) mutableStateOf(true)
} }
@@ -198,20 +202,19 @@ fun MainScreen(
}, },
) )
convosGraph( convosGraph(
handleNavigationIntent = { intent ->
when (intent) {
ConvoNavigationIntent.Back -> {}
ConvoNavigationIntent.Archive -> {}
ConvoNavigationIntent.CreateChat -> onNavigateToCreateChat()
is ConvoNavigationIntent.MessagesHistory -> {
onNavigateToMessagesHistory(intent.convoId)
}
}
},
activity = activity, activity = activity,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onScrolledToTop = {
tabReselected = tabReselected.toMutableMap().also {
it[ConvoGraph] = false
}
}
) )
profileScreen( profileScreen(
activity = activity, activity = activity,
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked, onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked onPhotoClicked = onPhotoClicked
) )
@@ -0,0 +1,38 @@
package dev.meloda.fast.presentation
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
@Composable
fun RootErrorDialog(
baseError: BaseError?,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
if (baseError == null) return
val errorText = when (baseError) {
BaseError.ConnectionError -> "Connection error"
BaseError.InternalError -> "Internal error"
BaseError.UnknownError -> "Unknown error"
is BaseError.SimpleError -> baseError.message
BaseError.SessionExpired -> "Session expired"
BaseError.AccountBlocked -> "Account blocked"
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(id = R.string.warning)) },
text = { Text(text = errorText) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = stringResource(id = R.string.try_again))
}
}
)
}
@@ -4,16 +4,12 @@ import android.Manifest
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -91,10 +87,7 @@ fun RootScreen(
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle() val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle() val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>() val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LaunchedEffect(viewModel) {
Log.d("VM_CREATE", "RootScreen(): viewModel: $viewModel")
}
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle() val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
@@ -125,14 +118,12 @@ fun RootScreen(
} }
} }
LifecycleResumeEffect(longPollStateToApply) { LifecycleResumeEffect(longPollStateToApply) {
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) { if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched() if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply && longPollCurrentState != longPollStateToApply
) { ) {
toggleLongPollService(false, null) toggleLongPollService(false, null)
Log.d("LongPoll", "recreate()")
} }
toggleLongPollService( toggleLongPollService(
@@ -241,6 +232,7 @@ fun RootScreen(
val context = LocalContext.current val context = LocalContext.current
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle() val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle() val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle() val isNeedToShowDeniedDialog by viewModel.isNeedToShowNotificationsDeniedDialog.collectAsStateWithLifecycle()
val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle() val isNeedToShowRationaleDialog by viewModel.isNeedToShowNotificationsRationaleDialog.collectAsStateWithLifecycle()
@@ -304,6 +296,12 @@ fun RootScreen(
) )
} }
RootErrorDialog(
baseError = baseError,
onDismiss = viewModel::onErrorConsumed,
onConfirm = viewModel::onErrorConsumed
)
if (startDestination != null) { if (startDestination != null) {
CompositionLocalProvider( CompositionLocalProvider(
LocalNavRootController provides navController, LocalNavRootController provides navController,
@@ -16,11 +16,17 @@ import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.model.LongPollState import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUseCase import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.StoreUsersUseCase
import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkUserData
import dev.meloda.fast.model.api.data.asDomain
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
@@ -33,6 +39,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.flow.last
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -55,6 +62,8 @@ class LongPollingService : Service() {
private val coroutineScope = CoroutineScope(coroutineContext) private val coroutineScope = CoroutineScope(coroutineContext)
private val longPollUseCase: LongPollUseCase by inject() private val longPollUseCase: LongPollUseCase by inject()
private val messagesUseCase: MessagesUseCase by inject()
private val storeUsersUseCase: StoreUsersUseCase by inject()
private val updatesParser: LongPollUpdatesParser by inject() private val updatesParser: LongPollUpdatesParser by inject()
private var currentJob: Job? = null private var currentJob: Job? = null
@@ -150,6 +159,8 @@ class LongPollingService : Service() {
var serverInfo = getServerInfo() var serverInfo = getServerInfo()
?: throw LongPollException(message = "bad VK response (server info)") ?: throw LongPollException(message = "bad VK response (server info)")
syncLongPollHistory(serverInfo)
var lastUpdatesResponse: LongPollUpdates? = getUpdatesResponse(serverInfo) var lastUpdatesResponse: LongPollUpdates? = getUpdatesResponse(serverInfo)
?: throw LongPollException(message = "initiation error: bad VK response (last updates)") ?: throw LongPollException(message = "initiation error: bad VK response (last updates)")
@@ -160,6 +171,7 @@ class LongPollingService : Service() {
failCount++ failCount++
serverInfo = getServerInfo() serverInfo = getServerInfo()
?: throw LongPollException(message = "failed retrieving server info after error: bad VK response (server info #2)") ?: throw LongPollException(message = "failed retrieving server info after error: bad VK response (server info #2)")
syncLongPollHistory(serverInfo)
lastUpdatesResponse = getUpdatesResponse(serverInfo) lastUpdatesResponse = getUpdatesResponse(serverInfo)
continue continue
} }
@@ -179,6 +191,7 @@ class LongPollingService : Service() {
?: throw LongPollException( ?: throw LongPollException(
message = "failed retrieving server info after error: bad VK response (server info #3)" message = "failed retrieving server info after error: bad VK response (server info #3)"
) )
syncLongPollHistory(serverInfo)
lastUpdatesResponse = getUpdatesResponse(serverInfo) lastUpdatesResponse = getUpdatesResponse(serverInfo)
} }
@@ -196,6 +209,9 @@ class LongPollingService : Service() {
updates.forEach(updatesParser::parseNextUpdate) updates.forEach(updatesParser::parseNextUpdate)
} }
AppSettings.LongPoll.ts = lastUpdatesResponse.ts ?: serverInfo.ts
AppSettings.LongPoll.pts = lastUpdatesResponse.pts ?: AppSettings.LongPoll.pts
lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs)) lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs))
} }
} }
@@ -246,6 +262,73 @@ class LongPollingService : Service() {
} }
} }
private suspend fun syncLongPollHistory(serverInfo: VkLongPollData) {
val cursorTs = AppSettings.LongPoll.ts ?: serverInfo.ts
val cursorPts = AppSettings.LongPoll.pts ?: serverInfo.pts
val maxMsgId = messagesUseCase.getLocalMaxMessageId()
var currentTs = cursorTs
var currentPts = cursorPts
var more: Int?
do {
val historyResponse = getLongPollHistory(
ts = currentTs,
pts = currentPts,
maxMsgId = maxMsgId
) ?: return
historyResponse.history.orEmpty().forEach(updatesParser::parseNextUpdate)
historyResponse.messages?.items.orEmpty().takeIf { it.isNotEmpty() }?.let { rawMessages ->
val messages = rawMessages.map { it.asDomain() }
messagesUseCase.storeMessages(messages)
}
historyResponse.profiles.orEmpty().takeIf { it.isNotEmpty() }?.let { profiles ->
val users = profiles.map(VkUserData::mapToDomain)
VkMemoryCache.appendUsers(users)
storeUsersUseCase(users).last()
}
historyResponse.groups.orEmpty().takeIf { it.isNotEmpty() }?.let { groups ->
VkMemoryCache.appendGroups(groups.map(VkGroupData::mapToDomain))
}
currentTs = historyResponse.ts ?: historyResponse.fromPts ?: currentTs
currentPts = historyResponse.newPts ?: historyResponse.pts ?: currentPts
more = historyResponse.more
AppSettings.LongPoll.ts = currentTs
AppSettings.LongPoll.pts = currentPts
} while (more == 1)
}
private suspend fun getLongPollHistory(
ts: Int,
pts: Int,
maxMsgId: Long?
): dev.meloda.fast.model.api.data.LongPollHistoryResponse? = suspendCancellableCoroutine {
longPollUseCase.getLongPollHistory(
ts = ts,
pts = pts,
lpVersion = VkConstants.LP_VERSION,
maxMsgId = maxMsgId,
eventsLimit = 1000,
msgsLimit = 200
).listenValue(coroutineScope) { state ->
state.processState(
success = { response ->
it.resume(response)
},
error = { error ->
Log.e(TAG, "getLongPollHistory: error: $error")
it.resume(null)
}
)
}
}
private fun handleError(throwable: Throwable) { private fun handleError(throwable: Throwable) {
Log.e(TAG, "error: $throwable") Log.e(TAG, "error: $throwable")
@@ -1,6 +1,7 @@
package dev.meloda.fast.service.longpolling.di package dev.meloda.fast.service.longpolling.di
import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.LongPollUpdatesReducer
import dev.meloda.fast.domain.LongPollUseCase import dev.meloda.fast.domain.LongPollUseCase
import dev.meloda.fast.domain.LongPollUseCaseImpl import dev.meloda.fast.domain.LongPollUseCaseImpl
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
@@ -10,4 +11,5 @@ import org.koin.dsl.module
val longPollModule = module { val longPollModule = module {
singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class singleOf(::LongPollUseCaseImpl) bind LongPollUseCase::class
singleOf(::LongPollUpdatesParser) singleOf(::LongPollUpdatesParser)
singleOf(::LongPollUpdatesReducer)
} }
@@ -19,9 +19,6 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
compileSdk = getVersionInt("compileSdk") compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk") targetSdk = getVersionInt("targetSdk")
} }
lint {
abortOnError = false
}
} }
} }
} }
@@ -2,7 +2,6 @@ import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.disableUnnecessaryAndroidTests import dev.meloda.fast.disableUnnecessaryAndroidTests
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@@ -21,13 +20,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
androidResources.enable = false androidResources.enable = false
defaultConfig { defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
minSdk = getVersionInt("minSdk")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
lint {
abortOnError = false
}
} }
extensions.configure<LibraryAndroidComponentsExtension> { extensions.configure<LibraryAndroidComponentsExtension> {
disableUnnecessaryAndroidTests(target) disableUnnecessaryAndroidTests(target)
@@ -25,6 +25,7 @@ internal fun Project.configureKotlinAndroid(
commonExtension.apply { commonExtension.apply {
compileSdk = getVersionInt("compileSdk") compileSdk = getVersionInt("compileSdk")
buildToolsVersion = "36.1.0"
} }
configureKotlin<KotlinAndroidProjectExtension>() configureKotlin<KotlinAndroidProjectExtension>()
@@ -4,7 +4,7 @@ object AppConstants {
const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive" const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive"
const val API_VERSION = "5.263" const val API_VERSION = "5.238"
const val URL_OAUTH = "https://oauth.vk.ru" const val URL_OAUTH = "https://oauth.vk.ru"
const val URL_API = "https://api.vk.ru/method" const val URL_API = "https://api.vk.ru/method"
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -45,6 +46,14 @@ fun <T> Flow<T>.listenValue(
action: suspend (T) -> Unit action: suspend (T) -> Unit
): Job = onEach(action::invoke).launchIn(coroutineScope) ): Job = onEach(action::invoke).launchIn(coroutineScope)
fun CoroutineScope.launchDbRefresh(
load: suspend () -> Unit,
after: suspend () -> Unit
): Job = launch {
load()
after()
}
fun createTimerFlow( fun createTimerFlow(
time: Int, time: Int,
onStartAction: (suspend () -> Unit)? = null, onStartAction: (suspend () -> Unit)? = null,
@@ -0,0 +1,25 @@
package dev.meloda.fast.common.paging
fun canPaginate(pageSize: Int, loadedCount: Int): Boolean = loadedCount == pageSize
fun isPaginationExhausted(
pageSize: Int,
loadedCount: Int,
hasExistingItems: Boolean
): Boolean = loadedCount != pageSize && hasExistingItems
fun <T> mergePage(
existing: List<T>,
page: List<T>,
offset: Int
): List<T> = if (offset == 0) page else existing + page
data class LoadingFlags(
val isLoading: Boolean,
val isPaginating: Boolean
)
fun loadingFlags(offset: Int, isLoading: Boolean): LoadingFlags = LoadingFlags(
isLoading = offset == 0 && isLoading,
isPaginating = offset > 0 && isLoading
)
@@ -1,24 +1,18 @@
package dev.meloda.fast.data package dev.meloda.fast.data
import androidx.core.net.toUri
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import java.net.URLEncoder
class AccessTokenInterceptor : Interceptor { class AccessTokenInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val builder = chain.request().url.newBuilder() val request = chain.request()
val urlBuilder = request.url.newBuilder()
val uri = builder.build().toUri().toString().toUri() if (request.url.queryParameter("access_token") == null) {
urlBuilder.addQueryParameter("access_token", UserConfig.accessToken)
if (uri.getQueryParameter("access_token") == null) {
builder.addQueryParameter(
"access_token",
URLEncoder.encode(UserConfig.accessToken, "utf-8")
)
} }
return chain.proceed(chain.request().newBuilder().apply { url(builder.build()) }.build()) return chain.proceed(request.newBuilder().url(urlBuilder.build()).build())
} }
} }
@@ -24,6 +24,9 @@ object UserConfig {
accessToken = "" accessToken = ""
fastToken = "" fastToken = ""
userId = -1 userId = -1
trustedHash = null
exchangeToken = null
AppSettings.LongPoll.clear()
} }
fun isLoggedIn(): Boolean { fun isLoggedIn(): Boolean {
@@ -21,12 +21,10 @@ class VkGroupsMap(
else map[abs(convo.id)] else map[abs(convo.id)]
fun messageActionGroup(message: VkMessage): VkGroupDomain? = fun messageActionGroup(message: VkMessage): VkGroupDomain? =
if (message.actionMemberId == null || message.actionMemberId!! >= 0) null message.actionMemberId?.takeIf { it < 0 }?.let { map[abs(it)] }
else map[abs(message.actionMemberId!!)]
fun messageActionGroup(message: VkMessageData): VkGroupDomain? = fun messageActionGroup(message: VkMessageData): VkGroupDomain? =
if (message.action?.memberId == null || message.action!!.memberId!! >= 0) null message.action?.memberId?.takeIf { it < 0 }?.let { map[abs(it)] }
else map[abs(message.action!!.memberId!!)]
fun messageGroup(message: VkMessage): VkGroupDomain? = fun messageGroup(message: VkMessage): VkGroupDomain? =
if (!message.isGroup()) null if (!message.isGroup()) null
@@ -1,6 +1,5 @@
package dev.meloda.fast.data package dev.meloda.fast.data
import dev.meloda.fast.data.UserConfig.userId
import dev.meloda.fast.model.api.domain.VkContactDomain import dev.meloda.fast.model.api.domain.VkContactDomain
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkGroupDomain import dev.meloda.fast.model.api.domain.VkGroupDomain
@@ -38,7 +37,15 @@ object VkMemoryCache {
contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact } contacts.forEach { contact -> VkMemoryCache.contacts[contact.userId] = contact }
} }
operator fun set(userid: Long, user: VkUser) { fun clear() {
users.clear()
groups.clear()
messages.clear()
convos.clear()
contacts.clear()
}
operator fun set(userId: Long, user: VkUser) {
users[userId] = user users[userId] = user
} }
@@ -20,12 +20,10 @@ class VkUsersMap(
else map[convo.id] else map[convo.id]
fun messageActionUser(message: VkMessage): VkUser? = fun messageActionUser(message: VkMessage): VkUser? =
if (message.actionMemberId == null || message.actionMemberId!! <= 0) null message.actionMemberId?.takeIf { it > 0 }?.let(map::get)
else map[message.actionMemberId]
fun messageActionUser(message: VkMessageData): VkUser? = fun messageActionUser(message: VkMessageData): VkUser? =
if (message.action?.memberId == null || message.action!!.memberId!! <= 0) null message.action?.memberId?.takeIf { it > 0 }?.let(map::get)
else map[message.action!!.memberId]
fun messageUser(message: VkMessage): VkUser? = fun messageUser(message: VkMessage): VkUser? =
if (!message.isUser()) null if (!message.isUser()) null
@@ -8,6 +8,9 @@ import dev.meloda.fast.network.RestApiErrorDomain
interface ConvosRepository { interface ConvosRepository {
suspend fun storeConvos(convos: List<VkConvo>) suspend fun storeConvos(convos: List<VkConvo>)
suspend fun getLocalConvos(): List<VkConvo>
suspend fun getLocalConvoById(peerId: Long): VkConvo?
suspend fun deleteLocalConvo(peerId: Long)
suspend fun getConvos( suspend fun getConvos(
count: Int?, count: Int?,
@@ -19,11 +19,13 @@ import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.database.VkConvoEntity
import dev.meloda.fast.model.api.requests.ConvosGetRequest import dev.meloda.fast.model.api.requests.ConvosGetRequest
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.convos.ConvosService import dev.meloda.fast.network.service.convos.ConvosService
import dev.meloda.fast.model.database.asExternalModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -40,6 +42,28 @@ class ConvosRepositoryImpl(
convoDao.insertAll(convos.map(VkConvo::asEntity)) convoDao.insertAll(convos.map(VkConvo::asEntity))
} }
override suspend fun getLocalConvos(): List<VkConvo> = withContext(Dispatchers.IO) {
convoDao.getAllWithMessage().map { convoWithMessage ->
convoWithMessage.convo.asExternalModel().copy(
lastMessage = convoWithMessage.message?.asExternalModel()
)
}.sorted()
}
override suspend fun getLocalConvoById(peerId: Long): VkConvo? = withContext(Dispatchers.IO) {
convoDao.getByIdWithMessage(peerId)?.let { convoWithMessage ->
convoWithMessage.convo.asExternalModel().copy(
lastMessage = convoWithMessage.message?.asExternalModel()
)
}
}
override suspend fun deleteLocalConvo(peerId: Long) {
withContext(Dispatchers.IO) {
convoDao.deleteById(peerId)
}
}
override suspend fun getConvos( override suspend fun getConvos(
count: Int?, count: Int?,
offset: Int?, offset: Int?,
@@ -198,4 +222,28 @@ class ConvosRepositoryImpl(
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) { ): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
convosService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault() convosService.unarchive(mapOf("peer_id" to peerId.toString())).mapApiDefault()
} }
private fun List<VkConvo>.sorted(): List<VkConvo> {
val newConvos = toMutableList()
val pinnedConvos = newConvos
.filter(VkConvo::isPinned)
.sortedWith { c1, c2 ->
val diff = c2.majorId - c1.majorId
if (diff == 0) {
c2.minorId - c1.minorId
} else {
diff
}
}
newConvos.removeAll(pinnedConvos)
newConvos.sortWith { c1, c2 ->
(c2.lastMessage?.date ?: 0) - (c1.lastMessage?.date ?: 0)
}
newConvos.addAll(0, pinnedConvos)
return newConvos
}
} }
@@ -1,6 +1,7 @@
package dev.meloda.fast.data.api.longpoll package dev.meloda.fast.data.api.longpoll
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult import com.slack.eithernet.ApiResult
@@ -21,4 +22,14 @@ interface LongPollRepository {
mode: Int, mode: Int,
version: Int version: Int
): ApiResult<LongPollUpdates, RestApiErrorDomain> ): ApiResult<LongPollUpdates, RestApiErrorDomain>
suspend fun getLongPollHistory(
ts: Int,
pts: Int,
lpVersion: Int,
lastN: Int? = null,
maxMsgId: Long? = null,
eventsLimit: Int? = null,
msgsLimit: Int? = null
): ApiResult<LongPollHistoryResponse, RestApiErrorDomain>
} }
@@ -1,7 +1,9 @@
package dev.meloda.fast.data.api.longpoll package dev.meloda.fast.data.api.longpoll
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.model.api.requests.LongPollGetHistoryRequest
import dev.meloda.fast.model.api.requests.LongPollGetUpdatesRequest import dev.meloda.fast.model.api.requests.LongPollGetUpdatesRequest
import dev.meloda.fast.model.api.requests.MessagesGetLongPollServerRequest import dev.meloda.fast.model.api.requests.MessagesGetLongPollServerRequest
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
@@ -52,4 +54,29 @@ class LongPollRepositoryImpl(
longPollService.getResponse(serverUrl, requestModel.map).mapDefault() longPollService.getResponse(serverUrl, requestModel.map).mapDefault()
} }
override suspend fun getLongPollHistory(
ts: Int,
pts: Int,
lpVersion: Int,
lastN: Int?,
maxMsgId: Long?,
eventsLimit: Int?,
msgsLimit: Int?
): ApiResult<LongPollHistoryResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = LongPollGetHistoryRequest(
ts = ts,
pts = pts,
lpVersion = lpVersion,
lastN = lastN,
maxMsgId = maxMsgId,
eventsLimit = eventsLimit,
msgsLimit = msgsLimit
)
messagesService.getLongPollHistory(requestModel.map).mapApiResult(
successMapper = { response -> response.requireResponse() },
errorMapper = { error -> error?.toDomain() }
)
}
} }
@@ -14,6 +14,12 @@ interface MessagesRepository {
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
suspend fun getLocalMessages(convoId: Long): List<VkMessage>
suspend fun getLocalMessageById(messageId: Long): VkMessage?
suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage?
suspend fun getLocalMaxMessageId(): Long?
suspend fun deleteLocalMessages(messageIds: List<Long>)
suspend fun getHistory( suspend fun getHistory(
convoId: Long, convoId: Long,
offset: Int?, offset: Int?,
@@ -22,6 +22,7 @@ import dev.meloda.fast.model.api.domain.VkGroupDomain
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.database.VkMessageEntity
import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest
import dev.meloda.fast.model.api.requests.MessagesDeleteRequest import dev.meloda.fast.model.api.requests.MessagesDeleteRequest
import dev.meloda.fast.model.api.requests.MessagesEditRequest import dev.meloda.fast.model.api.requests.MessagesEditRequest
@@ -39,6 +40,7 @@ import dev.meloda.fast.model.api.requests.MessagesUnpinMessageRequest
import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse import dev.meloda.fast.model.api.responses.MessagesGetConvoMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse import dev.meloda.fast.model.api.responses.MessagesGetReadPeersResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.model.database.asExternalModel
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.mapApiResult
@@ -354,6 +356,28 @@ class MessagesRepositoryImpl(
messageDao.insertAll(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
} }
override suspend fun getLocalMessages(convoId: Long): List<VkMessage> = withContext(Dispatchers.IO) {
messageDao.getAll(convoId).map(VkMessageEntity::asExternalModel)
}
override suspend fun getLocalMessageById(messageId: Long): VkMessage? = withContext(Dispatchers.IO) {
messageDao.getById(messageId)?.asExternalModel()
}
override suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage? = withContext(Dispatchers.IO) {
messageDao.getByConvoMessageId(convoId, cmId)?.asExternalModel()
}
override suspend fun getLocalMaxMessageId(): Long? = withContext(Dispatchers.IO) {
messageDao.getMaxId()
}
override suspend fun deleteLocalMessages(messageIds: List<Long>) {
withContext(Dispatchers.IO) {
messageDao.deleteByIds(messageIds)
}
}
override suspend fun edit( override suspend fun edit(
peerId: Long, peerId: Long,
messageId: Long?, messageId: Long?,
@@ -9,9 +9,13 @@ import dev.meloda.fast.model.database.VkConvoEntity
@Dao @Dao
abstract class ConvoDao : EntityDao<VkConvoEntity> { abstract class ConvoDao : EntityDao<VkConvoEntity> {
@Query("SELECT * FROM convos") @Query("SELECT * FROM convos ORDER BY majorId DESC, minorId DESC")
abstract suspend fun getAll(): List<VkConvoEntity> abstract suspend fun getAll(): List<VkConvoEntity>
@Transaction
@Query("SELECT * FROM convos ORDER BY majorId DESC, minorId DESC")
abstract suspend fun getAllWithMessage(): List<ConvoWithMessage>
@Query("SELECT * FROM convos WHERE id IN (:ids)") @Query("SELECT * FROM convos WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkConvoEntity> abstract suspend fun getAllByIds(ids: List<Int>): List<VkConvoEntity>
@@ -22,6 +26,9 @@ abstract class ConvoDao : EntityDao<VkConvoEntity> {
@Query("SELECT * FROM convos WHERE id IS (:id)") @Query("SELECT * FROM convos WHERE id IS (:id)")
abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage? abstract suspend fun getByIdWithMessage(id: Long): ConvoWithMessage?
@Query("DELETE FROM convos WHERE id IS (:id)")
abstract suspend fun deleteById(id: Long): Int
@Query("DELETE FROM convos WHERE rowid IN (:ids)") @Query("DELETE FROM convos WHERE rowid IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int abstract suspend fun deleteByIds(ids: List<Int>): Int
} }
@@ -10,15 +10,21 @@ abstract class MessageDao : EntityDao<VkMessageEntity> {
@Query("SELECT * FROM messages") @Query("SELECT * FROM messages")
abstract suspend fun getAll(): List<VkMessageEntity> abstract suspend fun getAll(): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE peerId IS (:convoId)") @Query("SELECT * FROM messages WHERE peerId IS (:convoId) ORDER BY date DESC, id DESC")
abstract suspend fun getAll(convoId: Long): List<VkMessageEntity> abstract suspend fun getAll(convoId: Long): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE id IN (:ids)") @Query("SELECT * FROM messages WHERE id IN (:ids)")
abstract suspend fun getAllByIds(ids: List<Int>): List<VkMessageEntity> abstract suspend fun getAllByIds(ids: List<Long>): List<VkMessageEntity>
@Query("SELECT * FROM messages WHERE id IS (:messageId)") @Query("SELECT * FROM messages WHERE id IS (:messageId)")
abstract suspend fun getById(messageId: Long): VkMessageEntity? abstract suspend fun getById(messageId: Long): VkMessageEntity?
@Query("SELECT * FROM messages WHERE peerId IS (:convoId) AND cmId IS (:cmId)")
abstract suspend fun getByConvoMessageId(convoId: Long, cmId: Long): VkMessageEntity?
@Query("SELECT MAX(id) FROM messages")
abstract suspend fun getMaxId(): Long?
@Query("DELETE FROM messages WHERE id IN (:ids)") @Query("DELETE FROM messages WHERE id IN (:ids)")
abstract suspend fun deleteByIds(ids: List<Int>): Int abstract suspend fun deleteByIds(ids: List<Long>): Int
} }
@@ -230,6 +230,21 @@ object AppSettings {
set(value) = put(SettingsKeys.KEY_MORE_ANIMATIONS, value) set(value) = put(SettingsKeys.KEY_MORE_ANIMATIONS, value)
} }
object LongPoll {
var ts: Int?
get() = get(SettingsKeys.KEY_LONG_POLL_TS, 0).takeIf { it > 0 }
set(value) = put(SettingsKeys.KEY_LONG_POLL_TS, value ?: 0)
var pts: Int?
get() = get(SettingsKeys.KEY_LONG_POLL_PTS, 0).takeIf { it > 0 }
set(value) = put(SettingsKeys.KEY_LONG_POLL_PTS, value ?: 0)
fun clear() {
ts = null
pts = null
}
}
object Debug { object Debug {
var showAlertAfterCrash: Boolean var showAlertAfterCrash: Boolean
get() = get( get() = get(
@@ -39,6 +39,8 @@ object SettingsKeys {
const val DEFAULT_VALUE_FEATURES_FAST_TEXT = "¯\\_(ツ)_/¯" const val DEFAULT_VALUE_FEATURES_FAST_TEXT = "¯\\_(ツ)_/¯"
const val KEY_LONG_POLL_IN_BACKGROUND = "lp_background" const val KEY_LONG_POLL_IN_BACKGROUND = "lp_background"
const val DEFAULT_LONG_POLL_IN_BACKGROUND = false const val DEFAULT_LONG_POLL_IN_BACKGROUND = false
const val KEY_LONG_POLL_TS = "lp_ts"
const val KEY_LONG_POLL_PTS = "lp_pts"
const val KEY_ACTIVITY_SEND_ONLINE_STATUS = "activity_send_online_status" const val KEY_ACTIVITY_SEND_ONLINE_STATUS = "activity_send_online_status"
const val DEFAULT_VALUE_KEY_ACTIVITY_SEND_ONLINE_STATUS = false const val DEFAULT_VALUE_KEY_ACTIVITY_SEND_ONLINE_STATUS = false
@@ -8,6 +8,9 @@ import kotlinx.coroutines.flow.Flow
interface ConvoUseCase : BaseUseCase { interface ConvoUseCase : BaseUseCase {
suspend fun storeConvos(convos: List<VkConvo>) suspend fun storeConvos(convos: List<VkConvo>)
suspend fun getLocalConvos(): List<VkConvo>
suspend fun getLocalConvoById(peerId: Long): VkConvo?
suspend fun deleteLocalConvo(peerId: Long)
fun getConvos( fun getConvos(
count: Int? = null, count: Int? = null,
@@ -19,6 +19,18 @@ class ConvoUseCaseImpl(
repository.storeConvos(convos) repository.storeConvos(convos)
} }
override suspend fun getLocalConvos(): List<VkConvo> = withContext(Dispatchers.IO) {
repository.getLocalConvos()
}
override suspend fun getLocalConvoById(peerId: Long): VkConvo? = withContext(Dispatchers.IO) {
repository.getLocalConvoById(peerId)
}
override suspend fun deleteLocalConvo(peerId: Long) = withContext(Dispatchers.IO) {
repository.deleteLocalConvo(peerId)
}
override fun getConvos( override fun getConvos(
count: Int?, count: Int?,
offset: Int?, offset: Int?,
@@ -0,0 +1,36 @@
package dev.meloda.fast.domain
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent
internal class LongPollEventDispatcher {
private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> =
mutableMapOf()
@Suppress("UNCHECKED_CAST")
fun <T : LongPollParsedEvent> dispatch(eventType: LongPollEvent, event: T) {
listenersMap[eventType]?.forEach { callback ->
(callback as? VkEventCallback<T>)?.onEvent(event)
}
}
fun dispatchAll(eventType: LongPollEvent, events: List<LongPollParsedEvent>) {
events.forEach { event -> dispatch(eventType, event) }
}
@Suppress("UNCHECKED_CAST")
fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: VkEventCallback<T>
) {
listenersMap[eventType] = (listenersMap[eventType] ?: mutableListOf())
.also { it.add(listener as VkEventCallback<LongPollParsedEvent>) }
}
fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<LongPollEvent>,
listener: VkEventCallback<T>
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
}
}
@@ -0,0 +1,537 @@
package dev.meloda.fast.domain
import android.util.Log
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.asInt
import dev.meloda.fast.common.extensions.asLong
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.toList
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.model.ApiEvent
import dev.meloda.fast.model.ConvoFlags
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.MessageFlags
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
internal class LongPollEventParser(
private val coroutineScope: CoroutineScope,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val dispatch: (LongPollEvent, LongPollParsedEvent) -> Unit,
private val dispatchAll: (LongPollEvent, List<LongPollParsedEvent>) -> Unit
) {
fun parseNextUpdate(event: List<Any>) {
val eventId = event.first().asInt()
when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> Unit
ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event)
ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event)
ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event)
ApiEvent.MESSAGE_EDIT -> parseMessageEdit(eventType, event)
ApiEvent.MESSAGE_READ_INCOMING -> parseMessageReadIncoming(eventType, event)
ApiEvent.MESSAGE_READ_OUTGOING -> parseMessageReadOutgoing(eventType, event)
ApiEvent.CHAT_CLEAR_FLAGS -> parseChatClearFlags(eventType, event)
ApiEvent.CHAT_SET_FLAGS -> parseChatSetFlags(eventType, event)
ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event)
ApiEvent.CHAT_MAJOR_CHANGED -> parseChatMajorChanged(eventType, event)
ApiEvent.CHAT_MINOR_CHANGED -> parseChatMinorChanged(eventType, event)
ApiEvent.TYPING,
ApiEvent.AUDIO_MESSAGE_RECORDING,
ApiEvent.PHOTO_UPLOADING,
ApiEvent.VIDEO_UPLOADING,
ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event)
ApiEvent.UNREAD_COUNT_UPDATE -> parseUnreadCounterUpdate(eventType, event)
ApiEvent.MESSAGE_UPDATED -> parseMessageUpdated(eventType, event)
ApiEvent.MESSAGE_CACHE_CLEAR -> parseMessageCacheClear(eventType, event)
}
}
private fun parseMessageSetFlags(eventType: ApiEvent, event: List<Any>) {
val cmId = event[1].asLong()
val flags = event[2].asInt()
val peerId = event[3].asLong()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = MessageFlags.parse(flags)
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
cmId = cmId,
marked = true
)
eventsToSend += eventToSend
dispatch(LongPollEvent.MARKED_AS_IMPORTANT, eventToSend)
}
MessageFlags.SPAM -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
dispatch(LongPollEvent.MARKED_AS_SPAM, eventToSend)
}
MessageFlags.DELETED -> {
val eventToSend =
if (parsedFlags.contains(MessageFlags.DELETED_FOR_ALL)) {
LongPollParsedEvent.MessageDeleted(
peerId = peerId,
cmId = cmId,
forAll = true
)
} else {
LongPollParsedEvent.MessageDeleted(
peerId = peerId,
cmId = cmId,
forAll = false
)
}
eventsToSend += eventToSend
dispatch(LongPollEvent.MESSAGE_DELETED, eventToSend)
}
MessageFlags.AUDIO_LISTENED -> {
val eventToSend = LongPollParsedEvent.AudioMessageListened(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
dispatch(LongPollEvent.AUDIO_MESSAGE_LISTENED, eventToSend)
}
else -> Unit
}
}
dispatchAll(LongPollEvent.MESSAGE_SET_FLAGS, eventsToSend)
}
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) {
val cmId = event[1].asLong()
val flags = event[2].asInt()
val peerId = event[3].asLong()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = MessageFlags.parse(flags)
coroutineScope.launch {
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
cmId = cmId,
marked = false
)
eventsToSend += eventToSend
dispatch(LongPollEvent.MARKED_AS_IMPORTANT, eventToSend)
}
MessageFlags.SPAM -> {
if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) {
withContext(Dispatchers.IO) {
val message = loadMessage(
peerId = peerId,
cmId = cmId
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageMarkedAsNotSpam(message = message)
eventsToSend += eventToSend
dispatch(LongPollEvent.MARKED_AS_NOT_SPAM, eventToSend)
}
}
}
}
MessageFlags.DELETED -> {
withContext(Dispatchers.IO) {
val message = loadMessage(
peerId = peerId,
cmId = cmId
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageRestored(message = message)
eventsToSend += eventToSend
dispatch(LongPollEvent.MESSAGE_RESTORED, eventToSend)
}
}
}
else -> Unit
}
}
dispatchAll(LongPollEvent.MESSAGE_CLEAR_FLAGS, eventsToSend)
}
}
private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) {
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message =
async { loadMessage(peerId = peerId, cmId = cmId) }.await()
val convo =
async {
loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
)
}.await()
message?.let {
dispatch(
LongPollEvent.MESSAGE_NEW,
LongPollParsedEvent.NewMessage(
message = message,
inArchive = convo?.isArchived == true
)
)
}
}
}
private fun parseMessageEdit(eventType: ApiEvent, event: List<Any>) {
val cmId = event[1].asLong()
val peerId = event[3].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
dispatch(LongPollEvent.MESSAGE_EDITED, LongPollParsedEvent.MessageEdited(message))
}
}
}
private fun parseMessageReadIncoming(eventType: ApiEvent, event: List<Any>) {
dispatchMessageRead(
longPollEvent = LongPollEvent.INCOMING_MESSAGE_READ,
parsedEvent = LongPollParsedEvent.IncomingMessageRead(
peerId = event[1].asLong(),
cmId = event[2].asLong(),
unreadCount = event[3].asInt()
)
)
}
private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List<Any>) {
dispatchMessageRead(
longPollEvent = LongPollEvent.OUTGOING_MESSAGE_READ,
parsedEvent = LongPollParsedEvent.OutgoingMessageRead(
peerId = event[1].asLong(),
cmId = event[2].asLong(),
unreadCount = event[3].asInt()
)
)
}
private fun parseChatClearFlags(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val flags = event[2].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConvoFlags.ARCHIVED -> {
handleArchivedChat(
peerId = peerId,
archived = false,
eventsToSend = eventsToSend
)
}
else -> Unit
}
}
dispatchAll(LongPollEvent.CHAT_CLEAR_FLAGS, eventsToSend)
}
}
private fun parseChatSetFlags(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val flags = event[2].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConvoFlags.ARCHIVED -> {
handleArchivedChat(
peerId = peerId,
archived = true,
eventsToSend = eventsToSend
)
}
else -> Unit
}
}
dispatchAll(LongPollEvent.CHAT_SET_FLAGS, eventsToSend)
}
}
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val cmId = event[2].asLong()
dispatch(
LongPollEvent.CHAT_CLEARED,
LongPollParsedEvent.ChatCleared(
peerId = peerId,
toCmId = cmId
)
)
}
private fun parseChatMajorChanged(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val majorId = event[2].asInt()
dispatch(
LongPollEvent.CHAT_MAJOR_CHANGED,
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = majorId,
)
)
}
private fun parseChatMinorChanged(eventType: ApiEvent, event: List<Any>) {
val peerId = event[1].asLong()
val minorId = event[2].asInt()
dispatch(
LongPollEvent.CHAT_MINOR_CHANGED,
LongPollParsedEvent.ChatMinorChanged(
peerId = peerId,
minorId = minorId,
)
)
}
private fun parseInteraction(eventType: ApiEvent, event: List<Any>) {
val interactionType = when (eventType) {
ApiEvent.TYPING -> InteractionType.Typing
ApiEvent.AUDIO_MESSAGE_RECORDING -> InteractionType.VoiceMessage
ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo
ApiEvent.VIDEO_UPLOADING -> InteractionType.Video
ApiEvent.FILE_UPLOADING -> InteractionType.File
else -> return
}
val longPollEvent: LongPollEvent = when (eventType) {
ApiEvent.TYPING -> LongPollEvent.TYPING
ApiEvent.AUDIO_MESSAGE_RECORDING -> LongPollEvent.AUDIO_MESSAGE_RECORDING
ApiEvent.PHOTO_UPLOADING -> LongPollEvent.PHOTO_UPLOADING
ApiEvent.VIDEO_UPLOADING -> LongPollEvent.VIDEO_UPLOADING
ApiEvent.FILE_UPLOADING -> LongPollEvent.FILE_UPLOADING
else -> return
}
val peerId = event[1].asLong()
val userIds = event[2].toList(Any::asLong).filter { it != UserConfig.userId }
val totalCount = event[3].asInt()
val timestamp = event[4].asInt()
if (userIds.isEmpty()) return
dispatch(
longPollEvent,
LongPollParsedEvent.Interaction(
interactionType = interactionType,
peerId = peerId,
userIds = userIds,
totalCount = totalCount,
timestamp = timestamp
)
)
}
private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List<Any>) {
val unreadCount = event[1].asInt()
val unreadUnmutedCount = event[2].asInt()
val showOnlyMuted = event[3].asInt() == 1
val businessNotifyUnreadCount = event[4].asInt()
val archiveUnreadCount = event[7].asInt()
val archiveUnreadUnmutedCount = event[8].asInt()
val archiveMentionsCount = event[9].asInt()
dispatch(
LongPollEvent.UNREAD_COUNTER_UPDATE,
LongPollParsedEvent.UnreadCounter(
unread = unreadCount,
unreadUnmuted = unreadUnmutedCount,
showOnlyMuted = showOnlyMuted,
business = businessNotifyUnreadCount,
archive = archiveUnreadCount,
archiveUnmuted = archiveUnreadUnmutedCount,
archiveMentions = archiveMentionsCount
)
)
}
private fun parseMessageUpdated(eventType: ApiEvent, event: List<Any>) {
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
dispatch(LongPollEvent.MESSAGE_UPDATED, LongPollParsedEvent.MessageUpdated(message))
}
}
}
private fun parseMessageCacheClear(eventType: ApiEvent, event: List<Any>) {
val messageId = event[1].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(messageId = messageId)?.let { message ->
dispatch(
LongPollEvent.MESSAGE_CACHE_CLEAR,
LongPollParsedEvent.MessageCacheClear(message)
)
}
}
}
private suspend fun loadMessage(
peerId: Long? = null,
cmId: Long? = null,
messageId: Long? = null
): VkMessage? = suspendCancellableCoroutine { continuation ->
require((peerId != null && cmId != null) || messageId != null)
val job = coroutineScope.launch(Dispatchers.IO) {
messagesUseCase.getById(
peerCmIds = null,
peerId = peerId,
messageIds = messageId?.let(::listOf),
cmIds = cmId?.let(::listOf),
extended = true,
fields = VkConstants.ALL_FIELDS
).listenValue(this) { state ->
state.processState(
error = { error ->
Log.e("LongPollEventParser", "loadMessage: error: $error")
continuation.resume(null)
},
success = { response ->
val message = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(message)
}
)
}
}
continuation.invokeOnCancellation {
job.cancel()
}
}
private suspend fun loadConvo(
peerId: Long,
extended: Boolean = false,
fields: String? = null
): VkConvo? = suspendCancellableCoroutine { continuation ->
val job = coroutineScope.launch(Dispatchers.IO) {
convoUseCase.getById(
peerIds = listOf(peerId),
extended = extended,
fields = fields
).listenValue(coroutineScope) { state ->
state.processState(
error = { error ->
Log.e("LongPollEventParser", "loadConvo: error: $error")
continuation.resume(null)
},
success = { response ->
val convo = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(convo)
}
)
}
}
continuation.invokeOnCancellation {
job.cancel()
}
}
private suspend fun handleArchivedChat(
peerId: Long,
archived: Boolean,
eventsToSend: MutableList<LongPollParsedEvent>
) {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return
val message = loadMessage(
peerId = peerId,
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
convo = convo.copy(lastMessage = message),
archived = archived
)
eventsToSend += eventToSend
dispatch(LongPollEvent.CHAT_ARCHIVED, eventToSend)
}
private fun dispatchMessageRead(
longPollEvent: LongPollEvent,
parsedEvent: LongPollParsedEvent
) {
dispatch(longPollEvent, parsedEvent)
}
}
@@ -1,35 +1,17 @@
package dev.meloda.fast.domain package dev.meloda.fast.domain
import android.util.Log import android.util.Log
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.asInt
import dev.meloda.fast.common.extensions.asLong
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.toList
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.model.ApiEvent
import dev.meloda.fast.model.ConvoFlags
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.MessageFlags
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LongPollUpdatesParser( class LongPollUpdatesParser(
private val convoUseCase: ConvoUseCase, convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase messagesUseCase: MessagesUseCase
) { ) {
private val job = SupervisorJob() private val job = SupervisorJob()
@@ -43,747 +25,89 @@ class LongPollUpdatesParser(
get() = Dispatchers.Default + job + exceptionHandler get() = Dispatchers.Default + job + exceptionHandler
private val coroutineScope = CoroutineScope(coroutineContext) private val coroutineScope = CoroutineScope(coroutineContext)
private val eventDispatcher = LongPollEventDispatcher()
private val listenersMap: MutableMap<LongPollEvent, MutableList<VkEventCallback<LongPollParsedEvent>>> = private val eventParser = LongPollEventParser(
mutableMapOf() coroutineScope = coroutineScope,
convoUseCase = convoUseCase,
messagesUseCase = messagesUseCase,
dispatch = eventDispatcher::dispatch,
dispatchAll = eventDispatcher::dispatchAll
)
fun parseNextUpdate(event: List<Any>) { fun parseNextUpdate(event: List<Any>) {
val eventId = event.first().asInt() eventParser.parseNextUpdate(event)
when (val eventType = ApiEvent.parseOrNull(eventId)) {
null -> Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event")
ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event)
ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event)
ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event)
ApiEvent.MESSAGE_EDIT -> parseMessageEdit(eventType, event)
ApiEvent.MESSAGE_READ_INCOMING -> parseMessageReadIncoming(eventType, event)
ApiEvent.MESSAGE_READ_OUTGOING -> parseMessageReadOutgoing(eventType, event)
ApiEvent.CHAT_CLEAR_FLAGS -> parseChatClearFlags(eventType, event)
ApiEvent.CHAT_SET_FLAGS -> parseChatSetFlags(eventType, event)
ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event)
ApiEvent.CHAT_MAJOR_CHANGED -> parseChatMajorChanged(eventType, event)
ApiEvent.CHAT_MINOR_CHANGED -> parseChatMinorChanged(eventType, event)
ApiEvent.TYPING,
ApiEvent.AUDIO_MESSAGE_RECORDING,
ApiEvent.PHOTO_UPLOADING,
ApiEvent.VIDEO_UPLOADING,
ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event)
ApiEvent.UNREAD_COUNT_UPDATE -> parseUnreadCounterUpdate(eventType, event)
ApiEvent.MESSAGE_UPDATED -> parseMessageUpdated(eventType, event)
ApiEvent.MESSAGE_CACHE_CLEAR -> parseMessageCacheClear(eventType, event)
}
}
private fun parseMessageSetFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val flags = event[2].asInt()
val peerId = event[3].asLong()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = MessageFlags.parse(flags)
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
cmId = cmId,
marked = true
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.SPAM -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_SPAM]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsSpam>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.DELETED -> {
val eventToSend =
if (parsedFlags.contains(MessageFlags.DELETED_FOR_ALL)) {
LongPollParsedEvent.MessageDeleted(
peerId = peerId,
cmId = cmId,
forAll = true
)
} else {
LongPollParsedEvent.MessageDeleted(
peerId = peerId,
cmId = cmId,
forAll = false
)
}
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_DELETED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageDeleted>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.AUDIO_LISTENED -> {
val eventToSend = LongPollParsedEvent.AudioMessageListened(
peerId = peerId,
cmId = cmId
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.AUDIO_MESSAGE_LISTENED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.AudioMessageListened>)
?.onEvent(eventToSend)
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_SET_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(eventToSend)
}
}
}
}
private fun parseMessageClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val flags = event[2].asInt()
val peerId = event[3].asLong()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = MessageFlags.parse(flags)
coroutineScope.launch {
parsedFlags.forEach { flag ->
when (flag) {
MessageFlags.IMPORTANT -> {
val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant(
peerId = peerId,
cmId = cmId,
marked = false
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_IMPORTANT]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsImportant>)
?.onEvent(eventToSend)
}
}
}
MessageFlags.SPAM -> {
if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) {
withContext(Dispatchers.IO) {
val message = loadMessage(
peerId = peerId,
cmId = cmId
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageMarkedAsNotSpam(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MARKED_AS_NOT_SPAM]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageMarkedAsNotSpam>)
?.onEvent(eventToSend)
}
}
}
}
}
}
MessageFlags.DELETED -> {
withContext(Dispatchers.IO) {
val message = loadMessage(
peerId = peerId,
cmId = cmId
)
message?.let {
val eventToSend =
LongPollParsedEvent.MessageRestored(message = message)
eventsToSend += eventToSend
listenersMap[LongPollEvent.MESSAGE_RESTORED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.MessageRestored>)
?.onEvent(eventToSend)
}
}
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.MESSAGE_CLEAR_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
vkEventCallback.onEvent(eventToSend)
}
}
}
}
}
private fun parseMessageNew(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
val message =
async { loadMessage(peerId = peerId, cmId = cmId) }.await()
val convo =
async {
loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
)
}.await()
message?.let {
listenersMap[LongPollEvent.MESSAGE_NEW]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.NewMessage>)
.onEvent(
LongPollParsedEvent.NewMessage(
message = message,
inArchive = convo?.isArchived == true
// TODO: 03-Apr-25, Danil Nikolaev:
// load user settings about restoring chats with
// enabled notifications from archive
)
)
}
}
}
}
}
private fun parseMessageEdit(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val cmId = event[1].asLong()
val peerId = event[3].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_EDITED]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageEdited>)
.onEvent(LongPollParsedEvent.MessageEdited(message))
}
}
}
}
}
private fun parseMessageReadIncoming(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val unreadCount = event[3].asInt()
listenersMap[LongPollEvent.INCOMING_MESSAGE_READ]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.IncomingMessageRead>)
.onEvent(
LongPollParsedEvent.IncomingMessageRead(
peerId = peerId,
cmId = cmId,
unreadCount = unreadCount
)
)
}
}
}
private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
val unreadCount = event[3].asInt()
listenersMap[LongPollEvent.OUTGOING_MESSAGE_READ]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.OutgoingMessageRead>)
.onEvent(
LongPollParsedEvent.OutgoingMessageRead(
peerId = peerId,
cmId = cmId,
unreadCount = unreadCount
)
)
}
}
}
private fun parseChatClearFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val flags = event[2].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConvoFlags.ARCHIVED -> {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return@forEach
val message = loadMessage(
peerId = peerId,
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
convo = convo.copy(lastMessage = message),
archived = false
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.ChatArchived>)
?.onEvent(eventToSend)
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.CHAT_CLEAR_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(
eventToSend
)
}
}
}
}
}
private fun parseChatSetFlags(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val flags = event[2].asInt()
val eventsToSend = mutableListOf<LongPollParsedEvent>()
val parsedFlags = ConvoFlags.parse(flags)
coroutineScope.launch(Dispatchers.IO) {
parsedFlags.forEach { flag ->
when (flag) {
ConvoFlags.ARCHIVED -> {
val convo = loadConvo(
peerId = peerId,
extended = true,
fields = VkConstants.ALL_FIELDS
) ?: return@forEach
val message = loadMessage(
peerId = peerId,
cmId = convo.lastCmId
)
val eventToSend = LongPollParsedEvent.ChatArchived(
convo = convo.copy(lastMessage = message),
archived = true
)
eventsToSend += eventToSend
listenersMap[LongPollEvent.CHAT_ARCHIVED]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent.ChatArchived>)
?.onEvent(eventToSend)
}
}
}
else -> Unit
}
}
eventsToSend.forEach { eventToSend ->
listenersMap[LongPollEvent.CHAT_SET_FLAGS]?.let { listeners ->
listeners.map { vkEventCallback ->
(vkEventCallback as? VkEventCallback<LongPollParsedEvent>)?.onEvent(
eventToSend
)
}
}
}
}
}
private fun parseMessagesDeleted(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val cmId = event[2].asLong()
listenersMap[LongPollEvent.CHAT_CLEARED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatCleared>)
.onEvent(
LongPollParsedEvent.ChatCleared(
peerId = peerId,
toCmId = cmId
)
)
}
}
}
private fun parseChatMajorChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val majorId = event[2].asInt()
listenersMap[LongPollEvent.CHAT_MAJOR_CHANGED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatMajorChanged>)
.onEvent(
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = majorId,
)
)
}
}
}
private fun parseChatMinorChanged(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val peerId = event[1].asLong()
val minorId = event[2].asInt()
listenersMap[LongPollEvent.CHAT_MINOR_CHANGED]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.ChatMinorChanged>)
.onEvent(
LongPollParsedEvent.ChatMinorChanged(
peerId = peerId,
minorId = minorId,
)
)
}
}
}
private fun parseInteraction(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType: $event")
val interactionType = when (eventType) {
ApiEvent.TYPING -> InteractionType.Typing
ApiEvent.AUDIO_MESSAGE_RECORDING -> InteractionType.VoiceMessage
ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo
ApiEvent.VIDEO_UPLOADING -> InteractionType.Video
ApiEvent.FILE_UPLOADING -> InteractionType.File
else -> return
}
val longPollEvent: LongPollEvent = when (eventType) {
ApiEvent.TYPING -> LongPollEvent.TYPING
ApiEvent.AUDIO_MESSAGE_RECORDING -> LongPollEvent.AUDIO_MESSAGE_RECORDING
ApiEvent.PHOTO_UPLOADING -> LongPollEvent.PHOTO_UPLOADING
ApiEvent.VIDEO_UPLOADING -> LongPollEvent.VIDEO_UPLOADING
ApiEvent.FILE_UPLOADING -> LongPollEvent.FILE_UPLOADING
else -> return
}
val peerId = event[1].asLong()
val userIds = event[2].toList(Any::asLong).filter { it != UserConfig.userId }
val totalCount = event[3].asInt()
val timestamp = event[4].asInt()
// if userIds contains only account's id, then we don't need to show our status
if (userIds.isEmpty()) return
listenersMap[longPollEvent]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.Interaction>)
.onEvent(
LongPollParsedEvent.Interaction(
interactionType = interactionType,
peerId = peerId,
userIds = userIds,
totalCount = totalCount,
timestamp = timestamp
)
)
}
}
}
private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
val unreadCount = event[1].asInt()
val unreadUnmutedCount = event[2].asInt()
val showOnlyMuted = event[3].asInt() == 1
val businessNotifyUnreadCount = event[4].asInt()
val archiveUnreadCount = event[7].asInt()
val archiveUnreadUnmutedCount = event[8].asInt()
val archiveMentionsCount = event[9].asInt()
listenersMap[LongPollEvent.UNREAD_COUNTER_UPDATE]?.let { listeners ->
listeners.forEach { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.UnreadCounter>)
.onEvent(
LongPollParsedEvent.UnreadCounter(
unread = unreadCount,
unreadUnmuted = unreadUnmutedCount,
showOnlyMuted = showOnlyMuted,
business = businessNotifyUnreadCount,
archive = archiveUnreadCount,
archiveUnmuted = archiveUnreadUnmutedCount,
archiveMentions = archiveMentionsCount
)
)
}
}
}
private fun parseMessageUpdated(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
val cmId = event[1].asLong()
val peerId = event[4].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(
peerId = peerId,
cmId = cmId
)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_UPDATED]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageUpdated>)
.onEvent(LongPollParsedEvent.MessageUpdated(message))
}
}
}
}
}
private fun parseMessageCacheClear(eventType: ApiEvent, event: List<Any>) {
Log.d("LongPollUpdatesParser", "$eventType $event")
val messageId = event[1].asLong()
coroutineScope.launch(Dispatchers.IO) {
loadMessage(messageId = messageId)?.let { message ->
listenersMap[LongPollEvent.MESSAGE_CACHE_CLEAR]?.let {
it.map { vkEventCallback ->
(vkEventCallback as VkEventCallback<LongPollParsedEvent.MessageCacheClear>)
.onEvent(LongPollParsedEvent.MessageCacheClear(message))
}
}
}
}
}
private suspend fun loadMessage(
peerId: Long? = null,
cmId: Long? = null,
messageId: Long? = null
): VkMessage? = suspendCoroutine { continuation ->
require((peerId != null && cmId != null) || messageId != null)
coroutineScope.launch(Dispatchers.IO) {
messagesUseCase.getById(
peerCmIds = null,
peerId = peerId,
messageIds = messageId?.let(::listOf),
cmIds = cmId?.let(::listOf),
extended = true,
fields = VkConstants.ALL_FIELDS
).listenValue(this) { state ->
state.processState(
error = { error ->
Log.e("LongPollUpdatesParser", "loadMessage: error: $error")
continuation.resume(null)
},
success = { response ->
val message = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(message)
}
)
}
}
}
private suspend fun loadConvo(
peerId: Long,
extended: Boolean = false,
fields: String? = null
): VkConvo? = suspendCoroutine { continuation ->
coroutineScope.launch(Dispatchers.IO) {
convoUseCase.getById(
peerIds = listOf(peerId),
extended = extended,
fields = fields
).listenValue(coroutineScope) { state ->
state.processState(
error = { error ->
Log.e("LongPollUpdatesParser", "loadConvo: error: $error")
continuation.resume(null)
},
success = { response ->
val convo = response.singleOrNull() ?: run {
continuation.resume(null)
return@listenValue
}
continuation.resume(convo)
}
)
}
}
}
@Suppress("UNCHECKED_CAST")
private fun <T : LongPollParsedEvent> registerListener(
eventType: LongPollEvent,
listener: VkEventCallback<T>
) {
listenersMap.let { map ->
map[eventType] = (map[eventType] ?: mutableListOf())
.also {
it.add(listener as VkEventCallback<LongPollParsedEvent>)
}
}
}
private fun <T : LongPollParsedEvent> registerListeners(
eventTypes: List<LongPollEvent>,
listener: VkEventCallback<T>
) {
eventTypes.forEach { eventType -> registerListener(eventType, listener) }
} }
fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) { fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MESSAGE_SET_FLAGS, assembleEventCallback(block))
} }
fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) { fun onMessageMarkedAsImportant(block: (LongPollParsedEvent.MessageMarkedAsImportant) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MARKED_AS_IMPORTANT, assembleEventCallback(block))
} }
fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) { fun onMessageMarkedAsSpam(block: (LongPollParsedEvent.MessageMarkedAsSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MARKED_AS_SPAM, assembleEventCallback(block))
} }
fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) { fun onMessageDeleted(block: (LongPollParsedEvent.MessageDeleted) -> Unit) {
registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MESSAGE_DELETED, assembleEventCallback(block))
} }
fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) { fun onMessageClearFlags(block: (LongPollParsedEvent) -> Unit) {
registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MESSAGE_CLEAR_FLAGS, assembleEventCallback(block))
} }
fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) { fun onMessageMarkedAsNotSpam(block: (LongPollParsedEvent.MessageMarkedAsNotSpam) -> Unit) {
registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MARKED_AS_NOT_SPAM, assembleEventCallback(block))
} }
fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) { fun onMessageRestored(block: (LongPollParsedEvent.MessageRestored) -> Unit) {
registerListener(LongPollEvent.MESSAGE_RESTORED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MESSAGE_RESTORED, assembleEventCallback(block))
}
fun onMessageUpdated(block: (LongPollParsedEvent.MessageUpdated) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MESSAGE_UPDATED, assembleEventCallback(block))
}
fun onMessageCacheClear(block: (LongPollParsedEvent.MessageCacheClear) -> Unit) {
eventDispatcher.registerListener(LongPollEvent.MESSAGE_CACHE_CLEAR, assembleEventCallback(block))
} }
fun onNewMessage(block: (LongPollParsedEvent.NewMessage) -> Unit) { fun onNewMessage(block: (LongPollParsedEvent.NewMessage) -> Unit) {
registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MESSAGE_NEW, assembleEventCallback(block))
} }
fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) { fun onMessageEdited(block: (LongPollParsedEvent.MessageEdited) -> Unit) {
registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.MESSAGE_EDITED, assembleEventCallback(block))
} }
fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) { fun onMessageIncomingRead(block: (LongPollParsedEvent.IncomingMessageRead) -> Unit) {
registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.INCOMING_MESSAGE_READ, assembleEventCallback(block))
} }
fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) { fun onMessageOutgoingRead(block: (LongPollParsedEvent.OutgoingMessageRead) -> Unit) {
registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.OUTGOING_MESSAGE_READ, assembleEventCallback(block))
} }
fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) { fun onChatCleared(block: (LongPollParsedEvent.ChatCleared) -> Unit) {
registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.CHAT_CLEARED, assembleEventCallback(block))
} }
fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) { fun onChatMajorChanged(block: (LongPollParsedEvent.ChatMajorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.CHAT_MAJOR_CHANGED, assembleEventCallback(block))
} }
fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) { fun onChatMinorChanged(block: (LongPollParsedEvent.ChatMinorChanged) -> Unit) {
registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.CHAT_MINOR_CHANGED, assembleEventCallback(block))
} }
fun onChatArchived(block: (LongPollParsedEvent.ChatArchived) -> Unit) { fun onChatArchived(block: (LongPollParsedEvent.ChatArchived) -> Unit) {
registerListener(LongPollEvent.CHAT_ARCHIVED, assembleEventCallback(block)) eventDispatcher.registerListener(LongPollEvent.CHAT_ARCHIVED, assembleEventCallback(block))
} }
fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) { fun onInteractions(block: (LongPollParsedEvent.Interaction) -> Unit) {
registerListeners( eventDispatcher.registerListeners(
eventTypes = listOf( eventTypes = listOf(
LongPollEvent.TYPING, LongPollEvent.TYPING,
LongPollEvent.AUDIO_MESSAGE_RECORDING, LongPollEvent.AUDIO_MESSAGE_RECORDING,
@@ -0,0 +1,213 @@
package dev.meloda.fast.domain
import android.util.Log
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
class LongPollUpdatesReducer(
updatesParser: LongPollUpdatesParser,
private val messagesUseCase: MessagesUseCase,
private val convoUseCase: ConvoUseCase
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _events = MutableSharedFlow<LongPollParsedEvent>(extraBufferCapacity = 256)
val events: SharedFlow<LongPollParsedEvent> = _events.asSharedFlow()
val newMessages = events.filterIsInstance<LongPollParsedEvent.NewMessage>()
val messageEdited = events.filterIsInstance<LongPollParsedEvent.MessageEdited>()
val messageIncomingRead = events.filterIsInstance<LongPollParsedEvent.IncomingMessageRead>()
val messageOutgoingRead = events.filterIsInstance<LongPollParsedEvent.OutgoingMessageRead>()
val messageDeleted = events.filterIsInstance<LongPollParsedEvent.MessageDeleted>()
val messageRestored = events.filterIsInstance<LongPollParsedEvent.MessageRestored>()
val messageMarkedAsImportant = events.filterIsInstance<LongPollParsedEvent.MessageMarkedAsImportant>()
val messageMarkedAsSpam = events.filterIsInstance<LongPollParsedEvent.MessageMarkedAsSpam>()
val messageMarkedAsNotSpam = events.filterIsInstance<LongPollParsedEvent.MessageMarkedAsNotSpam>()
val interactions = events.filterIsInstance<LongPollParsedEvent.Interaction>()
val chatMajorChanged = events.filterIsInstance<LongPollParsedEvent.ChatMajorChanged>()
val chatMinorChanged = events.filterIsInstance<LongPollParsedEvent.ChatMinorChanged>()
val chatCleared = events.filterIsInstance<LongPollParsedEvent.ChatCleared>()
val chatArchived = events.filterIsInstance<LongPollParsedEvent.ChatArchived>()
val messageUpdated = events.filterIsInstance<LongPollParsedEvent.MessageUpdated>()
val messageCacheClear = events.filterIsInstance<LongPollParsedEvent.MessageCacheClear>()
init {
updatesParser.onNewMessage { publish(it) }
updatesParser.onMessageEdited { publish(it) }
updatesParser.onMessageIncomingRead { publish(it) }
updatesParser.onMessageOutgoingRead { publish(it) }
updatesParser.onMessageDeleted { publish(it) }
updatesParser.onMessageRestored { publish(it) }
updatesParser.onMessageUpdated { publish(it) }
updatesParser.onMessageCacheClear { publish(it) }
updatesParser.onMessageMarkedAsImportant { publish(it) }
updatesParser.onMessageMarkedAsSpam { publish(it) }
updatesParser.onMessageMarkedAsNotSpam { publish(it) }
updatesParser.onInteractions { publish(it) }
updatesParser.onChatMajorChanged { publish(it) }
updatesParser.onChatMinorChanged { publish(it) }
updatesParser.onChatCleared { publish(it) }
updatesParser.onChatArchived { publish(it) }
}
private fun publish(event: LongPollParsedEvent) {
scope.launch {
runCatching { applyCommon(event) }
.onFailure { throwable ->
Log.e("LongPollUpdatesReducer", "applyCommon failed: $event", throwable)
}
_events.emit(event)
}
}
private suspend fun applyCommon(event: LongPollParsedEvent) {
when (event) {
is LongPollParsedEvent.NewMessage -> {
messagesUseCase.storeMessages(listOf(event.message))
updateConvoForMessage(event.message, unreadIncrement = if (event.message.isOut) 0 else 1)
}
is LongPollParsedEvent.MessageEdited -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageUpdated -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageCacheClear -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.IncomingMessageRead -> {
updateConvoReadState(
peerId = event.peerId,
inReadCmId = event.cmId,
unreadCount = event.unreadCount
)
}
is LongPollParsedEvent.OutgoingMessageRead -> {
updateConvoReadState(
peerId = event.peerId,
outReadCmId = event.cmId,
unreadCount = event.unreadCount
)
}
is LongPollParsedEvent.MessageDeleted -> {
val message = messagesUseCase.getLocalMessageByConvoMessageId(
convoId = event.peerId,
cmId = event.cmId
)
if (message != null) {
messagesUseCase.deleteLocalMessages(listOf(message.id))
}
}
is LongPollParsedEvent.MessageRestored -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.MessageMarkedAsImportant -> {
val message = messagesUseCase.getLocalMessageByConvoMessageId(
convoId = event.peerId,
cmId = event.cmId
) ?: return
messagesUseCase.storeMessage(message.copy(isImportant = event.marked))
}
is LongPollParsedEvent.MessageMarkedAsSpam -> {
val message = messagesUseCase.getLocalMessageByConvoMessageId(
convoId = event.peerId,
cmId = event.cmId
)
if (message != null) {
messagesUseCase.deleteLocalMessages(listOf(message.id))
}
}
is LongPollParsedEvent.MessageMarkedAsNotSpam -> {
messagesUseCase.storeMessage(event.message)
}
is LongPollParsedEvent.ChatMajorChanged -> {
updateConvoSortState(event.peerId, majorId = event.majorId)
}
is LongPollParsedEvent.ChatMinorChanged -> {
updateConvoSortState(event.peerId, minorId = event.minorId)
}
is LongPollParsedEvent.ChatCleared -> {
convoUseCase.deleteLocalConvo(event.peerId)
}
is LongPollParsedEvent.ChatArchived -> {
event.convo.lastMessage?.let(messagesUseCase::storeMessage)
convoUseCase.storeConvos(listOf(event.convo.copy(isArchived = event.archived)))
}
is LongPollParsedEvent.Interaction -> Unit
}
}
private suspend fun updateConvoReadState(
peerId: Long,
inReadCmId: Long? = null,
outReadCmId: Long? = null,
unreadCount: Int
) {
val convo = convoUseCase.getLocalConvoById(peerId) ?: return
convoUseCase.storeConvos(
listOf(
convo.copy(
inReadCmId = inReadCmId ?: convo.inReadCmId,
outReadCmId = outReadCmId ?: convo.outReadCmId,
unreadCount = unreadCount
)
)
)
}
private suspend fun updateConvoSortState(
peerId: Long,
majorId: Int? = null,
minorId: Int? = null
) {
val convo = convoUseCase.getLocalConvoById(peerId) ?: return
convoUseCase.storeConvos(
listOf(
convo.copy(
majorId = majorId ?: convo.majorId,
minorId = minorId ?: convo.minorId
)
)
)
}
private suspend fun updateConvoForMessage(
message: VkMessage,
unreadIncrement: Int
) {
val convo = convoUseCase.getLocalConvoById(message.peerId) ?: return
convoUseCase.storeConvos(
listOf(
convo.copy(
lastMessageId = message.id,
lastCmId = message.cmId,
unreadCount = convo.unreadCount + unreadIncrement
)
)
)
}
}
@@ -1,6 +1,7 @@
package dev.meloda.fast.domain package dev.meloda.fast.domain
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -21,4 +22,14 @@ interface LongPollUseCase {
mode: Int, mode: Int,
version: Int version: Int
): Flow<State<LongPollUpdates>> ): Flow<State<LongPollUpdates>>
fun getLongPollHistory(
ts: Int,
pts: Int,
lpVersion: Int,
lastN: Int? = null,
maxMsgId: Long? = null,
eventsLimit: Int? = null,
msgsLimit: Int? = null
): Flow<State<LongPollHistoryResponse>>
} }
@@ -3,6 +3,7 @@ package dev.meloda.fast.domain
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.data.api.longpoll.LongPollRepository import dev.meloda.fast.data.api.longpoll.LongPollRepository
import dev.meloda.fast.data.mapToState import dev.meloda.fast.data.mapToState
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.LongPollUpdates import dev.meloda.fast.model.api.data.LongPollUpdates
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -48,4 +49,27 @@ class LongPollUseCaseImpl(
).mapToState() ).mapToState()
emit(newState) emit(newState)
} }
override fun getLongPollHistory(
ts: Int,
pts: Int,
lpVersion: Int,
lastN: Int?,
maxMsgId: Long?,
eventsLimit: Int?,
msgsLimit: Int?
): Flow<State<LongPollHistoryResponse>> = flow {
emit(State.Loading)
val newState = repository.getLongPollHistory(
ts = ts,
pts = pts,
lpVersion = lpVersion,
lastN = lastN,
maxMsgId = maxMsgId,
eventsLimit = eventsLimit,
msgsLimit = msgsLimit
).mapToState()
emit(newState)
}
} }
@@ -12,6 +12,11 @@ interface MessagesUseCase : BaseUseCase {
suspend fun storeMessage(message: VkMessage) suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
suspend fun getLocalMessages(convoId: Long): List<VkMessage>
suspend fun getLocalMessageById(messageId: Long): VkMessage?
suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage?
suspend fun getLocalMaxMessageId(): Long?
suspend fun deleteLocalMessages(messageIds: List<Long>)
fun getMessagesHistory( fun getMessagesHistory(
convoId: Long, convoId: Long,
@@ -22,6 +22,26 @@ class MessagesUseCaseImpl(
repository.storeMessages(messages) repository.storeMessages(messages)
} }
override suspend fun getLocalMessages(convoId: Long): List<VkMessage> {
return repository.getLocalMessages(convoId)
}
override suspend fun getLocalMessageById(messageId: Long): VkMessage? {
return repository.getLocalMessageById(messageId)
}
override suspend fun getLocalMessageByConvoMessageId(convoId: Long, cmId: Long): VkMessage? {
return repository.getLocalMessageByConvoMessageId(convoId, cmId)
}
override suspend fun getLocalMaxMessageId(): Long? {
return repository.getLocalMaxMessageId()
}
override suspend fun deleteLocalMessages(messageIds: List<Long>) {
repository.deleteLocalMessages(messageIds)
}
override fun getMessagesHistory( override fun getMessagesHistory(
convoId: Long, convoId: Long,
count: Int?, count: Int?,
@@ -22,22 +22,35 @@ class OAuthUseCaseImpl(
): Flow<State<AuthInfo>> = flow { ): Flow<State<AuthInfo>> = flow {
emit(State.Loading) emit(State.Loading)
val newState = oAuthRepository.auth( val newState = when (val authResult = oAuthRepository.auth(
login = login, login = login,
password = password, password = password,
forceSms = forceSms, forceSms = forceSms,
validationCode = validationCode, validationCode = validationCode,
captchaSid = captchaSid, captchaSid = captchaSid,
captchaKey = captchaKey captchaKey = captchaKey
).asState( )) {
successMapper = { is com.slack.eithernet.ApiResult.Success -> {
AuthInfo( val value = authResult.value
userId = it.userId!!, val userId = value.userId
accessToken = it.accessToken!!, val accessToken = value.accessToken
validationHash = it.validationHash!! val validationHash = value.validationHash
)
if (userId == null || accessToken == null || validationHash == null) {
State.Error.InternalError
} else {
State.Success(
AuthInfo(
userId = userId,
accessToken = accessToken,
validationHash = validationHash
)
)
}
} }
)
else -> authResult.asState()
}
emit(newState) emit(newState)
} }
@@ -598,7 +598,6 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
AttachmentType.VIDEO_MESSAGE -> null AttachmentType.VIDEO_MESSAGE -> null
AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24 AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24
AttachmentType.STICKER_PACK_PREVIEW -> null AttachmentType.STICKER_PACK_PREVIEW -> null
AttachmentType.CHANNEL_MESSAGE -> null
}?.let(UiImage::Resource) }?.let(UiImage::Resource)
} }
@@ -688,7 +687,6 @@ fun getAttachmentUiText(
AttachmentType.VIDEO_MESSAGE -> R.string.message_attachments_video_message AttachmentType.VIDEO_MESSAGE -> R.string.message_attachments_video_message
AttachmentType.GROUP_CHAT_STICKER -> R.string.message_attachments_group_sticker AttachmentType.GROUP_CHAT_STICKER -> R.string.message_attachments_group_sticker
AttachmentType.STICKER_PACK_PREVIEW -> R.string.message_attachments_sticker_pack_preview AttachmentType.STICKER_PACK_PREVIEW -> R.string.message_attachments_sticker_pack_preview
AttachmentType.CHANNEL_MESSAGE -> R.string.message_attachments_channel_message
}.let(UiText::Resource) }.let(UiText::Resource)
} }
@@ -30,8 +30,7 @@ enum class AttachmentType(var value: String) {
ARTICLE("article"), ARTICLE("article"),
VIDEO_MESSAGE("video_message"), VIDEO_MESSAGE("video_message"),
GROUP_CHAT_STICKER("ugc_sticker"), GROUP_CHAT_STICKER("ugc_sticker"),
STICKER_PACK_PREVIEW("sticker_pack_preview"), STICKER_PACK_PREVIEW("sticker_pack_preview")
CHANNEL_MESSAGE("channel_message")
; ;
fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE) fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE)
@@ -0,0 +1,24 @@
package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LongPollHistoryResponse(
@Json(name = "history") val history: List<List<Any>>? = null,
@Json(name = "messages") val messages: Messages? = null,
@Json(name = "profiles") val profiles: List<VkUserData>? = null,
@Json(name = "groups") val groups: List<VkGroupData>? = null,
@Json(name = "new_pts") val newPts: Int? = null,
@Json(name = "from_pts") val fromPts: Int? = null,
@Json(name = "ts") val ts: Int? = null,
@Json(name = "pts") val pts: Int? = null,
@Json(name = "more") val more: Int? = null,
@Json(name = "conversations") val conversations: List<VkConvoData>? = null
) {
@JsonClass(generateAdapter = true)
data class Messages(
@Json(name = "count") val count: Int? = null,
@Json(name = "items") val items: List<VkMessageData>? = null
)
}
@@ -35,8 +35,7 @@ data class VkAttachmentItemData(
@Json(name = "article") val article: VkArticleData?, @Json(name = "article") val article: VkArticleData?,
@Json(name = "video_message") val videoMessage: VkVideoMessageData?, @Json(name = "video_message") val videoMessage: VkVideoMessageData?,
@Json(name = "ugc_sticker") val groupSticker: VkGroupStickerData?, @Json(name = "ugc_sticker") val groupSticker: VkGroupStickerData?,
@Json(name = "sticker_pack_preview") val stickerPackPreview: VkStickerPackPreviewData?, @Json(name = "sticker_pack_preview") val stickerPackPreview: VkStickerPackPreviewData?
@Json(name = "channel_message") val channelMessageData: VkChannelMessageData?
) { ) {
fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) { fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) {
AttachmentType.UNKNOWN -> VkUnknownAttachment AttachmentType.UNKNOWN -> VkUnknownAttachment
@@ -67,6 +66,5 @@ data class VkAttachmentItemData(
AttachmentType.VIDEO_MESSAGE -> videoMessage?.toDomain() AttachmentType.VIDEO_MESSAGE -> videoMessage?.toDomain()
AttachmentType.GROUP_CHAT_STICKER -> groupSticker?.toDomain() AttachmentType.GROUP_CHAT_STICKER -> groupSticker?.toDomain()
AttachmentType.STICKER_PACK_PREVIEW -> stickerPackPreview?.toDomain() AttachmentType.STICKER_PACK_PREVIEW -> stickerPackPreview?.toDomain()
AttachmentType.CHANNEL_MESSAGE -> channelMessageData?.toDomain()
} ?: VkUnknownAttachment } ?: VkUnknownAttachment
} }
@@ -1,40 +0,0 @@
package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.domain.VkChannelMessage
@JsonClass(generateAdapter = true)
data class VkChannelMessageData(
@Json(name = "channel_id") val channelId: Long,
@Json(name = "cmid") val cmId: Long,
@Json(name = "author_id") val authorId: Long,
@Json(name = "channel_info") val channelInfo: ChannelInfo,
@Json(name = "channel_type") val channelType: String,
@Json(name = "guid") val guid: String,
@Json(name = "text") val text: String?,
@Json(name = "time") val time: Long,
@Json(name = "attachments") val attachments: List<VkAttachmentItemData> = emptyList(),
) : VkAttachmentData {
@JsonClass(generateAdapter = true)
data class ChannelInfo(
@Json(name = "photo_base") val photoBase: String?,
@Json(name = "title") val title: String
)
fun toDomain(): VkChannelMessage = VkChannelMessage(
channelId = channelId,
cmId = cmId,
authorId = authorId,
channelInfo = VkChannelMessage.ChannelInfo(
title = channelInfo.title,
photoBase = channelInfo.photoBase
),
channelType = channelType,
guid = guid,
text = text,
time = time,
attachments = attachments.map(VkAttachmentItemData::toDomain),
)
}
@@ -1,23 +0,0 @@
package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.data.AttachmentType
data class VkChannelMessage(
val channelId: Long,
val cmId: Long,
val authorId: Long,
val channelInfo: ChannelInfo,
val channelType: String,
val guid: String,
val text: String?,
val time: Long,
val attachments: List<VkAttachment>?,
) : VkAttachment {
data class ChannelInfo(
val title: String,
val photoBase: String?
)
override val type: AttachmentType = AttachmentType.CHANNEL_MESSAGE
}
@@ -19,3 +19,27 @@ data class LongPollGetUpdatesRequest(
"version" to version.toString() "version" to version.toString()
) )
} }
data class LongPollGetHistoryRequest(
val ts: Int,
val pts: Int,
val lpVersion: Int,
val lastN: Int? = null,
val maxMsgId: Long? = null,
val eventsLimit: Int? = null,
val msgsLimit: Int? = null,
val extended: Boolean = true,
) {
val map: Map<String, String>
get() = mutableMapOf(
"ts" to ts.toString(),
"pts" to pts.toString(),
"lp_version" to lpVersion.toString(),
"extended" to if (extended) "1" else "0",
).apply {
lastN?.let { this["last_n"] = it.toString() }
maxMsgId?.let { this["max_msg_id"] = it.toString() }
eventsLimit?.let { this["events_limit"] = it.toString() }
msgsLimit?.let { this["msgs_limit"] = it.toString() }
}
}
@@ -1,90 +1,62 @@
package dev.meloda.fast.network.interceptor package dev.meloda.fast.network.interceptor
import android.util.Log
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.CaptchaTokenResult import dev.meloda.fast.datastore.CaptchaTokenResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.json.JSONObject import org.json.JSONObject
import java.util.concurrent.Executors import java.io.IOException
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import kotlin.time.Duration.Companion.minutes
class Error14HandlingInterceptor( class Error14HandlingInterceptor : Interceptor {
// private val domains: Set<String> = emptySet(),
) : Interceptor {
private val cookie = AtomicReference<String?>(null) private val cookie = AtomicReference<String?>(null)
private val captchaMutex = Mutex()
private companion object {
private const val CAPTCHA_ERROR_CODE = 14
private const val CAPTCHA_ERROR_KIND = "need_captcha"
private val executor = Executors.newSingleThreadExecutor()
}
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().withCookie() val request = chain.request().withCookie()
val response = chain.proceed(request) val response = chain.proceed(request)
response.parseCookie() response.parseCookie()
if (request.shouldSkipCaptcha()) return response if (request.shouldSkipCaptcha()) return response
val redirectUri = response.getRedirectUri() ?: return response val redirectUri = response.getRedirectUri() ?: return response
val token = passCaptchaAndGetToken(redirectUri) val token = awaitCaptchaToken(redirectUri)
return chain.proceed(chain.request().withCookie().withSuccessToken(token)) return chain.proceed(chain.request().withCookie().withSuccessToken(token))
} }
private fun passCaptchaAndGetToken(redirectUri: String): String = synchronized(this) { private fun awaitCaptchaToken(redirectUri: String): String = runBlocking(Dispatchers.IO) {
val tokenResult = AtomicReference<Result<String>>(Result.failure(Exception("No result"))) captchaMutex.withLock {
AppSettings.setCaptchaResult(CaptchaTokenResult.Initial)
executor.submit {
AppSettings.setCaptchaRedirectUri(redirectUri) AppSettings.setCaptchaRedirectUri(redirectUri)
Log.d("Error14Interceptor", "passCaptchaAndGetToken: $redirectUri")
var job: Job? = null try {
job = AppSettings.getCaptchaResultFlow() withTimeout(CAPTCHA_TIMEOUT) {
.listenValue(CoroutineScope(Dispatchers.IO)) { AppSettings.getCaptchaResultFlow()
Log.d("Error14Interceptor", "passCaptchaAndGetToken: $it") .first { it != CaptchaTokenResult.Initial }
if (it != CaptchaTokenResult.Initial) { .toToken()
synchronized(tokenResult) {
Log.d(
"Error14Interceptor",
"passCaptchaAndGetToken: SYNCHRONIZED: $it"
)
tokenResult.set(wrapResult(it))
tokenResult.notifyAll()
job?.cancel()
Log.d(
"Error14Interceptor",
"passCaptchaAndGetToken: NULL RESULT"
)
AppSettings.setCaptchaResult(CaptchaTokenResult.Initial)
AppSettings.setCaptchaRedirectUri(null)
}
}
} }
} } finally {
synchronized(tokenResult) { AppSettings.setCaptchaResult(CaptchaTokenResult.Initial)
if (tokenResult.get().getOrNull() == null) { AppSettings.setCaptchaRedirectUri(null)
tokenResult.wait()
} }
Log.d("Error14Interceptor", "passCaptchaAndGetToken: GET VALUE")
tokenResult.get().getOrThrow()
} }
} }
private fun wrapResult(result: CaptchaTokenResult): Result<String> { private fun CaptchaTokenResult.toToken(): String = when (this) {
return when (result) { is CaptchaTokenResult.Success -> token
// TODO: 03/05/2026, Danil Nikolaev: check again? CaptchaTokenResult.Cancelled -> throw IOException("Captcha cancelled")
CaptchaTokenResult.Null -> Result.success("") CaptchaTokenResult.Null -> throw IOException("Captcha result is empty")
CaptchaTokenResult.Initial -> throw IllegalStateException("Captcha result not ready")
CaptchaTokenResult.Cancelled, CaptchaTokenResult.Initial -> Result.success("")
is CaptchaTokenResult.Success -> Result.success(result.token)
}
} }
private fun Request.withSuccessToken(token: String): Request { private fun Request.withSuccessToken(token: String): Request {
@@ -133,13 +105,10 @@ class Error14HandlingInterceptor(
private fun Request.withCookie(): Request { private fun Request.withCookie(): Request {
return newBuilder().apply { cookie.get()?.let { addHeader("Cookie", it) } }.build() return newBuilder().apply { cookie.get()?.let { addHeader("Cookie", it) } }.build()
} }
private companion object {
private const val CAPTCHA_ERROR_CODE = 14
private const val CAPTCHA_ERROR_KIND = "need_captcha"
private val CAPTCHA_TIMEOUT = 10.minutes
}
} }
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
private inline fun Any.wait() = (this as Object).wait()
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
private inline fun Any.notify() = (this as Object).notify()
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
private inline fun Any.notifyAll() = (this as Object).notifyAll()
@@ -2,6 +2,7 @@ package dev.meloda.fast.network.service.messages
import com.slack.eithernet.ApiResult import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.data.VkChatData import dev.meloda.fast.model.api.data.VkChatData
import dev.meloda.fast.model.api.data.LongPollHistoryResponse
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.model.api.data.VkMessageData import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse
@@ -44,6 +45,12 @@ interface MessagesService {
@FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
): ApiResult<ApiResponse<VkLongPollData>, RestApiError> ): ApiResult<ApiResponse<VkLongPollData>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.GET_LONG_POLL_HISTORY)
suspend fun getLongPollHistory(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<LongPollHistoryResponse>, RestApiError>
@FormUrlEncoded @FormUrlEncoded
@POST(MessagesUrls.MARK_AS_READ) @POST(MessagesUrls.MARK_AS_READ)
suspend fun markAsRead( suspend fun markAsRead(
@@ -1,7 +1,6 @@
package dev.meloda.fast.ui.components package dev.meloda.fast.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Indication
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -21,8 +20,6 @@ import androidx.compose.ui.Alignment
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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -33,32 +30,27 @@ fun FastIconButton(
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
enabled: Boolean = true, enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
containerColor: Color = colors.containerColor(enabled),
contentColor: Color = colors.contentColor(enabled),
size: Dp = IconButtonTokens.StateLayerSize,
shape: Shape = IconButtonTokens.StateLayerShape,
alignment: Alignment = Alignment.Center,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
indication: Indication = ripple(),
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
Box( Box(
modifier = modifier =
modifier modifier
.minimumInteractiveComponentSize() .minimumInteractiveComponentSize()
.size(size) .size(IconButtonTokens.StateLayerSize)
.clip(shape) .clip(IconButtonTokens.StateLayerShape)
.background(containerColor) .background(color = colors.containerColor(enabled))
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
enabled = enabled, enabled = enabled,
interactionSource = interactionSource, interactionSource = interactionSource,
indication = indication indication = ripple()
), ),
contentAlignment = alignment contentAlignment = Alignment.Center
) { ) {
CompositionLocalProvider(LocalContentColor provides contentColor) { content() } val contentColor = colors.contentColor(enabled)
CompositionLocalProvider(LocalContentColor provides contentColor, content = content)
} }
} }
@@ -1,116 +0,0 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.util.ImmutableList
data class SegmentedButtonItem(
val key: String,
val iconResId: Int
)
@Composable
fun SegmentedButtonsRow(
items: ImmutableList<SegmentedButtonItem>,
onClick: (index: Int) -> Unit,
modifier: Modifier = Modifier,
containerShape: CornerBasedShape = RoundedCornerShape(24.dp),
containerColor: Color = MaterialTheme.colorScheme.background,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant,
borderSize: Dp = 1.dp,
iconContainerWidth: Dp = 42.dp,
iconContainerHeight: Dp = 36.dp,
iconSize: Dp = 18.dp,
showDividers: Boolean = true
) {
SegmentedButtonsRow(
modifier = modifier.sizeIn(maxHeight = iconContainerHeight + borderSize),
items = items.mapIndexed { index, item ->
{
val first = index == 0
val last = index == items.lastIndex
if (showDividers && !first) {
VerticalDivider(modifier = Modifier.padding(vertical = iconContainerHeight / 4))
}
SegmentedButton(
onClick = { onClick(index) },
iconResId = item.iconResId,
modifier = Modifier.size(
iconContainerWidth,
iconContainerHeight
),
iconSize = iconSize,
shape = containerShape.copy(
topStart = if (!first) CornerSize(0.dp) else containerShape.topStart,
bottomStart = if (!first) CornerSize(0.dp) else containerShape.bottomStart,
topEnd = if (!last) CornerSize(0.dp) else containerShape.topEnd,
bottomEnd = if (!last) CornerSize(0.dp) else containerShape.bottomEnd
)
)
}
},
containerShape = containerShape,
containerColor = containerColor,
borderColor = borderColor,
borderSize = borderSize
)
}
@Composable
fun SegmentedButtonsRow(
items: ImmutableList<@Composable () -> Unit>,
modifier: Modifier = Modifier,
containerShape: CornerBasedShape = RoundedCornerShape(24.dp),
containerColor: Color = MaterialTheme.colorScheme.background,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant,
borderSize: Dp = 1.dp,
) {
Row(
modifier = modifier
.background(containerColor, containerShape)
.border(borderSize, borderColor, containerShape)
) {
items.forEach { it.invoke() }
}
}
@Composable
fun SegmentedButton(
onClick: () -> Unit,
iconResId: Int,
modifier: Modifier = Modifier,
iconSize: Dp = 18.dp,
shape: Shape = CircleShape
) {
FastIconButton(
onClick = onClick,
modifier = modifier,
shape = shape
) {
Icon(
modifier = Modifier.size(iconSize),
painter = painterResource(iconResId),
contentDescription = null
)
}
}
@@ -58,7 +58,7 @@ fun AppTheme(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val colorScheme: ColorScheme = predefinedColorScheme ?: when { val colorScheme: ColorScheme = when {
useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (useDarkTheme) dynamicDarkColorScheme(context) if (useDarkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context) else dynamicLightColorScheme(context)
@@ -82,6 +82,10 @@ fun AppTheme(
} }
} }
val colorPrimary by animateColorAsState(colorScheme.primary)
val colorSurface by animateColorAsState(colorScheme.surface)
val colorBackground by animateColorAsState(colorScheme.background)
val typography = if (useSystemFont) { val typography = if (useSystemFont) {
MaterialTheme.typography MaterialTheme.typography
} else { } else {
@@ -114,7 +118,12 @@ fun AppTheme(
} }
MaterialExpressiveTheme( MaterialExpressiveTheme(
colorScheme = colorScheme, colorScheme = (predefinedColorScheme ?: colorScheme)
.copy(
primary = colorPrimary,
background = colorBackground,
surface = colorSurface
),
typography = typography, typography = typography,
content = content content = content
) )
@@ -18,7 +18,6 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -114,12 +113,11 @@ fun LazyListState.isScrollingUp(): Boolean {
@Composable @Composable
fun isNeedToEnableDarkMode(darkMode: DarkMode): Boolean { fun isNeedToEnableDarkMode(darkMode: DarkMode): Boolean {
val context = LocalContext.current val context = LocalContext.current
val configuration = LocalConfiguration.current
val appForceDarkMode = darkMode == DarkMode.ENABLED val appForceDarkMode = darkMode == DarkMode.ENABLED
val appBatterySaver = darkMode == DarkMode.AUTO_BATTERY val appBatterySaver = darkMode == DarkMode.AUTO_BATTERY
val systemUiNightMode = configuration.uiMode val systemUiNightMode = context.resources.configuration.uiMode
val isSystemBatterySaver = context.getSystemService<PowerManager>()?.isPowerSaveMode == true val isSystemBatterySaver = context.getSystemService<PowerManager>()?.isPowerSaveMode == true
val isSystemUsingDarkTheme = val isSystemUsingDarkTheme =
@@ -1,7 +1,6 @@
package dev.meloda.fast.ui.util package dev.meloda.fast.ui.util
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Immutable @Immutable
class ImmutableList<T>(val values: List<T>) : Collection<T> { class ImmutableList<T>(val values: List<T>) : Collection<T> {
@@ -58,9 +57,3 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList()) fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements)) fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements))
inline fun <T> buildImmutableList(builderAction: MutableList<T>.() -> Unit): ImmutableList<T> {
val mutableList = mutableListOf<T>()
mutableList.apply(builderAction)
return mutableList.toImmutableList()
}
-4
View File
@@ -88,7 +88,6 @@
<string name="message_attachments_video_message">Video message</string> <string name="message_attachments_video_message">Video message</string>
<string name="message_attachments_group_sticker">Group sticker</string> <string name="message_attachments_group_sticker">Group sticker</string>
<string name="message_attachments_sticker_pack_preview">Sticker pack preview</string> <string name="message_attachments_sticker_pack_preview">Sticker pack preview</string>
<string name="message_attachments_channel_message">Channel message</string>
<string name="chat_interaction_uploading_file">Uploading file</string> <string name="chat_interaction_uploading_file">Uploading file</string>
<string name="chat_interaction_uploading_photo">Uploading photo</string> <string name="chat_interaction_uploading_photo">Uploading photo</string>
@@ -306,7 +305,4 @@
<string name="confirm_chat_create_empty_with_title">Are you sure you want to create chat «%s» only with yourself?</string> <string name="confirm_chat_create_empty_with_title">Are you sure you want to create chat «%s» only with yourself?</string>
<string name="title_edit_message">Edit message</string> <string name="title_edit_message">Edit message</string>
<string name="action_close">Close</string>
<string name="action_hide_stacktrace">Hide stacktrace</string>
<string name="action_show_stacktrace">Show stacktrace</string>
</resources> </resources>
-9
View File
@@ -2,13 +2,4 @@
<resources> <resources>
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar" /> <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar" />
<style name="CrashDialogTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources> </resources>
@@ -100,13 +100,7 @@ class LoginViewModel(
} }
fun onBackPressed() { fun onBackPressed() {
_screenState.setValue { old -> _screenState.setValue { old -> old.copy(showLogo = true) }
old.copy(
showLogo = true,
loginError = false,
passwordError = false
)
}
} }
fun onPasswordVisibilityButtonClicked() { fun onPasswordVisibilityButtonClicked() {
@@ -31,13 +31,9 @@ import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.autofill.ContentType
@@ -62,6 +58,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginDialog import dev.meloda.fast.auth.login.model.LoginDialog
import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
@@ -70,8 +67,6 @@ import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.ClassicColorScheme
import dev.meloda.fast.ui.util.handleEnterKey import dev.meloda.fast.ui.util.handleEnterKey
import dev.meloda.fast.ui.util.handleTabKey import dev.meloda.fast.ui.util.handleTabKey
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -119,27 +114,17 @@ fun LoginRoute(
viewModel.onValidationCodeReceived(validationCode) viewModel.onValidationCodeReceived(validationCode)
} }
var useClassic by rememberSaveable { mutableStateOf(true) } LoginScreen(
screenState = screenState,
AppTheme( onLoginInputChanged = viewModel::onLoginInputChanged,
predefinedColorScheme = if (useClassic) ClassicColorScheme.lightScheme onPasswordInputChanged = viewModel::onPasswordInputChanged,
else lightColorScheme(), onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked,
) { onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
LoginScreen( onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
screenState = screenState, onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLoginInputChanged = viewModel::onLoginInputChanged, onLogoClicked = viewModel::onLogoClicked,
onPasswordInputChanged = viewModel::onPasswordInputChanged, onLogoLongClicked = onNavigateToSettings
onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked, )
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLogoClicked = {
viewModel.onLogoClicked()
useClassic = !useClassic
},
onLogoLongClicked = onNavigateToSettings
)
}
HandleDialogs( HandleDialogs(
loginDialog = loginDialog, loginDialog = loginDialog,
@@ -9,12 +9,16 @@ import dev.meloda.fast.chatmaterials.navigation.ChatMaterials
import dev.meloda.fast.chatmaterials.util.asPresentation import dev.meloda.fast.chatmaterials.util.asPresentation
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.paging.canPaginate as canPaginatePage
import dev.meloda.fast.common.paging.isPaginationExhausted as isPaginationExhaustedPage
import dev.meloda.fast.common.paging.loadingFlags
import dev.meloda.fast.common.paging.mergePage
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.network.VkErrorCode
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -89,11 +93,14 @@ class ChatMaterialsViewModelImpl(
state.processState( state.processState(
error = ::handleError, error = ::handleError,
success = { response -> success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT val itemsCountSufficient = canPaginatePage(LOAD_COUNT, response.size)
canPaginate.setValue { itemsCountSufficient } canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient val paginationExhausted = isPaginationExhaustedPage(
&& screenState.value.materials.isNotEmpty() pageSize = LOAD_COUNT,
loadedCount = response.size,
hasExistingItems = screenState.value.materials.isNotEmpty()
)
val loadedMaterials = response.mapNotNull(VkAttachmentHistoryMessage::asPresentation) val loadedMaterials = response.mapNotNull(VkAttachmentHistoryMessage::asPresentation)
@@ -107,64 +114,27 @@ class ChatMaterialsViewModelImpl(
} }
) )
if (offset == 0) { screenState.setValue {
screenState.setValue { newState.copy(
newState.copy(materials = loadedMaterials) materials = mergePage(newState.materials, loadedMaterials, offset)
} )
} else {
screenState.setValue {
newState.copy(
materials = newState.materials.plus(loadedMaterials)
)
}
} }
} }
) )
val flags = loadingFlags(offset, state.isLoading())
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
isLoading = offset == 0 && state.isLoading(), isLoading = flags.isLoading,
isPaginating = offset > 0 && state.isLoading() isPaginating = flags.isPaginating
) )
} }
} }
} }
private fun handleError(error: State.Error) { private fun handleError(error: State.Error) {
when (error) { VkUtils.parseError(error)?.let { newBaseError ->
is State.Error.ApiError -> { baseError.setValue { newBaseError }
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
baseError.setValue {
BaseError.SimpleError(message = error.errorMessage)
}
}
}
}
State.Error.ConnectionError -> {
baseError.setValue {
BaseError.SimpleError(message = "Connection error")
}
}
State.Error.InternalError -> {
baseError.setValue {
BaseError.SimpleError(message = "Internal error")
}
}
State.Error.UnknownError -> {
baseError.setValue {
BaseError.SimpleError(message = "Unknown error")
}
}
else -> Unit
} }
} }
@@ -3,229 +3,210 @@ package dev.meloda.fast.convos
import android.content.Context import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.os.Bundle import android.os.Bundle
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import com.conena.nanokt.collections.indexOfFirstOrNull import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.createTimerFlow import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.common.extensions.findWithIndex import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.extensions.launchDbRefresh
import dev.meloda.fast.common.extensions.updateValue import dev.meloda.fast.common.extensions.updateValue
import dev.meloda.fast.common.paging.canPaginate as canPaginatePage
import dev.meloda.fast.common.paging.isPaginationExhausted as isPaginationExhaustedPage
import dev.meloda.fast.common.paging.loadingFlags
import dev.meloda.fast.common.paging.mergePage
import dev.meloda.fast.convos.model.ConvoDialog import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoIntent import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.model.ConvosScreenState import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.model.InteractionJob import dev.meloda.fast.convos.model.InteractionJob
import dev.meloda.fast.convos.model.NewInteractionException import dev.meloda.fast.convos.model.NewInteractionException
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkUtils import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConvoUseCase import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.LoadConvosByIdUseCase import dev.meloda.fast.domain.LongPollUpdatesReducer
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.domain.util.extractAvatar import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.model.vk.ConvoOption import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@Immutable
class ConvosViewModel( class ConvosViewModel(
updatesParser: LongPollUpdatesParser, updatesReducer: LongPollUpdatesReducer,
val filter: ConvosFilter, private val filter: ConvosFilter,
private val convoUseCase: ConvoUseCase, private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val resources: Resources, private val resources: Resources,
private val userSettings: UserSettings, private val userSettings: UserSettings,
private val imageLoader: ImageLoader, private val imageLoader: ImageLoader,
private val applicationContext: Context, private val applicationContext: Context
private val loadConvosByIdUseCase: LoadConvosByIdUseCase
) : ViewModel() { ) : ViewModel() {
private val _screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
private val screenState = MutableStateFlow(ConvosScreenState.EMPTY) private val _navigation = MutableStateFlow<ConvoNavigation?>(null)
val screenStateFlow get() = screenState.asStateFlow() val navigation = _navigation.asStateFlow()
private val navigationIntent = MutableStateFlow<ConvoNavigationIntent?>(null) private val _dialog = MutableStateFlow<ConvoDialog?>(null)
val navigationIntentFlow get() = navigationIntent.asStateFlow() val dialog = _dialog.asStateFlow()
private val convos: MutableList<VkConvo> = mutableListOf() private val _convos = MutableStateFlow<List<VkConvo>>(emptyList())
val convos = _convos.asStateFlow()
private val pinnedConvosCount get() = convos.count(VkConvo::isPinned) private val _uiConvos = MutableStateFlow<List<UiConvo>>(emptyList())
val uiConvos = _uiConvos.asStateFlow()
private var currentOffset = 0 private val pinnedConvosCount = convos.map { convos ->
convos.count(VkConvo::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
private val _baseError = MutableStateFlow<BaseError?>(null)
val baseError = _baseError.asStateFlow()
private val _currentOffset = MutableStateFlow(0)
val currentOffset = _currentOffset.asStateFlow()
private val _canPaginate = MutableStateFlow(false)
val canPaginate = _canPaginate.asStateFlow()
private val expandedConvoId = MutableStateFlow(0L)
private val useContactNames: Boolean get() = userSettings.useContactNames.value
private val interactionsTimers = hashMapOf<Long, InteractionJob?>() private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init { init {
_screenState.updateValue { copy(isArchive = filter == ConvosFilter.ARCHIVE) }
loadConvos() loadConvos()
updatesParser.onNewMessage(::handleNewMessage) updatesReducer.newMessages.onEach(::handleNewMessage).launchIn(viewModelScope)
updatesParser.onMessageEdited(::handleEditedMessage) updatesReducer.messageEdited.onEach(::handleEditedMessage).launchIn(viewModelScope)
updatesParser.onMessageIncomingRead(::handleReadIncomingMessage) updatesReducer.messageIncomingRead.onEach(::handleReadIncomingMessage).launchIn(viewModelScope)
updatesParser.onMessageOutgoingRead(::handleReadOutgoingMessage) updatesReducer.messageOutgoingRead.onEach(::handleReadOutgoingMessage).launchIn(viewModelScope)
updatesParser.onInteractions(::handleInteraction) updatesReducer.interactions.onEach(::handleInteraction).launchIn(viewModelScope)
updatesParser.onChatMajorChanged(::handleChatMajorChanged) updatesReducer.chatMajorChanged.onEach(::handleChatMajorChanged).launchIn(viewModelScope)
updatesParser.onChatMinorChanged(::handleChatMinorChanged) updatesReducer.chatMinorChanged.onEach(::handleChatMinorChanged).launchIn(viewModelScope)
updatesParser.onChatCleared(::handleChatClearing) updatesReducer.chatCleared.onEach(::handleChatClearing).launchIn(viewModelScope)
updatesParser.onChatArchived(::handleChatArchived) updatesReducer.chatArchived.onEach(::handleChatArchived).launchIn(viewModelScope)
userSettings.useContactNames.listenValue(viewModelScope) { userSettings.useContactNames.listenValue(viewModelScope) {
syncUiConvos() syncUiConvos()
} }
} }
fun handleIntent(intent: ConvoIntent) {
when (intent) {
ConvoIntent.ArchiveClick -> {
navigationIntent.setValue { ConvoNavigationIntent.Archive }
}
ConvoIntent.Back -> {
navigationIntent.setValue { ConvoNavigationIntent.Back }
}
ConvoIntent.ConsumeScrollToTop -> Unit
ConvoIntent.CreateChatClick -> {
navigationIntent.setValue { ConvoNavigationIntent.CreateChat }
}
ConvoIntent.ErrorActionButtonClick -> {
onRefresh()
}
is ConvoIntent.ItemClick -> {
onConvoItemClick(intent.convoId)
}
is ConvoIntent.ItemLongClick -> {
onConvoItemLongClick(intent.convoId)
}
is ConvoIntent.OptionItemClick -> {
onOptionClicked(intent.option)
}
ConvoIntent.PaginationConditionsMet -> {
onPaginationConditionsMet()
}
ConvoIntent.Refresh -> {
onRefresh()
}
is ConvoIntent.SetScrollIndex -> {
setScrollIndex(intent.index)
}
is ConvoIntent.SetScrollOffset -> {
setScrollOffset(intent.offset)
}
is ConvoIntent.Dialog -> {
when (intent) {
is ConvoIntent.Dialog.Cancel -> Unit
is ConvoIntent.Dialog.Confirm -> onDialogConfirmed(intent.bundle)
ConvoIntent.Dialog.Dismiss -> onDialogDismissed()
}
}
}
}
fun onNavigationConsumed() { fun onNavigationConsumed() {
navigationIntent.setValue { null } _navigation.setValue { null }
} }
private fun onDialogConfirmed(bundle: Bundle?) { fun onDialogConfirmed(dialog: ConvoDialog, bundle: Bundle) {
val dialog = screenState.value.dialog ?: return onDialogDismissed(dialog)
onDialogDismissed()
val convo = with(screenState.value) {
convos.find { it.id == expandedConvoId }
} ?: return
when (dialog) { when (dialog) {
is ConvoDialog.Delete -> { is ConvoDialog.ConvoDelete -> {
deleteConvo(convo.id) deleteConvo(dialog.convoId)
} }
is ConvoDialog.Pin -> { is ConvoDialog.ConvoPin -> {
pinConvo(convo.id, true) pinConvo(dialog.convoId, true)
} }
is ConvoDialog.Unpin -> { is ConvoDialog.ConvoUnpin -> {
pinConvo(convo.id, false) pinConvo(dialog.convoId, false)
} }
is ConvoDialog.Archive -> { is ConvoDialog.ConvoArchive -> {
archiveConvo(convo.id, true) archiveConvo(dialog.convoId, true)
} }
is ConvoDialog.Unarchive -> { is ConvoDialog.ConvoUnarchive -> {
archiveConvo(convo.id, false) archiveConvo(dialog.convoId, false)
} }
} }
collapseConvos(false) expandedConvoId.setValue { 0 }
syncUiConvos() syncUiConvos()
} }
private fun onDialogDismissed() { fun onDialogDismissed(dialog: ConvoDialog) {
screenState.updateValue { copy(dialog = null) } _dialog.setValue { null }
} }
private fun onPaginationConditionsMet() { fun onDialogItemPicked(dialog: ConvoDialog, bundle: Bundle) {
currentOffset = convos.size when (dialog) {
is ConvoDialog.ConvoDelete -> Unit
is ConvoDialog.ConvoPin -> Unit
is ConvoDialog.ConvoUnpin -> Unit
is ConvoDialog.ConvoArchive -> Unit
is ConvoDialog.ConvoUnarchive -> Unit
}
}
fun onErrorButtonClicked() {
when (baseError.value) {
null -> Unit
is BaseError.ConnectionError,
is BaseError.InternalError,
is BaseError.SimpleError,
is BaseError.UnknownError -> onRefresh()
else -> Unit
}
}
fun onPaginationConditionsMet() {
_currentOffset.update { convos.value.size }
loadConvos() loadConvos()
} }
private fun onErrorConsumed() { fun onRefresh() {
screenState.updateValue { copy(error = null) }
}
private fun onRefresh() {
onErrorConsumed() onErrorConsumed()
loadConvos(offset = 0) loadConvos(offset = 0)
} }
private fun onConvoItemClick(convoId: Long) { fun onConvoItemClick(convo: UiConvo) {
collapseConvos() collapseConvos()
navigationIntent.setValue { ConvoNavigationIntent.MessagesHistory(convoId) } _navigation.setValue { ConvoNavigation.MessagesHistory(peerId = convo.id) }
} }
private fun onConvoItemLongClick(convoId: Long) { fun onConvoItemLongClick(convo: UiConvo) {
val isExpanded = screenState.value.convos.find { it.id == convoId }?.isExpanded == true expandedConvoId.setValue {
if (convo.isExpanded) 0
screenState.updateValue { copy(expandedConvoId = if (isExpanded) 0L else convoId) } else convo.id
}
syncUiConvos() syncUiConvos()
} }
private fun onOptionClicked(option: ConvoOption) { fun onOptionClicked(
val convo = convo: UiConvo,
screenState.value.convos.find { it.id == screenState.value.expandedConvoId } ?: return option: ConvoOption
) {
when (option) { when (option) {
ConvoOption.Delete -> setDialog(ConvoDialog.Delete) ConvoOption.Delete -> {
_dialog.setValue { ConvoDialog.ConvoDelete(convo.id) }
}
ConvoOption.MarkAsRead -> { ConvoOption.MarkAsRead -> {
val lastMessageId = convo.lastMessageId?.let { lastMessageId ->
screenState.value.convos.find { it.id == screenState.value.expandedConvoId }?.lastMessageId
if (lastMessageId != null) {
readConvo( readConvo(
peerId = convo.id, peerId = convo.id,
startMessageId = lastMessageId startMessageId = lastMessageId
@@ -234,39 +215,52 @@ class ConvosViewModel(
} }
} }
ConvoOption.Pin -> setDialog(ConvoDialog.Pin) ConvoOption.Pin -> {
ConvoOption.Unpin -> setDialog(ConvoDialog.Unpin) _dialog.setValue { ConvoDialog.ConvoPin(convo.id) }
ConvoOption.Archive -> setDialog(ConvoDialog.Archive) }
ConvoOption.Unarchive -> setDialog(ConvoDialog.Unarchive)
ConvoOption.Unpin -> {
_dialog.setValue { ConvoDialog.ConvoUnpin(convo.id) }
}
ConvoOption.Archive -> {
_dialog.setValue { ConvoDialog.ConvoArchive(convo.id) }
}
ConvoOption.Unarchive -> {
_dialog.setValue { ConvoDialog.ConvoUnarchive(convo.id) }
}
} }
} }
private fun setScrollIndex(index: Int) { fun onErrorConsumed() {
screenState.setValue { old -> old.copy(scrollIndex = index) } _baseError.setValue { null }
} }
private fun setScrollOffset(offset: Int) { fun setScrollIndex(index: Int) {
screenState.setValue { old -> old.copy(scrollOffset = offset) } _screenState.setValue { old -> old.copy(scrollIndex = index) }
} }
private fun setDialog(dialog: ConvoDialog?) { fun setScrollOffset(offset: Int) {
screenState.updateValue { copy(dialog = dialog) } _screenState.setValue { old -> old.copy(scrollOffset = offset) }
} }
private fun replaceConvos(newConvos: List<VkConvo>) { fun onCreateChatButtonClicked() {
convos.clear() _navigation.setValue { ConvoNavigation.CreateChat }
convos.addAll(newConvos)
} }
private fun collapseConvos(sync: Boolean = true) { private fun collapseConvos() {
screenState.updateValue { copy(expandedConvoId = null) } expandedConvoId.setValue { 0 }
syncUiConvos()
}
if (sync) { private fun loadConvos(
syncUiConvos() offset: Int = currentOffset.value
) {
if (offset == 0) {
refreshConvosFromDb()
} }
}
private fun loadConvos(offset: Int = currentOffset) {
convoUseCase.getConvos( convoUseCase.getConvos(
count = LOAD_COUNT, count = LOAD_COUNT,
offset = offset, offset = offset,
@@ -274,20 +268,18 @@ class ConvosViewModel(
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
screenState.updateValue { copy(error = VkUtils.parseError(error)) } val newBaseError = VkUtils.parseError(error)
_baseError.update { newBaseError }
}, },
success = { response -> success = { response ->
val newConvos = if (offset == 0) { val itemsCountSufficient = canPaginatePage(LOAD_COUNT, response.size)
response val paginationExhausted = isPaginationExhaustedPage(
} else { pageSize = LOAD_COUNT,
convos.plus(response) loadedCount = response.size,
} hasExistingItems = this.convos.value.isNotEmpty()
)
val itemsCountSufficient = response.size == LOAD_COUNT _screenState.updateValue {
val paginationExhausted = !itemsCountSufficient && convos.isNotEmpty()
screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted) copy(isPaginationExhausted = paginationExhausted)
} }
@@ -304,17 +296,17 @@ class ConvosViewModel(
convoUseCase.storeConvos(response) convoUseCase.storeConvos(response)
replaceConvos(newConvos) _convos.emit(mergePage(this.convos.value, response, offset))
screenState.updateValue { copy(canPaginate = itemsCountSufficient) }
syncUiConvos() syncUiConvos()
_canPaginate.setValue { itemsCountSufficient }
} }
) )
screenState.setValue { old -> val flags = loadingFlags(offset, state.isLoading())
_screenState.setValue { old ->
old.copy( old.copy(
isLoading = offset == 0 && state.isLoading(), isLoading = flags.isLoading,
isPaginating = offset > 0 && state.isLoading() isPaginating = flags.isPaginating
) )
} }
} }
@@ -325,17 +317,13 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
val newConvos = convos.toMutableList() viewModelScope.launch {
val convoIndex = convoUseCase.deleteLocalConvo(peerId)
newConvos.indexOfFirstOrNull { it.id == peerId } refreshConvosFromDb()
?: return@processState }
newConvos.removeAt(convoIndex)
replaceConvos(newConvos.sorted())
syncUiConvos()
} }
) )
screenState.emit(screenStateFlow.value.copy(isLoading = state.isLoading())) _screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
} }
} }
@@ -345,20 +333,26 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
handleChatMajorChanged( viewModelScope.launch {
LongPollParsedEvent.ChatMajorChanged( convoUseCase.getLocalConvoById(peerId)?.let { convo ->
peerId = peerId, convoUseCase.storeConvos(
majorId = if (pin) { listOf(
pinnedConvosCount.plus(1) * 16 convo.copy(
} else { majorId = if (pin) {
0 pinnedConvosCount.value.plus(1) * 16
} } else {
) 0
) }
)
)
)
}
refreshConvosFromDb()
}
} }
) )
screenState.setValue { old -> old.copy(isLoading = state.isLoading()) } _screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
} }
} }
@@ -368,144 +362,35 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
convos.find { it.id == peerId }?.let { convo -> viewModelScope.launch {
handleChatArchived( convoUseCase.getLocalConvoById(peerId)?.let { convo ->
LongPollParsedEvent.ChatArchived( convoUseCase.storeConvos(
convo = convo, listOf(
archived = archive convo.copy(isArchived = archive)
)
) )
) }
refreshConvosFromDb()
} }
} }
) )
} }
} }
// TODO: 03-Apr-25, Danil Nikolaev: handle business messages
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message refreshConvosFromDb()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) {
if (event.inArchive != (filter == ConvosFilter.ARCHIVE)) return
loadConvosByIdUseCase(
peerIds = listOf(message.peerId),
extended = true,
fields = VkConstants.ALL_FIELDS
).listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = { response ->
val convo = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message)
newConvos.add(pinnedConvosCount, convo)
replaceConvos(newConvos.sorted())
syncUiConvos()
}
)
}
} else {
val convo = newConvos[convoIndex]
var newConvo = convo.copy(
lastMessage = message,
lastMessageId = message.id,
lastCmId = message.cmId,
unreadCount = if (message.isOut) convo.unreadCount
else convo.unreadCount + 1
)
interactionsTimers[convo.id]?.let { job ->
if (job.interactionType == InteractionType.Typing
&& message.fromId in convo.interactionIds
) {
val newInteractionIds = newConvo.interactionIds.filter { id ->
id != message.fromId
}
newConvo = newConvo.copy(
interactionType = if (newInteractionIds.isEmpty()) -1 else {
newConvo.interactionType
},
interactionIds = newInteractionIds
)
}
}
if (convo.isPinned()) {
newConvos[convoIndex] = newConvo
} else {
newConvos.removeAt(convoIndex)
newConvos.add(pinnedConvosCount, newConvo)
}
replaceConvos(newConvos.sorted())
syncUiConvos()
}
} }
private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) { private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
val message = event.message refreshConvosFromDb()
val newConvos = convos.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
val convo = newConvos[convoIndex]
newConvos[convoIndex] = convo.copy(
lastMessage = message,
lastMessageId = message.id,
lastCmId = message.cmId
)
replaceConvos(newConvos)
syncUiConvos()
}
} }
private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) { private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) {
val newConvos = convos.toMutableList() refreshConvosFromDb()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(
inReadCmId = event.cmId,
unreadCount = event.unreadCount
)
replaceConvos(newConvos)
syncUiConvos()
}
} }
private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) { private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) {
val newConvos = convos.toMutableList() refreshConvosFromDb()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(
outReadCmId = event.cmId,
unreadCount = event.unreadCount
)
replaceConvos(newConvos)
syncUiConvos()
}
} }
private fun handleInteraction(event: LongPollParsedEvent.Interaction) { private fun handleInteraction(event: LongPollParsedEvent.Interaction) {
@@ -513,7 +398,7 @@ class ConvosViewModel(
val peerId = event.peerId val peerId = event.peerId
val userIds = event.userIds val userIds = event.userIds
val newConvos = convos.toMutableList() val newConvos = convos.value.toMutableList()
val convoAndIndex = val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } newConvos.findWithIndex { it.id == peerId }
@@ -524,7 +409,7 @@ class ConvosViewModel(
interactionIds = userIds interactionIds = userIds
) )
replaceConvos(newConvos) _convos.update { newConvos }
syncUiConvos() syncUiConvos()
interactionsTimers[peerId]?.let { interactionJob -> interactionsTimers[peerId]?.let { interactionJob ->
@@ -556,7 +441,7 @@ class ConvosViewModel(
private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) { private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return interactionsTimers[peerId] ?: return
val newConvos = convos.toMutableList() val newConvos = convos.value.toMutableList()
val convoAndIndex = val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } ?: return newConvos.findWithIndex { it.id == peerId } ?: return
@@ -566,7 +451,7 @@ class ConvosViewModel(
interactionIds = emptyList() interactionIds = emptyList()
) )
replaceConvos(newConvos) _convos.update { newConvos }
syncUiConvos() syncUiConvos()
interactionJob.timerJob.cancel() interactionJob.timerJob.cancel()
@@ -574,88 +459,19 @@ class ConvosViewModel(
} }
private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) { private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) {
val newConvos = convos.toMutableList() refreshConvosFromDb()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(majorId = event.majorId)
replaceConvos(newConvos.sorted())
syncUiConvos()
}
} }
private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) { private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) {
val newConvos = convos.toMutableList() refreshConvosFromDb()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(minorId = event.minorId)
replaceConvos(newConvos.sorted())
syncUiConvos()
}
} }
private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) { private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) {
val newConvos = convos.toMutableList() refreshConvosFromDb()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos.removeAt(convoIndex)
replaceConvos(newConvos.sorted())
syncUiConvos()
}
} }
private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) { private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) {
val convo = event.convo refreshConvosFromDb()
val newConvos = convos.toMutableList()
when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit
ConvosFilter.ARCHIVE -> {
if (event.archived) {
newConvos.add(0, convo)
} else {
val index = newConvos.indexOfFirstOrNull { it.id == convo.id }
if (index == null) return
newConvos.removeAt(index)
}
replaceConvos(newConvos)
syncUiConvos()
}
else -> {
if (event.archived) {
val index = newConvos.indexOfFirstOrNull { it.id == convo.id }
if (index == null) return
newConvos.removeAt(index)
} else {
newConvos.add(pinnedConvosCount, convo)
}
replaceConvos(newConvos.sorted())
syncUiConvos()
}
}
} }
private fun readConvo(peerId: Long, startMessageId: Long) { private fun readConvo(peerId: Long, startMessageId: Long) {
@@ -666,21 +482,42 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
val newConvos = convos.toMutableList() viewModelScope.launch {
val convoIndex = convoUseCase.getLocalConvoById(peerId)?.let { convo ->
newConvos.indexOfFirstOrNull { it.id == peerId } convoUseCase.storeConvos(
?: return@listenValue listOf(
convo.copy(
newConvos[convoIndex] = inRead = startMessageId,
newConvos[convoIndex].copy(inRead = startMessageId) unreadCount = 0
)
replaceConvos(newConvos) )
syncUiConvos() )
}
refreshConvosFromDb()
}
} }
) )
} }
} }
private fun refreshConvosFromDb() {
viewModelScope.launchDbRefresh(
load = {
val localConvos = convoUseCase.getLocalConvos()
val filteredConvos = when (filter) {
ConvosFilter.ARCHIVE -> localConvos.filter(VkConvo::isArchived)
ConvosFilter.UNREAD -> localConvos.filter { !it.isArchived && it.unreadCount > 0 }
ConvosFilter.ALL -> localConvos.filterNot(VkConvo::isArchived)
ConvosFilter.BUSINESS_NOTIFY -> localConvos
}
_convos.emit(filteredConvos)
},
after = ::syncUiConvos
)
}
private fun List<VkConvo>.sorted(): List<VkConvo> { private fun List<VkConvo>.sorted(): List<VkConvo> {
val newConvos = toMutableList() val newConvos = toMutableList()
@@ -706,44 +543,47 @@ class ConvosViewModel(
} }
private fun syncUiConvos(): List<UiConvo> { private fun syncUiConvos(): List<UiConvo> {
val convos = convos.value
val newUiConvos = convos.map { convo -> val newUiConvos = convos.map { convo ->
val options: ImmutableList<ConvoOption> = buildImmutableList { val options = mutableListOf<ConvoOption>()
if (!convo.isRead() && convo.lastMessage != null && convo.lastMessage?.isOut == false) { convo.lastMessage?.run {
add(ConvoOption.MarkAsRead) if (!convo.isRead() && !this.isOut) {
options += ConvoOption.MarkAsRead
} }
if (convo.isPinned()) {
add(ConvoOption.Unpin)
}
if (convos.size > 4 && pinnedConvosCount < 5 && !convo.isPinned()) {
add(ConvoOption.Pin)
}
when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit
ConvosFilter.ARCHIVE -> add(ConvoOption.Unarchive)
ConvosFilter.ALL,
ConvosFilter.UNREAD -> {
if (convo.id != UserConfig.userId) {
add(ConvoOption.Archive)
}
}
}
add(ConvoOption.Delete)
} }
val convosSize = this.convos.value.size
val pinnedCount = pinnedConvosCount.value
val canPinOneMoreDialog =
convosSize > 4 && pinnedCount < 5 && !convo.isPinned()
if (convo.isPinned()) {
options += ConvoOption.Unpin
} else if (canPinOneMoreDialog) {
options += ConvoOption.Pin
}
when (filter) {
ConvosFilter.ARCHIVE -> ConvoOption.Unarchive
ConvosFilter.UNREAD,
ConvosFilter.ALL -> ConvoOption.Archive
ConvosFilter.BUSINESS_NOTIFY -> null
}?.let(options::add)
options += ConvoOption.Delete
convo.asPresentation( convo.asPresentation(
resources = resources, resources = resources,
useContactName = userSettings.useContactNames.value, useContactName = useContactNames,
isExpanded = screenState.value.expandedConvoId == convo.id, isExpanded = expandedConvoId.value == convo.id,
options = options options = options.toImmutableList()
) )
} }
_uiConvos.setValue { newUiConvos }
screenState.updateValue { copy(convos = newUiConvos.toImmutableList()) }
return newUiConvos return newUiConvos
} }
@@ -31,7 +31,6 @@ private fun Scope.createConvosViewModel(filter: ConvosFilter): ConvosViewModel {
resources = get(), resources = get(),
userSettings = get(), userSettings = get(),
imageLoader = get(), imageLoader = get(),
applicationContext = get(), applicationContext = get()
loadConvosByIdUseCase = get()
) )
} }
@@ -4,9 +4,9 @@ import androidx.compose.runtime.Immutable
@Immutable @Immutable
sealed class ConvoDialog { sealed class ConvoDialog {
data object Pin : ConvoDialog() data class ConvoPin(val convoId: Long) : ConvoDialog()
data object Unpin : ConvoDialog() data class ConvoUnpin(val convoId: Long) : ConvoDialog()
data object Delete : ConvoDialog() data class ConvoDelete(val convoId: Long) : ConvoDialog()
data object Archive : ConvoDialog() data class ConvoArchive(val convoId: Long) : ConvoDialog()
data object Unarchive : ConvoDialog() data class ConvoUnarchive(val convoId: Long) : ConvoDialog()
} }
@@ -1,29 +0,0 @@
package dev.meloda.fast.convos.model
import android.os.Bundle
import dev.meloda.fast.ui.model.vk.ConvoOption
sealed class ConvoIntent {
data class ItemClick(val convoId: Long) : ConvoIntent()
data class ItemLongClick(val convoId: Long) : ConvoIntent()
data class OptionItemClick(val option: ConvoOption) : ConvoIntent()
data object PaginationConditionsMet : ConvoIntent()
data object Back : ConvoIntent()
data object Refresh : ConvoIntent()
data object CreateChatClick : ConvoIntent()
data object ArchiveClick : ConvoIntent()
data class SetScrollIndex(val index: Int) : ConvoIntent()
data class SetScrollOffset(val offset: Int) : ConvoIntent()
data object ErrorActionButtonClick : ConvoIntent()
data object ConsumeScrollToTop : ConvoIntent()
sealed class Dialog : ConvoIntent() {
data object Dismiss : Dialog()
data class Confirm(val bundle: Bundle? = null) : Dialog()
data class Cancel(val bundle: Bundle? = null) : Dialog()
}
}
@@ -0,0 +1,11 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoNavigation {
data class MessagesHistory(val peerId: Long) : ConvoNavigation()
data object CreateChat : ConvoNavigation()
}
@@ -1,9 +0,0 @@
package dev.meloda.fast.convos.model
sealed class ConvoNavigationIntent {
data object Back : ConvoNavigationIntent()
data class MessagesHistory(val convoId: Long) : ConvoNavigationIntent()
data object CreateChat : ConvoNavigationIntent()
data object Archive : ConvoNavigationIntent()
}
@@ -1,10 +1,6 @@
package dev.meloda.fast.convos.model package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
@Immutable @Immutable
data class ConvosScreenState( data class ConvosScreenState(
@@ -14,13 +10,7 @@ data class ConvosScreenState(
val profileImageUrl: String?, val profileImageUrl: String?,
val scrollIndex: Int, val scrollIndex: Int,
val scrollOffset: Int, val scrollOffset: Int,
val canPaginate: Boolean, val isArchive: Boolean
val expandedConvoId: Long?,
val convos: ImmutableList<UiConvo>,
val dialog: ConvoDialog?,
// TODO: 30.05.2026, Danil Nikolaev: remove
val error: BaseError?
) { ) {
companion object { companion object {
@@ -31,11 +21,7 @@ data class ConvosScreenState(
profileImageUrl = null, profileImageUrl = null,
scrollIndex = 0, scrollIndex = 0,
scrollOffset = 0, scrollOffset = 0,
canPaginate = false, isArchive = false
expandedConvoId = null,
convos = emptyImmutableList(),
dialog = null,
error = null
) )
} }
} }
@@ -1,16 +1,12 @@
package dev.meloda.fast.convos.navigation package dev.meloda.fast.convos.navigation
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navigation import androidx.navigation.navigation
import dev.meloda.fast.convos.ConvosViewModel import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.presentation.ConvosRoute import dev.meloda.fast.convos.presentation.ConvosRoute
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.ui.extensions.getOrThrow import dev.meloda.fast.ui.extensions.getOrThrow
import dev.meloda.fast.ui.theme.LocalNavController import dev.meloda.fast.ui.theme.LocalNavController
@@ -28,56 +24,44 @@ object Convos
object Archive object Archive
fun NavGraphBuilder.convosGraph( fun NavGraphBuilder.convosGraph(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
activity: AppCompatActivity, activity: AppCompatActivity,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Long) -> Unit,
onNavigateToCreateChat: () -> Unit,
onScrolledToTop: () -> Unit
) { ) {
navigation<ConvoGraph>( navigation<ConvoGraph>(
startDestination = Convos startDestination = Convos
) { ) {
val convosViewModel: ConvosViewModel = with(activity) {
getViewModel(qualifier = named(ConvosFilter.ALL))
}
composable<Convos> { composable<Convos> {
ConvosRootRoute( val navController = LocalNavController.getOrThrow()
handleNavigationIntent = handleNavigationIntent,
viewModel = with(activity) { ConvosRoute(
getViewModel(named(ConvosFilter.ALL)) viewModel = convosViewModel,
} onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onNavigateToArchive = { navController.navigate(Archive) },
onScrolledToTop = onScrolledToTop
) )
} }
composable<Archive> { composable<Archive> {
ConvosRootRoute( val navController = LocalNavController.getOrThrow()
handleNavigationIntent = handleNavigationIntent,
ConvosRoute(
viewModel = with(activity) { viewModel = with(activity) {
getViewModel<ConvosViewModel>(named(ConvosFilter.ARCHIVE)) getViewModel<ConvosViewModel>(
} qualifier = named(ConvosFilter.ARCHIVE)
)
},
onBack = navController::navigateUp,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onScrolledToTop = onScrolledToTop
) )
} }
} }
} }
@Composable
private fun ConvosRootRoute(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
viewModel: ConvosViewModel
) {
val navController = LocalNavController.getOrThrow()
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
val navigationIntent by viewModel.navigationIntentFlow.collectAsStateWithLifecycle()
LaunchedEffect(navigationIntent) {
navigationIntent?.let {
when (navigationIntent) {
ConvoNavigationIntent.Back -> navController.navigateUp()
ConvoNavigationIntent.Archive -> navController.navigate(Archive)
else -> handleNavigationIntent(it)
}
viewModel.onNavigationConsumed()
}
}
ConvosRoute(
handleIntent = viewModel::handleIntent,
screenState = screenState,
isArchive = viewModel.filter == ConvosFilter.ARCHIVE,
)
}
@@ -1,78 +1,82 @@
package dev.meloda.fast.convos.presentation package dev.meloda.fast.convos.presentation
import android.os.Bundle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.core.os.bundleOf
import dev.meloda.fast.convos.model.ConvoDialog import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
@Composable @Composable
fun HandleDialogs( fun HandleDialogs(
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState, screenState: ConvosScreenState,
dialog: ConvoDialog?,
onConfirmed: (ConvoDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (ConvoDialog) -> Unit = {},
onItemPicked: (ConvoDialog, Bundle) -> Unit = { _, _ -> }
) { ) {
when (screenState.dialog) { when (dialog) {
null -> Unit null -> Unit
is ConvoDialog.Archive -> { is ConvoDialog.ConvoArchive -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) }, onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_archive_convo), title = stringResource(id = R.string.confirm_archive_convo),
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) }, confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_archive), confirmText = stringResource(id = R.string.action_archive),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_archive_fill_round_24) icon = ImageVector.vectorResource(R.drawable.ic_archive_fill_round_24)
) )
} }
is ConvoDialog.Unarchive -> { is ConvoDialog.ConvoUnarchive -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) }, onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_unarchive_convo), title = stringResource(id = R.string.confirm_unarchive_convo),
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) }, confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_unarchive), confirmText = stringResource(id = R.string.action_unarchive),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_unarchive_fill_round_24) icon = ImageVector.vectorResource(R.drawable.ic_unarchive_fill_round_24)
) )
} }
is ConvoDialog.Delete -> { is ConvoDialog.ConvoDelete -> {
val errorColor = MaterialTheme.colorScheme.error val errorColor = MaterialTheme.colorScheme.error
MaterialDialog( MaterialDialog(
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) }, onDismissRequest = { onDismissed(dialog) },
icon = ImageVector.vectorResource(R.drawable.ic_delete_fill_round_24), icon = ImageVector.vectorResource(R.drawable.ic_delete_fill_round_24),
iconTint = errorColor, iconTint = errorColor,
title = stringResource(id = R.string.confirm_delete_convo), title = stringResource(id = R.string.confirm_delete_convo),
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) }, confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_delete), confirmText = stringResource(id = R.string.action_delete),
confirmContainerColor = errorColor, confirmContainerColor = errorColor,
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
) )
} }
is ConvoDialog.Pin -> { is ConvoDialog.ConvoPin -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) }, onDismissRequest = { onDismissed(dialog) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_fill_round_24), icon = ImageVector.vectorResource(R.drawable.ic_keep_fill_round_24),
title = stringResource(id = R.string.confirm_pin_convo), title = stringResource(id = R.string.confirm_pin_convo),
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) }, confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_pin), confirmText = stringResource(id = R.string.action_pin),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
) )
} }
is ConvoDialog.Unpin -> { is ConvoDialog.ConvoUnpin -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) }, onDismissRequest = { onDismissed(dialog) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_off_fill_round_24), icon = ImageVector.vectorResource(R.drawable.ic_keep_off_fill_round_24),
title = stringResource(id = R.string.confirm_unpin_convo), title = stringResource(id = R.string.confirm_unpin_convo),
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) }, confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_unpin), confirmText = stringResource(id = R.string.action_unpin),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
) )
@@ -62,9 +62,9 @@ val BirthdayColor = Color(0xffb00b69)
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ConvoItem( fun ConvoItem(
onItemClick: (convoId: Long) -> Unit, onItemClick: (UiConvo) -> Unit,
onItemLongClick: (convoId: Long) -> Unit, onItemLongClick: (convo: UiConvo) -> Unit,
onOptionClicked: (ConvoOption) -> Unit, onOptionClicked: (UiConvo, ConvoOption) -> Unit,
maxLines: Int, maxLines: Int,
isUserAccount: Boolean, isUserAccount: Boolean,
convo: UiConvo, convo: UiConvo,
@@ -81,9 +81,9 @@ fun ConvoItem(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.combinedClickable( .combinedClickable(
onClick = { onItemClick(convo.id) }, onClick = { onItemClick(convo) },
onLongClick = { onLongClick = {
onItemLongClick(convo.id) onItemLongClick(convo)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
} }
) )
@@ -281,7 +281,7 @@ fun ConvoItem(
val builder = val builder =
AnnotatedString.Builder(convo.message.text) AnnotatedString.Builder(convo.message.text)
convo.message.spanStyles.forEach { spanStyleRange -> convo.message.spanStyles.map { spanStyleRange ->
val updatedSpanStyle = val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) { if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary) spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary)
@@ -378,7 +378,7 @@ fun ConvoItem(
} }
ElevatedAssistChip( ElevatedAssistChip(
onClick = { onOptionClicked(option) }, onClick = { onOptionClicked(convo, option) },
leadingIcon = { leadingIcon = {
option.icon.getResourcePainter()?.let { painter -> option.icon.getResourcePainter()?.let { painter ->
Icon( Icon(
@@ -36,12 +36,12 @@ import kotlinx.coroutines.launch
fun ConvosList( fun ConvosList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
convos: ImmutableList<UiConvo>, convos: ImmutableList<UiConvo>,
onConvosClick: (Long) -> Unit, onConvosClick: (UiConvo) -> Unit,
onConvosLongClick: (Long) -> Unit, onConvosLongClick: (UiConvo) -> Unit,
screenState: ConvosScreenState, screenState: ConvosScreenState,
state: LazyListState, state: LazyListState,
maxLines: Int, maxLines: Int,
onOptionClicked: (ConvoOption) -> Unit, onOptionClicked: (UiConvo, ConvoOption) -> Unit,
padding: PaddingValues padding: PaddingValues
) { ) {
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
@@ -1,23 +1,79 @@
package dev.meloda.fast.convos.presentation package dev.meloda.fast.convos.presentation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import dev.meloda.fast.convos.model.ConvoIntent import androidx.compose.runtime.LaunchedEffect
import dev.meloda.fast.convos.model.ConvosScreenState import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable @Composable
fun ConvosRoute( fun ConvosRoute(
handleIntent: (ConvoIntent) -> Unit, viewModel: ConvosViewModel,
screenState: ConvosScreenState, onBack: (() -> Unit)? = null,
isArchive: Boolean, onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onNavigateToCreateChat: (() -> Unit)? = null,
onNavigateToArchive: (() -> Unit)? = null,
onScrolledToTop: () -> Unit,
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
val convos by viewModel.uiConvos.collectAsStateWithLifecycle()
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val shouldBeConsumed: Boolean = when (val navigation = navigationEvent) {
null -> false
is ConvoNavigation.CreateChat -> {
onNavigateToCreateChat?.invoke()
true
}
is ConvoNavigation.MessagesHistory -> {
onNavigateToMessagesHistory(navigation.peerId)
true
}
}
if (shouldBeConsumed) viewModel.onNavigationConsumed()
}
ConvosScreen( ConvosScreen(
handleIntent = handleIntent, onBack = { onBack?.invoke() },
screenState = screenState, screenState = screenState,
isArchive = isArchive, convos = convos.toImmutableList(),
baseError = baseError,
canPaginate = canPaginate,
onConvoItemClicked = viewModel::onConvoItemClick,
onConvoItemLongClicked = viewModel::onConvoItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh,
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
onArchiveActionClicked = { onNavigateToArchive?.invoke() },
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset,
onConsumeReselection = onScrolledToTop,
onErrorViewButtonClicked = {
if (baseError in listOf(BaseError.AccountBlocked, BaseError.SessionExpired)) {
onError(requireNotNull(baseError))
} else {
viewModel.onErrorButtonClicked()
}
}
) )
HandleDialogs( HandleDialogs(
handleIntent = handleIntent,
screenState = screenState, screenState = screenState,
dialog = dialog,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
) )
} }
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@@ -52,39 +53,53 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.skydoves.compose.stability.runtime.TraceRecomposition
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.navigation.ConvoGraph import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenContainedLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.SegmentedButtonItem import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.components.SegmentedButtonsRow import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalReselectedTab import dev.meloda.fast.ui.theme.LocalReselectedTab
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.buildImmutableList import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.isScrollingUp import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlin.time.Duration.Companion.milliseconds
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class, ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class,
ExperimentalMaterial3ExpressiveApi::class,
) )
@Composable @Composable
fun ConvosScreen( fun ConvosScreen(
handleIntent: (ConvoIntent) -> Unit, screenState: ConvosScreenState = ConvosScreenState.EMPTY,
screenState: ConvosScreenState, convos: ImmutableList<UiConvo> = emptyImmutableList(),
isArchive: Boolean, baseError: BaseError? = null,
canPaginate: Boolean = false,
onBack: () -> Unit = {},
onConvoItemClicked: (convo: UiConvo) -> Unit = {},
onConvoItemLongClicked: (convo: UiConvo) -> Unit = {},
onOptionClicked: (UiConvo, ConvoOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onArchiveActionClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {},
onConsumeReselection: () -> Unit = {},
onErrorViewButtonClicked: () -> Unit = {}
) { ) {
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val maxLines = if (currentTheme.enableMultiline) 2 else 1 val maxLines = if (currentTheme.enableMultiline) 2 else 1
@@ -97,33 +112,33 @@ fun ConvosScreen(
val currentTabReselected = LocalReselectedTab.current[ConvoGraph] == true val currentTabReselected = LocalReselectedTab.current[ConvoGraph] == true
LaunchedEffect(currentTabReselected) { LaunchedEffect(currentTabReselected) {
if (currentTabReselected) { if (currentTabReselected) {
if (isArchive) { if (screenState.isArchive) {
handleIntent(ConvoIntent.Back) onBack.invoke()
} else { } else {
if (listState.firstVisibleItemIndex > 14) { if (listState.firstVisibleItemIndex > 14) {
listState.scrollToItem(14) listState.scrollToItem(14)
} }
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
handleIntent(ConvoIntent.ConsumeScrollToTop) onConsumeReselection()
} }
} }
} }
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex } snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L.milliseconds) .debounce(500L)
.collectLatest { handleIntent(ConvoIntent.SetScrollIndex(it)) } .collectLatest(setScrollIndex)
} }
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset } snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(500L.milliseconds) .debounce(500L)
.collectLatest { handleIntent(ConvoIntent.SetScrollOffset(it)) } .collectLatest(setScrollOffset)
} }
val paginationConditionMet by remember(screenState.canPaginate, listState) { val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf { derivedStateOf {
screenState.canPaginate && canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6) ?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
} }
@@ -131,7 +146,7 @@ fun ConvosScreen(
LaunchedEffect(paginationConditionMet) { LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) { if (paginationConditionMet && !screenState.isPaginating) {
handleIntent(ConvoIntent.PaginationConditionsMet) onPaginationConditionsMet()
} }
} }
@@ -166,7 +181,7 @@ fun ConvosScreen(
text = stringResource( text = stringResource(
id = when { id = when {
screenState.isLoading -> R.string.title_loading screenState.isLoading -> R.string.title_loading
isArchive -> R.string.title_archive screenState.isArchive -> R.string.title_archive
else -> R.string.title_convos else -> R.string.title_convos
} }
), ),
@@ -176,8 +191,8 @@ fun ConvosScreen(
) )
}, },
navigationIcon = { navigationIcon = {
if (isArchive) { if (screenState.isArchive) {
IconButton(onClick = { handleIntent(ConvoIntent.Back) }) { IconButton(onClick = onBack) {
Icon( Icon(
painter = painterResource(R.drawable.ic_arrow_back_round_24), painter = painterResource(R.drawable.ic_arrow_back_round_24),
contentDescription = null contentDescription = null
@@ -186,47 +201,54 @@ fun ConvosScreen(
} }
}, },
actions = { actions = {
val dropDownItems: List<@Composable () -> Unit> = buildList {} if (!screenState.isArchive) {
IconButton(onClick = onArchiveActionClicked) {
val items = buildImmutableList { Icon(
if (!isArchive) { painter = painterResource(R.drawable.ic_archive_round_24),
add(SegmentedButtonItem("archive", R.drawable.ic_archive_round_24)) contentDescription = null
} )
if (AppSettings.General.showManualRefreshOptions) {
add(SegmentedButtonItem("refresh", R.drawable.ic_refresh_round_24))
}
if (dropDownItems.isNotEmpty()) {
add(SegmentedButtonItem("more", R.drawable.ic_more_vert_round_24))
} }
} }
val dropDownItems = mutableListOf<@Composable () -> Unit>()
SegmentedButtonsRow( if (AppSettings.General.showManualRefreshOptions) {
modifier = Modifier.padding(end = 8.dp), dropDownItems += {
items = items, DropdownMenuItem(
onClick = { index -> onClick = {
when (items[index].key) { onRefresh()
"archive" -> handleIntent(ConvoIntent.ArchiveClick) dropDownMenuExpanded = false
"refresh" -> handleIntent(ConvoIntent.Refresh) },
"more" -> dropDownMenuExpanded = true text = {
Text(text = stringResource(id = R.string.action_refresh))
else -> Unit },
} leadingIcon = {
Icon(
painter = painterResource(R.drawable.ic_refresh_round_24),
contentDescription = null
)
}
)
} }
) }
if (dropDownItems.isNotEmpty()) { if (dropDownItems.isNotEmpty()) {
DropdownMenu( IconButton(onClick = { dropDownMenuExpanded = true }) {
modifier = Modifier.defaultMinSize(minWidth = 140.dp), Icon(
expanded = dropDownMenuExpanded, painter = painterResource(R.drawable.ic_more_vert_round_24),
onDismissRequest = { dropDownMenuExpanded = false }, contentDescription = null
offset = DpOffset(x = (-4).dp, y = (-60).dp) )
) {
dropDownItems.forEach { it.invoke() }
} }
} }
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = { dropDownMenuExpanded = false },
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
dropDownItems.forEach { it.invoke() }
}
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy( containerColor = toolbarContainerColor.copy(
@@ -246,7 +268,7 @@ fun ConvosScreen(
) )
val showHorizontalProgressBar by remember(screenState) { val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && screenState.convos.isNotEmpty() } derivedStateOf { screenState.isLoading && convos.isNotEmpty() }
} }
AnimatedVisibility(showHorizontalProgressBar) { AnimatedVisibility(showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
@@ -257,14 +279,14 @@ fun ConvosScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
if (!isArchive) { if (!screenState.isArchive) {
val offsetY by animateIntAsState( val offsetY by animateIntAsState(
targetValue = if (listState.isScrollingUp()) 0 else 600 targetValue = if (listState.isScrollingUp()) 0 else 600
) )
Column { Column {
FloatingActionButton( FloatingActionButton(
onClick = { handleIntent(ConvoIntent.CreateChatClick) }, onClick = onCreateChatButtonClicked,
modifier = Modifier.offset { modifier = Modifier.offset {
IntOffset(0, offsetY) IntOffset(0, offsetY)
} }
@@ -281,15 +303,14 @@ fun ConvosScreen(
} }
) { padding -> ) { padding ->
when { when {
// TODO: 30.05.2026, Danil Nikolaev: move to UI State baseError != null -> {
// baseError != null -> { VkErrorView(
// VkErrorView( baseError = baseError,
// baseError = baseError, onButtonClick = onErrorViewButtonClicked
// onButtonClick = onErrorViewButtonClicked )
// ) }
// }
screenState.isLoading && screenState.convos.isEmpty() -> FullScreenContainedLoader() screenState.isLoading && convos.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
@@ -302,7 +323,7 @@ fun ConvosScreen(
.padding(bottom = padding.calculateBottomPadding()), .padding(bottom = padding.calculateBottomPadding()),
state = pullToRefreshState, state = pullToRefreshState,
isRefreshing = screenState.isLoading, isRefreshing = screenState.isLoading,
onRefresh = { handleIntent(ConvoIntent.Refresh) }, onRefresh = onRefresh,
indicator = { indicator = {
PullToRefreshDefaults.Indicator( PullToRefreshDefaults.Indicator(
state = pullToRefreshState, state = pullToRefreshState,
@@ -314,9 +335,9 @@ fun ConvosScreen(
} }
) { ) {
ConvosList( ConvosList(
convos = screenState.convos, convos = convos,
onConvosClick = { handleIntent(ConvoIntent.ItemClick(it)) }, onConvosClick = onConvoItemClicked,
onConvosLongClick = { handleIntent(ConvoIntent.ItemLongClick(it)) }, onConvosLongClick = onConvoItemLongClicked,
screenState = screenState, screenState = screenState,
state = listState, state = listState,
maxLines = maxLines, maxLines = maxLines,
@@ -325,14 +346,14 @@ fun ConvosScreen(
} else { } else {
Modifier Modifier
}.fillMaxSize(), }.fillMaxSize(),
onOptionClicked = { handleIntent(ConvoIntent.OptionItemClick(it)) }, onOptionClicked = onOptionClicked,
padding = padding padding = padding
) )
if (screenState.convos.isEmpty()) { if (convos.isEmpty()) {
NoItemsView( NoItemsView(
buttonText = stringResource(R.string.action_refresh), buttonText = stringResource(R.string.action_refresh),
onButtonClick = { handleIntent(ConvoIntent.Refresh) } onButtonClick = onRefresh
) )
} }
} }
@@ -7,9 +7,14 @@ import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.paging.canPaginate as canPaginatePage
import dev.meloda.fast.common.paging.isPaginationExhausted as isPaginationExhaustedPage
import dev.meloda.fast.common.paging.loadingFlags
import dev.meloda.fast.common.paging.mergePage
import dev.meloda.fast.convos.model.CreateChatScreenState import dev.meloda.fast.convos.model.CreateChatScreenState
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.FriendsUseCase import dev.meloda.fast.domain.FriendsUseCase
@@ -18,7 +23,6 @@ import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.model.vk.UiFriend import dev.meloda.fast.ui.model.vk.UiFriend
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -154,13 +158,16 @@ class CreateChatViewModel(
friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset) friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset)
.listenValue(viewModelScope) { state -> .listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = ::handleError, error = ::handleError,
success = { response -> success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT val itemsCountSufficient = canPaginatePage(LOAD_COUNT, response.size)
_canPaginate.setValue { itemsCountSufficient } _canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient && val paginationExhausted = isPaginationExhaustedPage(
screenState.value.friends.isNotEmpty() pageSize = LOAD_COUNT,
loadedCount = response.size,
hasExistingItems = screenState.value.friends.isNotEmpty()
)
val imagesToPreload = val imagesToPreload =
response.mapNotNull { it.photo100.takeIf { p -> !p.isNullOrEmpty() } } response.mapNotNull { it.photo100.takeIf { p -> !p.isNullOrEmpty() } }
@@ -182,24 +189,19 @@ class CreateChatViewModel(
val newState = screenState.value.copy( val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted isPaginationExhausted = paginationExhausted
) )
if (offset == 0) { _screenState.setValue {
_screenState.setValue { newState.copy(
newState.copy(friends = loadedFriends) friends = mergePage(newState.friends, loadedFriends, offset)
} )
} else {
_screenState.setValue {
newState.copy(
friends = newState.friends.plus(loadedFriends)
)
}
} }
} }
) )
val flags = loadingFlags(offset, state.isLoading())
_screenState.setValue { old -> _screenState.setValue { old ->
old.copy( old.copy(
isLoading = offset == 0 && state.isLoading(), isLoading = flags.isLoading,
isPaginating = offset > 0 && state.isLoading() isPaginating = flags.isPaginating
) )
} }
} }
@@ -228,40 +230,8 @@ class CreateChatViewModel(
} }
private fun handleError(error: State.Error) { private fun handleError(error: State.Error) {
when (error) { VkUtils.parseError(error)?.let { newBaseError ->
is State.Error.ApiError -> { _baseError.setValue { newBaseError }
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
_baseError.setValue { BaseError.SessionExpired }
}
else -> {
_baseError.setValue {
BaseError.SimpleError(message = error.errorMessage)
}
}
}
}
State.Error.ConnectionError -> {
_baseError.setValue {
BaseError.SimpleError(message = "Connection error")
}
}
State.Error.InternalError -> {
_baseError.setValue {
BaseError.SimpleError(message = "Internal error")
}
}
State.Error.UnknownError -> {
_baseError.setValue {
BaseError.SimpleError(message = "Unknown error")
}
}
else -> Unit
} }
} }
@@ -4,7 +4,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.paging.canPaginate as canPaginatePage
import dev.meloda.fast.common.paging.isPaginationExhausted as isPaginationExhaustedPage
import dev.meloda.fast.common.paging.loadingFlags
import dev.meloda.fast.common.paging.mergePage
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.FriendsUseCase import dev.meloda.fast.domain.FriendsUseCase
@@ -13,7 +18,6 @@ import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.friends.model.FriendsScreenState import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.network.VkErrorCode
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -80,40 +84,8 @@ abstract class BaseFriendsViewModelImpl : ViewModel(), FriendsViewModel {
abstract fun loadFriends(offset: Int = currentOffset.value) abstract fun loadFriends(offset: Int = currentOffset.value)
protected fun handleError(error: State.Error) { protected fun handleError(error: State.Error) {
when (error) { VkUtils.parseError(error)?.let { newBaseError ->
is State.Error.ApiError -> { baseError.setValue { newBaseError }
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
baseError.setValue {
BaseError.SimpleError(message = error.errorMessage)
}
}
}
}
State.Error.ConnectionError -> {
baseError.setValue {
BaseError.SimpleError(message = "Connection error")
}
}
State.Error.InternalError -> {
baseError.setValue {
BaseError.SimpleError(message = "Internal error")
}
}
State.Error.UnknownError -> {
baseError.setValue {
BaseError.SimpleError(message = "Unknown error")
}
}
else -> Unit
} }
} }
@@ -154,11 +126,14 @@ class FriendsViewModelImpl(
state.processState( state.processState(
error = ::handleError, error = ::handleError,
success = { response -> success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT val itemsCountSufficient = canPaginatePage(LOAD_COUNT, response.size)
canPaginate.setValue { itemsCountSufficient } canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient val paginationExhausted = isPaginationExhaustedPage(
&& screenState.value.friends.isNotEmpty() pageSize = LOAD_COUNT,
loadedCount = response.size,
hasExistingItems = screenState.value.friends.isNotEmpty()
)
imagesToPreload.setValue { imagesToPreload.setValue {
response.mapNotNull(VkUser::photo100) response.mapNotNull(VkUser::photo100)
@@ -174,24 +149,20 @@ class FriendsViewModelImpl(
isPaginationExhausted = paginationExhausted isPaginationExhausted = paginationExhausted
) )
if (offset == 0) { friends.emit(mergePage(friends.value, response, offset))
friends.emit(response) screenState.setValue {
screenState.setValue { newState.copy(
newState.copy(friends = loadedFriends) friends = mergePage(newState.friends, loadedFriends, offset)
} )
} else {
friends.emit(friends.value.plus(response))
screenState.setValue {
newState.copy(friends = newState.friends.plus(loadedFriends))
}
} }
} }
) )
val flags = loadingFlags(offset, state.isLoading())
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
isLoading = offset == 0 && state.isLoading(), isLoading = flags.isLoading,
isPaginating = offset > 0 && state.isLoading() isPaginating = flags.isPaginating
) )
} }
} }
@@ -229,10 +200,14 @@ class OnlineFriendsViewModelImpl(
} }
) )
val flags = loadingFlags(
offset = offset,
isLoading = onlineState.isLoading() || state.isLoading()
)
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
isLoading = offset == 0 && (onlineState.isLoading() || state.isLoading()), isLoading = flags.isLoading,
isPaginating = offset > 0 && (onlineState.isLoading() || state.isLoading()) isPaginating = flags.isPaginating
) )
} }
} }
@@ -38,7 +38,6 @@ import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -48,6 +47,7 @@ fun FriendsScreen(
orderType: String, orderType: String,
padding: PaddingValues, padding: PaddingValues,
tabIndex: Int, tabIndex: Int,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userid: Long) -> Unit = {}, onMessageClicked: (userid: Long) -> Unit = {},
setCanScrollBackward: (Boolean) -> Unit = {}, setCanScrollBackward: (Boolean) -> Unit = {},
@@ -100,13 +100,13 @@ fun FriendsScreen(
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex } snapshotFlow { listState.firstVisibleItemIndex }
.debounce(250L.milliseconds) .debounce(250L)
.collectLatest(viewModel::setScrollIndex) .collectLatest(viewModel::setScrollIndex)
} }
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset } snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(250L.milliseconds) .debounce(250L)
.collectLatest(viewModel::setScrollOffset) .collectLatest(viewModel::setScrollOffset)
} }
@@ -9,11 +9,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -33,6 +34,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -45,14 +47,11 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.ActionInvokeDismiss import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.components.SelectionType import dev.meloda.fast.ui.components.SelectionType
import dev.meloda.fast.ui.model.TabItem import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -190,24 +189,16 @@ fun FriendsRoute(
), ),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
actions = { actions = {
val items = buildImmutableList { IconButton(
add( onClick = {
SegmentedButtonItem( showOrderDialog = true
"filter", }
R.drawable.ic_filter_list_round_24 ) {
) Icon(
painter = painterResource(R.drawable.ic_filter_list_round_24),
contentDescription = null
) )
} }
SegmentedButtonsRow(
modifier = Modifier.padding(end = 8.dp),
items = items,
onClick = { index ->
when (items[index].key) {
"filter" -> showOrderDialog = true
}
}
)
} }
) )
PrimaryTabRow( PrimaryTabRow(
@@ -243,6 +234,7 @@ fun FriendsRoute(
orderType = orderType, orderType = orderType,
padding = padding, padding = padding,
tabIndex = index, tabIndex = index,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked, onMessageClicked = onMessageClicked,
setCanScrollBackward = { canScrollBackward = it }, setCanScrollBackward = { canScrollBackward = it },
@@ -0,0 +1,267 @@
package dev.meloda.fast.messageshistory
import android.os.Bundle
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.extensions.getParcelableCompat
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.messageshistory.model.MessageDialog
import dev.meloda.fast.messageshistory.model.MessageOption
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.vk.MessageUiItem
import kotlinx.coroutines.flow.MutableStateFlow
internal class MessagesHistoryInteractionHandler(
private val screenState: MutableStateFlow<MessagesHistoryScreenState>,
private val messages: MutableStateFlow<List<VkMessage>>,
private val dialog: MutableStateFlow<MessageDialog?>,
private val selectedMessages: MutableStateFlow<List<VkMessage>>,
private val uiMessages: MutableStateFlow<List<MessageUiItem>>,
private val isNeedToScrollToIndex: MutableStateFlow<Int?>,
private val messageActions: MessagesHistoryMessageActions,
private val messageTransportActions: MessagesHistoryMessageTransportActions,
private val syncUiMessages: () -> Unit
) {
fun onDialogConfirmed(dialog: MessageDialog, bundle: Bundle) {
onDialogDismissed(dialog)
when (dialog) {
is MessageDialog.MessageOptions -> Unit
is MessageDialog.MessageDelete -> {
val deleteForEveryone = bundle.getBoolean("everyone")
if (dialog.message.id <= 0) {
val newMessages = messages.value.toMutableList()
newMessages.remove(dialog.message)
messages.setValue { newMessages }
syncUiMessages()
return
}
messageTransportActions.deleteMessage(
messageIds = listOf(dialog.message.id),
deleteForAll = deleteForEveryone
)
}
is MessageDialog.MessagesDelete -> {
val deleteForEveryone = bundle.getBoolean("everyone")
val failedMessages = dialog.messages.filter { it.id <= 0 }
val messageIdsToDelete =
dialog.messages
.filter { it.id > 0 }
.map(VkMessage::id)
messageTransportActions.deleteMessage(
messageIds = messageIdsToDelete,
deleteForAll = deleteForEveryone,
onSuccess = {
val newMessages = messages.value.toMutableList()
newMessages.removeAll(failedMessages)
messages.setValue { newMessages }
selectedMessages.setValue { emptyList() }
syncUiMessages()
}
)
}
is MessageDialog.MessagePin -> {
messageTransportActions.pinMessage(dialog.messageId)
}
is MessageDialog.MessageUnpin -> {
messageTransportActions.unpinMessage(dialog.messageId)
}
is MessageDialog.MessageMarkImportance -> {
messageTransportActions.markAsImportant(
messageIds = listOf(dialog.message.id),
important = dialog.isImportant
)
}
is MessageDialog.MessageSpam -> {
if (dialog.isSpam) {
messageTransportActions.deleteMessage(
messageIds = listOf(dialog.message.id),
spam = true
)
} else {
// TODO: 29-Mar-25, Danil Nikolaev: report as not spam
}
}
}
}
fun onDialogDismissed(dialog: MessageDialog) {
this.dialog.setValue { null }
}
fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) {
when (dialog) {
is MessageDialog.MessageOptions -> {
val cmId = bundle.getLong("cmId")
when (val option = bundle.getParcelableCompat("option", MessageOption::class)) {
null -> Unit
MessageOption.Retry -> {
// TODO: 28-Mar-25, Danil Nikolaev: retry sending
}
MessageOption.Reply -> messageActions.replyToMessage(cmId)
MessageOption.ForwardHere -> {
}
MessageOption.Forward -> {
}
MessageOption.Pin -> {
this.dialog.setValue {
MessageDialog.MessagePin(dialog.message.id)
}
}
MessageOption.Unpin -> {
this.dialog.setValue {
MessageDialog.MessageUnpin(dialog.message.id)
}
}
MessageOption.Read -> {
messageTransportActions.readMessage(dialog.message)
}
MessageOption.Copy -> {
messageTransportActions.copyMessage(dialog.message)
}
MessageOption.MarkAsImportant,
MessageOption.UnmarkAsImportant -> {
this.dialog.setValue {
MessageDialog.MessageMarkImportance(
message = dialog.message,
isImportant = option is MessageOption.MarkAsImportant
)
}
}
MessageOption.MarkAsSpam,
MessageOption.UnmarkAsSpam -> {
this.dialog.setValue {
MessageDialog.MessageSpam(
message = dialog.message,
isSpam = option is MessageOption.MarkAsSpam
)
}
}
MessageOption.Edit -> {
messageActions.editMessage(cmId)
syncUiMessages()
}
MessageOption.Delete -> {
this.dialog.setValue {
MessageDialog.MessageDelete(dialog.message)
}
}
}
}
is MessageDialog.MessageDelete -> Unit
is MessageDialog.MessageUnpin -> Unit
is MessageDialog.MessageMarkImportance -> Unit
is MessageDialog.MessageSpam -> Unit
is MessageDialog.MessagePin -> Unit
is MessageDialog.MessagesDelete -> Unit
}
}
fun onCloseButtonClicked() {
if (selectedMessages.value.isNotEmpty()) {
selectedMessages.setValue { emptyList() }
}
if (screenState.value.editCmId != null) {
messageActions.stopEditMessage()
}
syncUiMessages()
}
fun onMessageClicked(messageId: Long) {
val currentMessage = messages.value.firstOrNull { it.id == messageId } ?: return
if (selectedMessages.value.isNotEmpty()) {
val isSelected = selectedMessages.value.contains(currentMessage)
selectedMessages.setValue { old ->
old.toMutableList().also {
if (isSelected) {
it.remove(currentMessage)
} else {
it.add(currentMessage)
}
}
}
syncUiMessages()
} else {
dialog.setValue {
MessageDialog.MessageOptions(currentMessage)
}
}
}
fun onMessageLongClicked(messageId: Long) {
val currentMessage = messages.value.firstOrNull { it.id == messageId } ?: return
val isSelected = selectedMessages.value.contains(currentMessage)
if (isSelected) return
selectedMessages.setValue { old ->
old.toMutableList().also {
it.add(currentMessage)
}
}
syncUiMessages()
}
fun onEditSelectedMessageClicked() {
val cmId = selectedMessages.value.firstOrNull()?.cmId ?: return
selectedMessages.setValue { emptyList() }
messageActions.editMessage(cmId)
syncUiMessages()
}
fun onDeleteSelectedMessagesClicked() {
dialog.setValue {
MessageDialog.MessagesDelete(selectedMessages.value)
}
}
fun onPinnedMessageClicked(messageId: Long) {
val messageIndex = uiMessages.value.indexOfFirstOrNull {
it is MessageUiItem.Message && it.id == messageId
}
if (messageIndex != null) {
isNeedToScrollToIndex.setValue { messageIndex }
}
}
fun onUnpinMessageClicked() {
val pinnedMessageId = screenState.value.pinnedMessage?.id ?: return
dialog.setValue {
MessageDialog.MessageUnpin(pinnedMessageId)
}
}
}
@@ -0,0 +1,179 @@
package dev.meloda.fast.messageshistory
import android.util.Log
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.launchDbRefresh
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.paging.canPaginate as canPaginatePage
import dev.meloda.fast.common.paging.isPaginationExhausted as isPaginationExhaustedPage
import dev.meloda.fast.common.paging.loadingFlags
import dev.meloda.fast.common.paging.mergePage
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.data.State
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.domain.util.extractTitle
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
internal class MessagesHistoryLoaders(
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val resourceProvider: ResourceProvider,
private val screenState: MutableStateFlow<MessagesHistoryScreenState>,
private val messages: MutableStateFlow<List<VkMessage>>,
private val imagesToPreload: MutableStateFlow<List<String>>,
private val canPaginate: MutableStateFlow<Boolean>,
private val baseError: MutableStateFlow<BaseError?>,
private val scope: CoroutineScope,
private val syncUiMessages: () -> Unit,
private val onPinnedMessage: (VkMessage?) -> Unit,
) {
fun loadConvo() {
Log.d("MessagesHistoryViewModelImpl", "loadConvo()")
scope.launchDbRefresh(
load = {
convoUseCase.getLocalConvoById(screenState.value.convoId)?.let { convo ->
val title = convo.extractTitle(
useContactName = AppSettings.General.useContactNames,
resources = resourceProvider.resources
)
val avatar = convo.extractAvatar()
screenState.setValue { old ->
old.copy(
convo = convo,
title = title,
avatar = avatar
)
}
onPinnedMessage(convo.pinnedMessage)
}
},
after = {}
)
convoUseCase.getById(
peerIds = listOf(screenState.value.convoId),
extended = true,
fields = VkConstants.ALL_FIELDS
).listenValue(scope) { state ->
state.processState(
error = ::handleError,
success = { response ->
val convo = response.firstOrNull() ?: return@listenValue
val title = convo.extractTitle(
useContactName = AppSettings.General.useContactNames,
resources = resourceProvider.resources
)
val avatar = convo.extractAvatar()
screenState.setValue { old ->
old.copy(
convo = convo,
title = title,
avatar = avatar
)
}
onPinnedMessage(convo.pinnedMessage)
}
)
}
}
fun loadMessagesHistory(offset: Int) {
Log.d("MessagesHistoryViewModel", "loadMessagesHistory: $offset")
if (offset == 0) {
scope.launchDbRefresh(
load = {
val cachedMessages = messagesUseCase.getLocalMessages(screenState.value.convoId)
if (cachedMessages.isNotEmpty()) {
messages.emit(cachedMessages.sorted())
}
},
after = {
if (messages.value.isNotEmpty()) {
syncUiMessages()
}
}
)
}
messagesUseCase.getMessagesHistory(
convoId = screenState.value.convoId,
count = MessagesHistoryViewModelImpl.MESSAGES_LOAD_COUNT,
offset = offset,
).listenValue(scope) { state ->
state.processState(
error = ::handleError,
success = { response ->
val messages = response.messages
val fullMessages = mergePage(this.messages.value, messages, offset)
.sorted()
imagesToPreload.setValue {
messages.mapNotNull { it.extractAvatar().extractUrl() }
}
messagesUseCase.storeMessages(messages)
convoUseCase.storeConvos(response.convos)
val itemsCountSufficient = canPaginatePage(
MessagesHistoryViewModelImpl.MESSAGES_LOAD_COUNT,
messages.size
)
val paginationExhausted = isPaginationExhaustedPage(
pageSize = MessagesHistoryViewModelImpl.MESSAGES_LOAD_COUNT,
loadedCount = messages.size,
hasExistingItems = this.messages.value.isNotEmpty()
)
screenState.setValue { old ->
old.copy(isPaginationExhausted = paginationExhausted)
}
this.messages.emit(fullMessages)
syncUiMessages()
canPaginate.setValue { itemsCountSufficient }
}
)
val flags = loadingFlags(offset, state.isLoading())
screenState.setValue { old ->
old.copy(
isLoading = flags.isLoading,
isPaginating = flags.isPaginating
)
}
}
}
private fun handleError(error: State.Error) {
VkUtils.parseError(error)?.let { newBaseError ->
baseError.setValue { newBaseError }
}
}
private fun List<VkMessage>.sorted(): List<VkMessage> {
return sortedWith { m1, m2 ->
val dateDiff = m2.date - m1.date
if (dateDiff != 0) {
dateDiff
} else {
val idDiff = m2.id - m1.id
idDiff.toInt()
}
}
}
}
@@ -0,0 +1,99 @@
package dev.meloda.fast.messageshistory
import android.util.Log
import dev.meloda.fast.common.extensions.launchDbRefresh
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
internal class MessagesHistoryLongPollEventHandler(
private val scope: CoroutineScope,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val screenState: MutableStateFlow<MessagesHistoryScreenState>,
private val messages: MutableStateFlow<List<VkMessage>>,
private val syncUiMessages: () -> Unit,
private val onPinnedMessageChanged: (VkMessage?) -> Unit
) {
fun onNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
Log.d("MessagesHistoryViewModel", "handleNewMessage: $message")
if (message.peerId != screenState.value.convoId) return
refreshFromDb(refreshMessages = true)
}
fun onMessageEdited(event: LongPollParsedEvent.MessageEdited) {
val message = event.message
if (message.peerId != screenState.value.convoId) return
refreshFromDb(refreshMessages = true)
}
fun onReadIncoming(event: LongPollParsedEvent.IncomingMessageRead) {
if (event.peerId != screenState.value.convoId) return
refreshFromDb(refreshMessages = false)
}
fun onReadOutgoing(event: LongPollParsedEvent.OutgoingMessageRead) {
if (event.peerId != screenState.value.convoId) return
refreshFromDb(refreshMessages = false)
}
fun onMessageDeleted(event: LongPollParsedEvent.MessageDeleted) {
if (event.peerId != screenState.value.convoId) return
refreshFromDb(refreshMessages = true)
}
fun onMessageRestored(event: LongPollParsedEvent.MessageRestored) {
if (event.message.peerId != screenState.value.convoId) return
refreshFromDb(refreshMessages = true)
}
fun onMessageMarkedAsImportant(event: LongPollParsedEvent.MessageMarkedAsImportant) {
if (event.peerId != screenState.value.convoId) return
refreshFromDb(refreshMessages = true)
}
fun onMessageMarkedAsSpam(event: LongPollParsedEvent.MessageMarkedAsSpam) {
if (event.peerId != screenState.value.convoId) return
refreshFromDb(refreshMessages = true)
}
fun onMessageMarkedAsNotSpam(event: LongPollParsedEvent.MessageMarkedAsNotSpam) {
if (event.message.peerId != screenState.value.convoId) return
refreshFromDb(refreshMessages = true)
}
private fun refreshFromDb(refreshMessages: Boolean) {
scope.launchDbRefresh(
load = {
convoUseCase.getLocalConvoById(screenState.value.convoId)?.let { convo ->
screenState.setValue { old ->
old.copy(convo = convo)
}
onPinnedMessageChanged(convo.pinnedMessage)
}
if (refreshMessages) {
val localMessages = messagesUseCase.getLocalMessages(screenState.value.convoId)
messages.setValue { localMessages }
}
},
after = ::syncUiMessages
)
}
}
@@ -0,0 +1,23 @@
package dev.meloda.fast.messageshistory
import androidx.compose.ui.text.input.TextFieldValue
internal class MessagesHistoryMessageActions(
private val sendEditActions: MessagesHistoryMessageSendEditActions
) {
fun replyToMessage(cmId: Long) = sendEditActions.replyToMessage(cmId)
fun onMessageInputChanged(newText: TextFieldValue) = sendEditActions.onMessageInputChanged(newText)
fun onEmojiButtonLongClicked() = sendEditActions.onEmojiButtonLongClicked()
fun editMessage(cmId: Long) = sendEditActions.editMessage(cmId)
fun stopEditMessage() = sendEditActions.stopEditMessage()
fun onBoldClicked() = sendEditActions.onBoldClicked()
fun onItalicClicked() = sendEditActions.onItalicClicked()
fun onUnderlineClicked() = sendEditActions.onUnderlineClicked()
fun onRegularClicked() = sendEditActions.onRegularClicked()
fun onActionButtonClicked() = sendEditActions.onActionButtonClicked()
fun onReplyCloseClicked() = sendEditActions.onReplyCloseClicked()
fun onKeyboardShown() = sendEditActions.onKeyboardShown()
fun sendMessage() = sendEditActions.sendMessage()
fun confirmDeleteCurrentEditMessage() = sendEditActions.confirmDeleteCurrentEditMessage()
fun editCurrentEditMessage() = sendEditActions.editCurrentEditMessage()
}
@@ -0,0 +1,400 @@
package dev.meloda.fast.messageshistory
import android.util.Log
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.launchDbRefresh
import dev.meloda.fast.common.extensions.removeIfCompat
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.extractReplySummary
import dev.meloda.fast.domain.util.extractReplyTitle
import dev.meloda.fast.domain.util.extractTitle
import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.messageshistory.model.MessageDialog
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.model.api.domain.FormatDataType
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlin.random.Random
internal class MessagesHistoryMessageSendEditActions(
private val viewModelScope: CoroutineScope,
private val messagesUseCase: MessagesUseCase,
private val resourceProvider: ResourceProvider,
private val screenState: MutableStateFlow<MessagesHistoryScreenState>,
private val messages: MutableStateFlow<List<VkMessage>>,
private val showKeyboard: MutableStateFlow<Boolean>,
private val dialog: MutableStateFlow<MessageDialog?>,
private val syncUiMessages: () -> Unit
) {
private var lastMessageText: String? = null
private val sendingMessages: MutableList<VkMessage> = mutableListOf()
private val failedMessages: MutableList<VkMessage> = mutableListOf()
private var replyToCmId: Long? = null
private var editMessage: VkMessage? = null
private var formatData = VkMessage.FormatData("1", emptyList())
fun replyToMessage(cmId: Long) {
val messageToReply = messages.value.find { it.cmId == cmId } ?: return
showKeyboard.setValue { true }
replyToCmId = cmId
screenState.setValue { old ->
old.copy(
replyTitle = messageToReply.extractTitle(),
replyText = messageToReply.extractReplySummary(resourceProvider.resources)
)
}
}
fun onMessageInputChanged(newText: TextFieldValue) {
screenState.setValue { old ->
old.copy(
message = newText,
actionMode =
when {
screenState.value.editCmId != null -> {
// TODO: 13/03/2026, Danil Nikolaev: also check if attachments is empty
if (newText.text.trim().isEmpty()) {
ActionMode.DELETE
} else {
ActionMode.EDIT
}
}
newText.text.trim().isEmpty() -> ActionMode.RECORD_AUDIO
else -> ActionMode.SEND
}
)
}
updateStyles()
}
fun onEmojiButtonLongClicked() {
AppSettings.Features.fastText.takeIf { it.isNotBlank() }?.let { text ->
val newText = "${screenState.value.message.text}$text"
onMessageInputChanged(
TextFieldValue(text = newText, selection = TextRange(newText.length))
)
}
}
fun editMessage(cmId: Long) {
screenState.setValue { old -> old.copy(editCmId = cmId) }
val messageToEdit = messages.value.firstOrNull { it.cmId == cmId } ?: return
editMessage = messageToEdit
lastMessageText = screenState.value.message.text
var newState = screenState.value.copy(
message = TextFieldValue(
text = messageToEdit.text.orEmpty(),
selection = TextRange(messageToEdit.text.orEmpty().length)
),
actionMode = ActionMode.EDIT
)
messageToEdit.replyMessage?.let { reply ->
replyToCmId = reply.cmId
newState = newState.copy(
replyTitle = reply.extractReplyTitle(),
replyText = reply.extractReplySummary(resourceProvider.resources)
)
}
showKeyboard.setValue { true }
screenState.setValue { newState }
}
fun stopEditMessage() {
val lastText = lastMessageText.orEmpty().trim()
screenState.setValue { old ->
old.copy(
editCmId = null,
message = TextFieldValue(
text = lastText,
selection = TextRange(lastText.length)
),
actionMode = if (lastText.isBlank()) ActionMode.RECORD_AUDIO else ActionMode.SEND,
replyTitle = null,
replyText = null
)
}
}
fun onBoldClicked() = updateFormatting(FormatDataType.BOLD)
fun onItalicClicked() = updateFormatting(FormatDataType.ITALIC)
fun onUnderlineClicked() = updateFormatting(FormatDataType.UNDERLINE)
fun onRegularClicked() {
formatData = formatData.copy(items = emptyList())
updateStyles()
}
fun onActionButtonClicked() {
when (screenState.value.actionMode) {
ActionMode.DELETE -> confirmDeleteCurrentEditMessage()
ActionMode.EDIT -> editCurrentEditMessage()
ActionMode.RECORD_AUDIO -> {
screenState.setValue { it.copy(actionMode = ActionMode.RECORD_VIDEO) }
}
ActionMode.RECORD_VIDEO -> {
screenState.setValue { it.copy(actionMode = ActionMode.RECORD_AUDIO) }
}
ActionMode.SEND -> sendMessage()
}
}
fun onReplyCloseClicked() {
replyToCmId = null
screenState.setValue { old ->
old.copy(
replyTitle = null,
replyText = null
)
}
}
fun onKeyboardShown() {
showKeyboard.setValue { false }
}
fun sendMessage() {
lastMessageText = screenState.value.message.text
val newMessage = VkMessage(
id = -1L - sendingMessages.size,
cmId = -1L - sendingMessages.size,
text = lastMessageText,
isOut = true,
peerId = screenState.value.convoId,
fromId = UserConfig.userId,
date = (System.currentTimeMillis() / 1000).toInt(),
randomId = Random.nextInt().toLong(),
action = null,
actionMemberId = null,
actionText = null,
actionCmId = null,
actionMessage = null,
updateTime = null,
isImportant = false,
forwards = null,
attachments = null,
replyMessage = when {
replyToCmId != null -> messages.value.find { it.cmId == replyToCmId }
else -> null
},
geoType = null,
user = VkMemoryCache.getUser(UserConfig.userId),
group = null,
actionUser = null,
actionGroup = null,
isPinned = false,
isSpam = false,
pinnedAt = null,
formatData = formatData,
)
formatData = formatData.copy(items = emptyList())
sendingMessages += newMessage
messages.setValue { old -> listOf(newMessage).plus(old) }
syncUiMessages()
screenState.setValue { old ->
old.copy(
message = TextFieldValue(),
actionMode = ActionMode.RECORD_AUDIO,
replyTitle = null,
replyText = null
)
}
val replyCmId = replyToCmId
replyToCmId = null
val forward = when {
replyCmId != null -> {
buildJsonObject {
put("peer_id", screenState.value.convoId)
put("conversation_message_ids", buildJsonArray { add(replyCmId) })
put("is_reply", true)
}.toString()
}
else -> null
}
messagesUseCase.sendMessage(
peerId = screenState.value.convoId,
randomId = newMessage.randomId,
message = newMessage.text,
forward = forward,
attachments = null,
formatData = newMessage.formatData,
).listenValue(viewModelScope) { state ->
state.processState(
any = { sendingMessages.remove(newMessage) },
error = { error ->
Log.d("MessagesHistoryViewModelImpl", "sendMessage: ERROR: $error")
val failedId = -500_000L - failedMessages.size
val newFailedMessage = newMessage.copy(id = failedId)
failedMessages += newFailedMessage
val newMessages = messages.value.toMutableList()
newMessages[newMessages.indexOf(newMessage)] = newFailedMessage
messages.setValue { newMessages }
syncUiMessages()
},
success = { response ->
viewModelScope.launch {
messagesUseCase.storeMessage(
newMessage.copy(
id = response.messageId,
cmId = response.cmId
)
)
refreshMessagesFromDb()
}
}
)
}
}
fun confirmDeleteCurrentEditMessage() {
val currentMessage = editMessage ?: return
dialog.setValue { MessageDialog.MessageDelete(currentMessage) }
}
fun editCurrentEditMessage() {
val newText = screenState.value.message.text
val lastText = lastMessageText.orEmpty().trim()
val currentReplyToCmId = replyToCmId
screenState.setValue { old ->
old.copy(
editCmId = null,
message = TextFieldValue(
text = lastText,
selection = TextRange(lastText.length)
),
actionMode = if (lastText.isBlank()) ActionMode.RECORD_AUDIO else ActionMode.SEND,
replyTitle = null,
replyText = null
)
}
syncUiMessages()
val newMessage = editMessage?.copy(
replyMessage = if (currentReplyToCmId == null) null else editMessage?.replyMessage,
text = newText
) ?: return
Log.d("MessagesHistoryViewModelImpl", "editMessage: $newMessage")
messagesUseCase.edit(
peerId = screenState.value.convoId,
cmId = newMessage.cmId,
message = newMessage.text,
attachments = null,
formatData = newMessage.formatData
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
Log.d("MessagesHistoryViewModelImpl", "editMessage: ERROR: $error")
},
success = {
viewModelScope.launch {
messagesUseCase.storeMessage(newMessage)
refreshMessagesFromDb()
}
}
)
}
replyToCmId = null
}
private fun updateFormatting(type: FormatDataType) {
val selectionRange = screenState.value.message.selection
val newItems = formatData.items.toMutableList()
val wasRemoved = newItems.removeIfCompat {
it.type == type &&
it.offset == selectionRange.start &&
it.offset + it.length == selectionRange.end
}
if (!wasRemoved) {
newItems += VkMessage.FormatData.Item(
offset = selectionRange.start,
length = selectionRange.end - selectionRange.start,
type = type,
url = null
)
}
formatData = formatData.copy(items = newItems)
updateStyles()
}
private fun updateStyles() {
val annotations =
mutableListOf<AnnotatedString.Range<out AnnotatedString.Annotation>>()
formatData.items.forEach { item ->
val spanStyle = when (item.type) {
FormatDataType.BOLD -> SpanStyle(fontWeight = FontWeight.SemiBold)
FormatDataType.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
FormatDataType.UNDERLINE -> SpanStyle(textDecoration = TextDecoration.Underline)
FormatDataType.URL -> null
}
spanStyle?.let {
annotations += AnnotatedString.Range(
item = spanStyle,
start = item.offset,
end = item.offset + item.length
)
}
}
val newText = AnnotatedString(
text = screenState.value.message.text,
annotations = annotations
)
screenState.setValue { old ->
old.copy(message = old.message.copy(annotatedString = newText))
}
}
private fun refreshMessagesFromDb() {
viewModelScope.launchDbRefresh(
load = {
val localMessages = messagesUseCase.getLocalMessages(screenState.value.convoId)
messages.setValue { localMessages }
},
after = ::syncUiMessages
)
}
}
@@ -0,0 +1,232 @@
package dev.meloda.fast.messageshistory
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.toBitmapOrNull
import coil.imageLoader
import coil.request.ImageRequest
import dev.meloda.fast.common.extensions.launchDbRefresh
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.data.State
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.ui.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
internal class MessagesHistoryMessageTransportActions(
private val applicationContext: Context,
private val viewModelScope: CoroutineScope,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val screenState: MutableStateFlow<MessagesHistoryScreenState>,
private val messages: MutableStateFlow<List<VkMessage>>,
private val baseError: MutableStateFlow<BaseError?>,
private val syncUiMessages: () -> Unit,
private val onPinnedMessageChanged: (VkMessage?) -> Unit
) {
fun markAsImportant(messageIds: List<Long>, important: Boolean) {
messagesUseCase.markAsImportant(
peerId = screenState.value.convoId,
messageIds = messageIds,
important = important
).listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = {
viewModelScope.launch {
messageIds.forEach { messageId ->
messagesUseCase.getLocalMessageById(messageId)?.let { localMessage ->
messagesUseCase.storeMessage(localMessage.copy(isImportant = important))
}
}
refreshMessagesFromDb()
}
}
)
}
}
fun deleteMessage(
messageIds: List<Long>,
spam: Boolean = false,
deleteForAll: Boolean = false,
onSuccess: () -> Unit = {}
) {
messagesUseCase.delete(
peerId = screenState.value.convoId,
messageIds = messageIds,
spam = spam,
deleteForAll = deleteForAll
).listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = {
viewModelScope.launch {
onSuccess()
val localMessageIds = mutableListOf<Long>()
messageIds.forEach { messageId ->
messagesUseCase.getLocalMessageById(messageId)?.let { localMessage ->
localMessageIds += localMessage.id
}
}
messagesUseCase.deleteLocalMessages(localMessageIds)
refreshMessagesFromDb()
}
}
)
}
}
fun pinMessage(messageId: Long) {
messagesUseCase.pin(
peerId = screenState.value.convoId,
messageId = messageId,
cmId = null
).listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { pinnedMessage ->
viewModelScope.launch {
onPinnedMessageChanged(pinnedMessage)
messagesUseCase.storeMessage(pinnedMessage)
refreshMessagesFromDb()
}
}
)
}
}
fun unpinMessage(messageId: Long) {
messagesUseCase.unpin(screenState.value.convoId)
.listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = {
viewModelScope.launch {
messagesUseCase.getLocalMessageById(messageId)?.let { localMessage ->
messagesUseCase.storeMessage(localMessage.copy(isPinned = false))
}
onPinnedMessageChanged(null)
refreshMessagesFromDb()
}
}
)
}
}
fun readMessage(message: VkMessage) {
messagesUseCase.markAsRead(
peerId = screenState.value.convoId,
startMessageId = message.id
).listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = {
viewModelScope.launch {
convoUseCase.getLocalConvoById(screenState.value.convoId)?.let { localConvo ->
convoUseCase.storeConvos(
listOf(
localConvo.copy(
inRead = if (!message.isOut) message.id else localConvo.inRead,
outRead = if (message.isOut) message.id else localConvo.outRead
)
)
)
}
refreshMessagesFromDb()
}
}
)
}
}
fun copyMessage(message: VkMessage) {
val clipboardManager =
applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val messageToCopy = message.text.orEmpty().trim()
if (messageToCopy.isEmpty()) {
val photo = with(message.attachments.orEmpty()) {
if (size == 1 && all { it is VkPhotoDomain }) {
first() as? VkPhotoDomain
} else null
} ?: return
val photoMaxSize = photo.getMaxSize() ?: return
viewModelScope.launch(Dispatchers.IO) {
val drawable = applicationContext.imageLoader.execute(
ImageRequest.Builder(applicationContext)
.data(photoMaxSize.url)
.build()
).drawable ?: return@launch
val imagesDir = File(applicationContext.cacheDir, "images")
if (!imagesDir.exists()) imagesDir.mkdirs()
val imageFile = File(imagesDir, "shared_image_id${photo.id}.png")
FileOutputStream(imageFile).use {
drawable.toBitmapOrNull()?.compress(Bitmap.CompressFormat.PNG, 100, it)
}
val uri = FileProvider.getUriForFile(
applicationContext,
"${applicationContext.packageName}.provider",
imageFile
)
val clip = ClipData.newUri(applicationContext.contentResolver, "Image", uri)
clipboardManager.setPrimaryClip(clip)
withContext(Dispatchers.Main) {
Toast.makeText(
applicationContext,
"Image copied to clipboard",
Toast.LENGTH_SHORT
).show()
}
}
return
}
clipboardManager.setPrimaryClip(ClipData.newPlainText("Message", messageToCopy))
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) {
Toast.makeText(applicationContext, R.string.copied_to_clipboard, Toast.LENGTH_SHORT)
.show()
}
}
private fun handleError(error: State.Error) {
VkUtils.parseError(error)?.let { newBaseError ->
baseError.setValue { newBaseError }
}
}
private fun refreshMessagesFromDb() {
viewModelScope.launchDbRefresh(
load = {
val localMessages = messagesUseCase.getLocalMessages(screenState.value.convoId)
messages.setValue { localMessages }
},
after = ::syncUiMessages
)
}
}
@@ -0,0 +1,24 @@
package dev.meloda.fast.messageshistory
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.messageshistory.model.MessageNavigation
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.flow.MutableStateFlow
internal class MessagesHistoryNavigationActions(
private val screenState: MutableStateFlow<MessagesHistoryScreenState>,
private val messages: MutableStateFlow<List<VkMessage>>,
private val navigation: MutableStateFlow<MessageNavigation?>
) {
fun onTopBarClicked() {
val cmId = messages.value.firstOrNull()?.cmId ?: return
navigation.setValue {
MessageNavigation.ChatMaterials(
peerId = screenState.value.convoId,
cmId = cmId
)
}
}
}
@@ -0,0 +1,51 @@
package dev.meloda.fast.messageshistory
import androidx.compose.ui.text.buildAnnotatedString
import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.model.api.domain.VkMessage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.math.abs
internal class MessagesHistoryPinnedMessageHandler(
private val screenState: MutableStateFlow<MessagesHistoryScreenState>
) {
fun update(pinnedMessage: VkMessage?) {
if (pinnedMessage == null) {
screenState.setValue { old ->
old.copy(
pinnedMessage = null,
convo = old.convo.copy(
pinnedMessage = null,
pinnedMessageId = null
),
pinnedSummary = null,
pinnedTitle = null
)
}
return
}
val pinnedUser = VkMemoryCache.getUser(pinnedMessage.fromId)
val pinnedGroup = VkMemoryCache.getGroup(abs(pinnedMessage.fromId))
val pinnedTitle = pinnedUser?.fullName ?: pinnedGroup?.name
val pinnedSummary = buildAnnotatedString {
pinnedMessage.text?.let(::append) ?: append("...")
}
screenState.setValue { old ->
old.copy(
pinnedMessage = pinnedMessage,
convo = old.convo.copy(
pinnedMessage = pinnedMessage,
pinnedMessageId = pinnedMessage.id
),
pinnedSummary = pinnedSummary,
pinnedTitle = pinnedTitle.orDots()
)
}
}
}
@@ -0,0 +1,28 @@
package dev.meloda.fast.messageshistory
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.GetMessageReadPeersUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
internal class MessagesHistoryReadPeersLoader(
private val scope: CoroutineScope,
private val getMessageReadPeersUseCase: GetMessageReadPeersUseCase
) {
suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int =
suspendCancellableCoroutine { continuation ->
scope.launch {
getMessageReadPeersUseCase
.invoke(peerId = peerId, cmId = cmId)
.listenValue(scope) { state ->
state.processState(
error = { continuation.resume(-1) },
success = { count -> continuation.resume(count) }
)
}
}
}
}
@@ -0,0 +1,27 @@
package dev.meloda.fast.messageshistory
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.vk.MessageUiItem
fun buildMessagesHistoryUiMessages(
messages: List<VkMessage>,
selectedMessages: List<VkMessage>,
screenState: MessagesHistoryScreenState,
resourceProvider: ResourceProvider,
): List<MessageUiItem> = messages.mapIndexed { index, message ->
message.asPresentation(
resourceProvider = resourceProvider,
showName = true,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = AppSettings.Experimental.showTimeInActionMessages,
convo = screenState.convo,
isSelected = screenState.editCmId == message.cmId ||
selectedMessages.indexOfFirstOrNull { it.id == message.id } != null
)
}
@@ -182,7 +182,7 @@ fun MessagesList(
item = item, item = item,
onClick = { onClick = {
if (item.actionCmId != null) { if (item.actionCmId != null) {
onRequestScrollToCmId(item.actionCmId!!) onRequestScrollToCmId(item.actionCmId)
} }
} }
) )
@@ -301,7 +301,7 @@ fun MessagesList(
}, },
onReplyClick = { onReplyClick = {
if (item.replyCmId != null) { if (item.replyCmId != null) {
onRequestScrollToCmId(item.replyCmId!!) onRequestScrollToCmId(item.replyCmId)
} }
}, },
offsetX = offsetAnimatable.value offsetX = offsetAnimatable.value
@@ -328,7 +328,7 @@ fun MessagesList(
}, },
onReplyClick = { onReplyClick = {
if (item.replyCmId != null) { if (item.replyCmId != null) {
onRequestScrollToCmId(item.replyCmId!!) onRequestScrollToCmId(item.replyCmId)
} }
}, },
offsetX = offsetAnimatable.value offsetX = offsetAnimatable.value
@@ -119,15 +119,12 @@ fun Attachments(
AttachmentType.STICKER -> { AttachmentType.STICKER -> {
Sticker( Sticker(
url = (attachment as VkStickerDomain).getUrl( item = attachment as VkStickerDomain
width = 256,
withBackground = false
)
) )
} }
AttachmentType.GIFT -> { AttachmentType.GIFT -> {
Gift(url = (attachment as VkGiftDomain).getDefaultThumbSizeOrLess()) Gift(item = attachment as VkGiftDomain)
} }
AttachmentType.VIDEO_MESSAGE -> { AttachmentType.VIDEO_MESSAGE -> {
@@ -217,10 +214,10 @@ fun Attachments(
} }
} }
fun VkAttachment.asUiPhoto(): UiPreview { fun VkAttachment.asUiPhoto(): UiPreview? {
return when (this) { return when (this) {
is VkPhotoDomain -> { is VkPhotoDomain -> {
val size = this.getDefault()!! val size = this.getDefault() ?: return null
UiPreview( UiPreview(
id = this.id, id = this.id,
url = size.url, url = size.url,
@@ -250,7 +247,7 @@ fun VkAttachment.asUiPhoto(): UiPreview {
is VkFileDomain -> { is VkFileDomain -> {
when { when {
this.preview?.video != null -> { this.preview?.video != null -> {
val video = this.preview?.video!! val video = this.preview?.video ?: return null
UiPreview( UiPreview(
id = id, id = id,
@@ -262,7 +259,7 @@ fun VkAttachment.asUiPhoto(): UiPreview {
} }
this.preview?.photo != null -> { this.preview?.photo != null -> {
val photoSize = this.preview?.photo?.sizes?.first()!! val photoSize = this.preview?.photo?.sizes?.firstOrNull() ?: return null
UiPreview( UiPreview(
id = id, id = id,
@@ -273,11 +270,11 @@ fun VkAttachment.asUiPhoto(): UiPreview {
) )
} }
else -> error("Unsupported type: $this") else -> null
} }
} }
else -> error("Unsupported type: $this") else -> null
} }
} }
@@ -21,24 +21,25 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.model.api.domain.VkGiftDomain
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
@Composable @Composable
fun Gift( fun Gift(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
url: String item: VkGiftDomain
) { ) {
Column( Column(
modifier = modifier modifier = modifier.width(192.dp),
.width(208.dp)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
AsyncImage( AsyncImage(
model = url, model = item.getDefaultThumbSizeOrLess(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.size(192.dp) modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
) )
Row( Row(
@@ -108,8 +108,9 @@ fun Link(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
if (item.title != null) { if (item.title != null) {
val title = item.title
Text( Text(
text = item.title!!, text = title,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@@ -10,23 +10,27 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.model.api.domain.VkStickerDomain
@Composable @Composable
fun Sticker( fun Sticker(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
url: String? item: VkStickerDomain
) { ) {
Box( Box(
modifier = modifier modifier = modifier.size(192.dp),
.size(208.dp)
.padding(8.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
AsyncImage( AsyncImage(
model = url, model = item.getUrl(
width = 256,
withBackground = false
),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier
.padding(8.dp)
.fillMaxSize()
) )
} }
} }

Some files were not shown because too many files have changed in this diff Show More