diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e5cc5fc5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,470 @@ +# AGENTS.md + +Internal handbook for agents working in this repository. + +Use this file to avoid re-discovering the project, preserve the current architecture, and move quickly in a large Android codebase without introducing stack drift. + +Primary source of truth: + +- `README.md` +- `settings.gradle.kts` +- `gradle/libs.versions.toml` + +## Non-Negotiables + +- Preserve the current stack and architecture unless the user explicitly asks to change them. +- Follow the existing MVI approach in `presentation`. +- Keep non-trivial screen state and side effects in stores/components, not in scattered composable state. +- Keep flavor-specific behavior aligned across `app`, `data`, and `presentation`. +- Write or update tests when changes affect logic, parsing, mapping, repositories, stores, reducers, state transitions, or bug fixes. +- Pure visual copy/layout tweaks may not need tests; behavior changes usually do. +- If a meaningful test should exist and you do not add one, say so explicitly. +- Do not replace core libraries with parallel stacks without explicit user approval. + +## Architecture + +Gradle modules: + +- `:app` = final Android application module +- `:presentation` = Compose UI, navigation, MVI stores, feature logic +- `:data` = repository implementations, TDLib integration, Room, push, services +- `:domain` = shared contracts, domain models, managers +- `:core` = low-level shared utilities +- `:baselineprofile` = startup/performance benchmarking + +Dependency direction: + +- `app` depends on `presentation`, `data`, `domain`, `core` +- `presentation` depends on `domain`, `core` +- `data` depends on `domain`, `core` +- `domain` and `core` should remain platform-light + +Typical feature flow: + +1. Android startup and app wiring happen in `app` +2. `presentation/root` selects startup and navigation flow +3. a `Default*Component` wires a `Store` +4. the `Store` processes intents and uses domain contracts +5. `data` implements those contracts and talks to TDLib / DB / push / platform services +6. results are mapped into UI state rendered by `*Content.kt` + +## Module Details + +### `:app` + +Role: + +- Android entrypoint +- build types, flavors, signing, APK naming/copying +- app shell wiring and top-level Android integration + +Main areas: + +- `app/src/main/java/org/monogram/app/components` +- `app/src/main/java/org/monogram/app/di` +- `app/src/main/java/org/monogram/app/ui` +- `app/src/main/java/org/monogram/app/ui/theme` + +Belongs here: + +- application startup +- Android app shell concerns +- theme/bootstrap wiring +- flavor/runtime-specific app setup + +Does not belong here: + +- repository logic +- TDLib implementation details +- large reusable feature flows already living in `presentation` + +### `:presentation` + +Role: + +- main UI module +- Compose screens, navigation, MVI state management, feature interaction logic + +Top-level areas: + +- `presentation/root` = app-wide startup/navigation +- `presentation/di` = Koin wiring and presentation-level setup +- `presentation/core` = shared UI/media/util infrastructure +- `presentation/features` = product flows +- `presentation/settings` = settings flows +- `presentation/src/main/cpp` = native media/animation support + +Common file roles: + +- `*Content.kt` = UI rendering +- `*Store.kt` / `*StoreFactory.kt` = state, intents, side effects +- `Default*Component.kt` = concrete wiring and navigation integration + +Important subareas: + +- `features/chats` + - largest and most fragile feature area + - chat list, conversation, message rendering, input bar, scrolling, selection, editor flows +- `features/auth` + - auth/login flow +- `features/webapp`, `features/webview` + - mini apps and embedded web flows +- `settings/privacy`, `settings/proxy`, `settings/storage` + - settings areas with substantial behavior/state + +Belongs here: + +- screen state +- user intent handling +- navigation state +- Compose UI +- presentation-level mapping and UI orchestration + +Does not belong here: + +- low-level TDLib calls +- Room access +- repository implementation details + +### `:data` + +Role: + +- concrete data layer +- repository implementations and integration boundary with TDLib/platform/storage + +Main areas: + +- `data/chats` +- `data/datasource/cache` +- `data/datasource/remote` +- `data/db/dao` +- `data/db/model` +- `data/di` +- `data/gateway` +- `data/infra` +- `data/mapper` +- `data/notifications` +- `data/push` +- `data/repository` +- `data/service` +- `data/stickers` +- `data/src/official/jniLibs` +- `data/src/telemt/jniLibs` + +Belongs here: + +- repository implementations +- TDLib access +- local cache and DB +- push implementation +- data mapping between external/raw and internal models + +Does not belong here: + +- Compose UI +- screen state +- app shell concerns + +### `:domain` + +Role: + +- shared contract/model layer between `presentation` and `data` + +Main areas: + +- `domain/models` +- `domain/models/webapp` +- `domain/repository` +- `domain/managers` +- `domain/proxy` + +Belongs here: + +- interfaces/contracts +- stable domain models +- business-level abstractions + +Does not belong here: + +- Android framework code +- Room entities/DAOs +- Compose/UI code +- TDLib implementation details + +### `:core` + +Role: + +- low-level shared helpers with low coupling + +Known area: + +- `core/date` + +Belongs here: + +- generic reusable utilities + +Does not belong here: + +- feature-specific behavior +- app-specific orchestration + +### `:baselineprofile` + +Role: + +- performance/startup benchmark module targeting `:app` + +Main files: + +- `BaselineProfileGenerator.kt` +- `StartupBenchmarks.kt` + +## Task Recipes + +- UI issue on one screen: + - start at the nearest `*Content.kt` + - then inspect the related `Store` + - then inspect the `Default*Component` if navigation/wiring is involved +- MVI/state bug: + - start from `*Store.kt` and `*StoreFactory.kt` + - check intents, state transitions, side effects, and any reducer/executor-style logic +- Chat bug: + - start in `presentation/features/chats` + - if it smells like data/update/state source issue, follow into `data/chats`, `data/gateway`, `data/datasource/remote` +- TDLib/API shape bug: + - inspect `data/gateway` and `data/datasource/remote` + - inspect `data/src/official/java/org/drinkless/tdlib/TdApi.java` or `data/src/telemt/java/org/drinkless/tdlib/TdApi.java` + - then check official TDLib docs +- Mapper/parsing bug: + - start in `data/mapper`, then trace caller repository/datasource usage +- Push or flavor bug: + - inspect `firebase/libre` source sets in `app`, `data`, and `presentation` + - confirm the same flavor assumption across modules before changing code +- Storage/database bug: + - start in `data/db`, `data/datasource/cache`, and affected repository +- Build or Android SDK bug: + - if errors look environment-related, assume sandbox may be the cause and escalate quickly + +## TDLib Reference + +The project uses `TdApi.java` as the generated TDLib Java API surface. + +Important local copies: + +- `data/src/official/java/org/drinkless/tdlib/TdApi.java` +- `data/src/telemt/java/org/drinkless/tdlib/TdApi.java` + +Upstream/reference copies also exist in: + +- `td/example/android/tdlib/java/org/drinkless/tdlib/TdApi.java` +- `td-telemt/example/android/tdlib/java/org/drinkless/tdlib/TdApi.java` + +When working on TDLib-related behavior: + +- inspect `TdApi.java` first for object/function types and field names +- inspect `data/gateway` for the project’s TDLib boundary +- inspect `data/datasource/remote` and repositories for call sites + +External TDLib references: + +- Telegram API docs: `https://core.telegram.org/api` +- TDLib C++/API docs: `https://core.telegram.org/tdlib/docs/td__api_8h.html` + +IDE note from `README.md`: + +- `TdApi.java` is large enough that Android Studio indexing limits may need to be increased + +## Dependency Usage Guide + +All dependency versions are centralized in `gradle/libs.versions.toml`. + +Rules: + +- change versions in the version catalog, not ad hoc in module files +- extend the current stack before adding a new library +- do not introduce a parallel stack unless the user explicitly asks for it + +Current stack and intended use: + +- `Kotlin` + `kotlinx.coroutines` + `Flow` + - default async/concurrency model + - do not introduce `RxJava` +- `Jetpack Compose` + `Material 3` + adaptive Compose libs + - main UI toolkit + - do not pivot new UI work to an XML-first architecture +- `Decompose` + - navigation and component lifecycle in `presentation` + - do not replace with another navigation stack without request +- `MVIKotlin` + - screen state management and MVI flow in `presentation` + - keep non-trivial presentation logic in the existing MVI shape +- `Koin` + - dependency injection across `app`, `presentation`, and `data` + - do not replace with `Dagger` or `Hilt` +- `kotlinx.serialization` + - serialization/parsing layer + - avoid introducing `Gson` or `Moshi` without explicit need +- `Room` + `KSP` + - persistence and codegen in `data/db` + - do not replace with another persistence stack casually +- `Coil 3` + - image, GIF, SVG, and video loading in UI + - do not replace with `Glide` or `Picasso` +- `Media3 / ExoPlayer` + - media playback +- `CameraX` + - camera capture and scanner flows +- `ZXing` + - QR/barcode processing +- `TDLib` + - Telegram core integration +- `Firebase Messaging` + - push for `firebase` variants +- `UnifiedPush` + - non-Firebase compatible push path and abstraction +- `MapLibre Compose` + - map UI +- `AndroidX Security Crypto` + `Biometric` + - local security and biometric flows +- `libphonenumber` + - phone number parsing/formatting +- `OSS Licenses plugin` + `play-services-oss-licenses` + - licenses screen support + +## Build, Flavors, Sandbox + +Flavor dimensions: + +- `tdlib`: `official`, `telemt` +- `runtime`: `firebase`, `libre` + +Meaning: + +- `official` / `telemt` = TDLib source/binaries +- `firebase` = Firebase-backed push/runtime path +- `libre` = non-Firebase path + +Required local config in `local.properties`: + +```properties +API_ID=12345678 +API_HASH=your_api_hash_here +``` + +Optional release signing: + +```properties +RELEASE_STORE_FILE=keystore/release.jks +RELEASE_STORE_PASSWORD=... +RELEASE_KEY_ALIAS=... +RELEASE_KEY_PASSWORD=... +``` + +Firebase variants also require: + +- `app/google-services.json` + +Native prerequisite for `presentation`: + +1. go to `presentation/src/main/cpp` +2. set `ANDROID_NDK_HOME` in `build.sh` +3. run `build.sh` + +TDLib build commands: + +```bash +./build-tdlib.sh official +./build-tdlib.sh telemt +./build-tdlib.sh both +``` + +WSL examples from Windows PowerShell: + +```powershell +wsl -d Ubuntu -- bash -lc "cd /mnt/z/GitHub/monogram && ./build-tdlib.sh official" +wsl -d Ubuntu -- bash -lc "cd /mnt/z/GitHub/monogram && ./build-tdlib.sh telemt" +``` + +Useful Gradle tasks: + +```powershell +.\gradlew.bat :app:assembleOfficialLibreDebug +.\gradlew.bat :app:assembleTelemtLibreDebug +.\gradlew.bat :data:testOfficialLibreDebugUnitTest +.\gradlew.bat :presentation:testOfficialLibreDebugUnitTest +.\gradlew.bat :core:test +``` + +Sandbox rule: + +- If Gradle, Android SDK, NDK, emulator, ADB, or related tooling fails because SDK paths, environment access, local components, or process spawning appear restricted, assume sandbox is the likely cause. +- Do not waste time on random workaround guesses first. +- Automatically request an out-of-sandbox build/test run with escalation. +- This especially applies to Android SDK lookup errors, missing SDK components that are known to exist locally, emulator/ADB access issues, and Gradle failures that look environment-related rather than code-related. + +## Testing Expectations + +Add or update tests for: + +- bug fixes +- store/reducer/side-effect logic changes +- repository behavior changes +- mapper/parser/utility changes +- chat behavior changes +- webapp/proxy logic changes +- state machine or branching logic changes + +Check existing tests first in: + +- `presentation/src/test/java` +- `data/src/test/java` +- `data/src/officialUnitTest/java` +- `data/src/telemtUnitTest/java` +- `core/src/test` + +Verification rule: + +- run the narrowest relevant test task first +- then run broader compile/build validation only if needed + +## Navigation Heuristics + +Usually start from: + +- `app` for startup/theme/bootstrap problems +- `presentation` for UI, navigation, MVI state, and user interaction issues +- `data` for TDLib, repository, cache, DB, push, and mapper issues +- `domain` for interfaces/contracts/model boundaries +- `core` for generic shared utility problems + +Usually ignore unless debugging build internals: + +- `.git/` +- `.gradle/` +- `.idea/` +- `.kotlin/` +- `build/` +- `app/build/` +- `data/build/` +- `presentation/build/` +- `presentation/.cxx/` +- `domain/build/` +- `core/build/` +- `baselineprofile/build/` + +## Hotspots / Risks + +- `presentation/features/chats` + - largest UI/logic surface and easiest place to introduce regressions +- flavor alignment across `app`, `data`, and `presentation` + - one-module-only fixes often break another variant +- `data/gateway` + - TDLib boundary; small changes can fan out widely +- `data/mapper` + - easy place for subtle model regressions +- `presentation/src/main/cpp` + - native build/setup can fail for environment reasons unrelated to app code diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c7a48c3e..95bcccd5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -210,6 +210,8 @@ dependencies { implementation(project(":core")) baselineProfile(project(":baselineprofile")) + + testImplementation(libs.junit) } tasks.withType(DependencyTask::class.java).configureEach { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d2250ece..c9ba1275 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -70,6 +70,18 @@ + + + + + + + + + + + + PendingAttachment? +) { + constructor(context: android.content.Context) : this( + copyUriToAttachment = { uri -> copyUriToAttachment(context, uri) } + ) + + suspend fun resolve(intent: Intent): IncomingShareRequest? = withContext(Dispatchers.IO) { + val payload = extractIncomingSharePayload(intent) ?: return@withContext null + val attachments = payload.uris.mapNotNull(copyUriToAttachment) + + IncomingShareRequest( + requestId = System.currentTimeMillis(), + text = payload.text, + attachments = attachments + ).takeIf { it.text.isNotBlank() || it.attachments.isNotEmpty() } + } +} + +internal data class ExtractedIncomingSharePayload( + val text: String, + val uris: List +) + +internal data class IncomingSharePayloadInput( + val action: String?, + val text: String?, + val clipDataUris: List = emptyList(), + val extraStreamUris: List = emptyList() +) + +internal data class NormalizedIncomingSharePayload( + val text: String, + val uriStrings: List +) + +internal fun extractIncomingSharePayload(intent: Intent): ExtractedIncomingSharePayload? { + val extraStreamUris = when (intent.action) { + Intent.ACTION_SEND -> listOfNotNull( + intent.parcelableExtraCompat(Intent.EXTRA_STREAM)?.toString() + ) + + Intent.ACTION_SEND_MULTIPLE -> intent.parcelableArrayListExtraCompat(Intent.EXTRA_STREAM) + ?.map(Uri::toString) + .orEmpty() + + else -> emptyList() + } + + val normalized = normalizeIncomingSharePayload( + IncomingSharePayloadInput( + action = intent.action, + text = intent.getStringExtra(Intent.EXTRA_TEXT), + clipDataUris = intent.clipData?.collectUriStrings().orEmpty(), + extraStreamUris = extraStreamUris + ) + ) ?: return null + + return ExtractedIncomingSharePayload( + text = normalized.text, + uris = normalized.uriStrings.map(Uri::parse) + ) +} + +internal fun normalizeIncomingSharePayload( + input: IncomingSharePayloadInput +): NormalizedIncomingSharePayload? { + if (input.action != Intent.ACTION_SEND && input.action != Intent.ACTION_SEND_MULTIPLE) return null + + val uris = LinkedHashSet() + input.clipDataUris.asSequence() + .map(String::trim) + .filter(String::isNotBlank) + .forEach(uris::add) + input.extraStreamUris.asSequence() + .map(String::trim) + .filter(String::isNotBlank) + .forEach(uris::add) + + val text = input.text.orEmpty().trim() + if (text.isBlank() && uris.isEmpty()) return null + + return NormalizedIncomingSharePayload( + text = text, + uriStrings = uris.toList() + ) +} + +internal fun copyUriToAttachment(context: android.content.Context, uri: Uri): PendingAttachment? { + val mediaCopy = context.copyUriToTempMediaFile(uri) + val kind = mediaCopy?.classifyAsMediaKind() + if (kind != null) { + return PendingAttachment( + localPath = mediaCopy.localPath, + kind = kind, + deleteAfterUse = true + ) + } + + val documentCopy = context.copyUriToTempDocumentFile(uri) ?: return null + return PendingAttachment( + localPath = documentCopy.localPath, + kind = PendingAttachmentKind.DOCUMENT, + deleteAfterUse = true + ) +} + +private fun Intent.isShareIntent(): Boolean { + return action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE +} + +private fun ClipData.collectUriStrings(): List { + return buildList { + for (index in 0 until itemCount) { + getItemAt(index)?.uri?.toString()?.let(::add) + } + } +} + +@Suppress("DEPRECATION") +private inline fun Intent.parcelableExtraCompat(name: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(name, T::class.java) + } else { + getParcelableExtra(name) + } +} + +@Suppress("DEPRECATION") +private inline fun Intent.parcelableArrayListExtraCompat(name: String): ArrayList? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableArrayListExtra(name, T::class.java) + } else { + getParcelableArrayListExtra(name) + } +} + +private fun CopiedUriFile.classifyAsMediaKind(): PendingAttachmentKind? { + val mime = mimeType.orEmpty().lowercase() + val name = (displayName ?: localPath).lowercase() + return when { + mime == "image/gif" || name.endsWith(".gif") -> PendingAttachmentKind.GIF + mime.startsWith("video/") -> PendingAttachmentKind.VIDEO + mime.startsWith("image/") -> PendingAttachmentKind.PHOTO + else -> null + } +} diff --git a/app/src/main/java/org/monogram/app/MainActivity.kt b/app/src/main/java/org/monogram/app/MainActivity.kt index 7c82a336..7c596d69 100644 --- a/app/src/main/java/org/monogram/app/MainActivity.kt +++ b/app/src/main/java/org/monogram/app/MainActivity.kt @@ -6,6 +6,7 @@ import android.os.Build import android.os.Bundle import android.provider.Settings import android.util.Log +import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.CompositionLocalProvider @@ -14,6 +15,7 @@ import androidx.compose.runtime.getValue import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import androidx.window.layout.WindowInfoTracker import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.retainedComponent @@ -35,6 +37,7 @@ class MainActivity : FragmentActivity() { private lateinit var root: RootComponent private val appPreferences: AppPreferences by inject() private val appForegroundTracker: AppForegroundTracker by inject() + private lateinit var incomingShareIntentResolver: IncomingShareIntentResolver @Volatile private var keepSplashOnScreen: Boolean = true @@ -64,6 +67,7 @@ class MainActivity : FragmentActivity() { ) ) } + incomingShareIntentResolver = IncomingShareIntentResolver(this) handleIntent(intent) @@ -98,10 +102,27 @@ class MainActivity : FragmentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) handleIntent(intent) } private fun handleIntent(intent: Intent) { + if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) { + lifecycleScope.launchWhenStarted { + val request = incomingShareIntentResolver.resolve(intent) + if (request != null) { + root.handleIncomingShare(request) + } else { + Toast.makeText( + this@MainActivity, + "Unable to share this content", + Toast.LENGTH_SHORT + ).show() + } + } + return + } + val data = intent.dataString if (data != null) { root.handleLink(data) diff --git a/app/src/main/java/org/monogram/app/components/MobileLayout.kt b/app/src/main/java/org/monogram/app/components/MobileLayout.kt index 7bbe1439..af939c21 100644 --- a/app/src/main/java/org/monogram/app/components/MobileLayout.kt +++ b/app/src/main/java/org/monogram/app/components/MobileLayout.kt @@ -38,6 +38,10 @@ import com.arkivanov.decompose.extensions.compose.stack.animation.StackAnimation import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.ChildStack import kotlinx.coroutines.launch +import org.monogram.presentation.core.ui.ScreenSwipeBackAction +import org.monogram.presentation.core.ui.ScreenSwipeBackState +import org.monogram.presentation.features.chats.conversation.ChatRenderMode +import org.monogram.presentation.features.chats.list.ChatListPreviewMode import org.monogram.presentation.root.RootComponent import kotlin.math.abs @@ -46,11 +50,12 @@ import kotlin.math.abs fun MobileLayout(root: RootComponent) { val stack by root.childStack.subscribeAsState() val isDragToBackEnabled by root.appPreferences.isDragToBackEnabled.collectAsState() + val showAllChatsFolder by root.appPreferences.showAllChatsFolder.collectAsState() val coroutineScope = rememberCoroutineScope() val activeEntry = stack.active val previousEntry = stack.items.dropLast(1).lastOrNull() val stackKeysAreUnique = stack.items.map { it.key }.toSet().size == stack.items.size - val canRenderSwipePreview = + val canRenderStackPreview = stackKeysAreUnique && previousEntry != null && previousEntry.key != activeEntry.key && @@ -58,19 +63,30 @@ fun MobileLayout(root: RootComponent) { var dragOffsetX by remember { mutableFloatStateOf(0f) } var isCompletingSwipeBack by remember { mutableStateOf(false) } var widthPx by remember { mutableFloatStateOf(0f) } - var isSwipeBackBlocked by remember { mutableStateOf(false) } var swipeBackActiveKey by remember { mutableStateOf(null) } var suppressNextPopAnimation by remember { mutableStateOf(false) } + var activeScreenSwipeBackState by remember { mutableStateOf(ScreenSwipeBackState()) } + val archivePreviewMode = resolveArchivePreviewMode(activeEntry.instance, showAllChatsFolder) + val archivePreviewFolderId = + (archivePreviewMode as? ChatListPreviewMode.FolderPreview)?.folderId + val swipeBackResolution = resolveSwipeBack( + child = activeEntry.instance, + screenSwipeBackState = activeScreenSwipeBackState, + archivePreviewFolderId = archivePreviewFolderId, + ) + val canRenderLocalPreview = + swipeBackResolution.preview == SwipeBackPreviewDescriptor.ChatForumList || + swipeBackResolution.preview is SwipeBackPreviewDescriptor.ChatListFolder val canUseDragToBack = isDragToBackEnabled && - previousEntry != null && stackKeysAreUnique && - isSwipeBackSupported(activeEntry.instance) && - !isSwipeBackBlocked + swipeBackResolution.isSupported && + !swipeBackResolution.isBlocked && + (previousEntry != null || canRenderLocalPreview) val dragProgress = if (widthPx > 0f) (dragOffsetX / widthPx).coerceIn(0f, 1f) else 0f LaunchedEffect(activeEntry.key, stackKeysAreUnique) { - isSwipeBackBlocked = false + activeScreenSwipeBackState = ScreenSwipeBackState() swipeBackActiveKey = null if (!stackKeysAreUnique) { dragOffsetX = 0f @@ -89,10 +105,15 @@ fun MobileLayout(root: RootComponent) { val isSwipeBackActive = dragOffsetX > 0f && - canRenderSwipePreview && + (canRenderStackPreview || canRenderLocalPreview) && swipeBackActiveKey == activeEntry.key val retainedPreviousKey = - if (stackKeysAreUnique && previousEntry != null && previousEntry.key != activeEntry.key) { + if ( + swipeBackResolution.preview == SwipeBackPreviewDescriptor.PreviousStackEntry && + stackKeysAreUnique && + previousEntry != null && + previousEntry.key != activeEntry.key + ) { previousEntry.key } else { null @@ -106,7 +127,7 @@ fun MobileLayout(root: RootComponent) { } .then( if (canUseDragToBack) { - Modifier.pointerInput(canUseDragToBack, activeEntry.key) { + Modifier.pointerInput(canUseDragToBack, activeEntry.key, swipeBackResolution) { awaitEachGesture { if (size.width == 0) return@awaitEachGesture @@ -152,8 +173,14 @@ fun MobileLayout(root: RootComponent) { ) { value, _ -> dragOffsetX = value } - suppressNextPopAnimation = true - root.onBack() + commitSwipeBack( + root = root, + activeChild = activeEntry.instance, + resolution = swipeBackResolution, + onSuppressStackPopAnimation = { + suppressNextPopAnimation = true + }, + ) dragOffsetX = 0f isCompletingSwipeBack = false swipeBackActiveKey = null @@ -185,7 +212,7 @@ fun MobileLayout(root: RootComponent) { continue } - if (!canRenderSwipePreview) { + if (!canRenderStackPreview && !canRenderLocalPreview) { dragOffsetX = 0f break } @@ -220,6 +247,33 @@ fun MobileLayout(root: RootComponent) { } ), ) { + if (isSwipeBackActive && swipeBackResolution.preview != SwipeBackPreviewDescriptor.PreviousStackEntry) { + Box( + modifier = Modifier.mobileStackLayerModifier( + role = StackLayerRole.LocalPreview, + dragOffsetX = dragOffsetX, + dragProgress = dragProgress, + transitionProgress = 1f, + isPop = false, + ), + ) { + RenderChild( + child = activeEntry.instance, + chatRenderModeOverride = when (swipeBackResolution.preview) { + SwipeBackPreviewDescriptor.ChatForumList -> ChatRenderMode.ForumTopicSwipePreview + else -> null + }, + chatListPreviewMode = when (val preview = swipeBackResolution.preview) { + is SwipeBackPreviewDescriptor.ChatListFolder -> ChatListPreviewMode.FolderPreview( + preview.folderId + ) + + else -> ChatListPreviewMode.Active + }, + ) + } + } + Children( stack = stack, animation = mobileStackAnimation( @@ -227,18 +281,31 @@ fun MobileLayout(root: RootComponent) { dragProgress = dragProgress, isSwipeBackActive = isSwipeBackActive, stackKeysAreUnique = stackKeysAreUnique, + showRetainedPreviousChild = swipeBackResolution.preview == SwipeBackPreviewDescriptor.PreviousStackEntry, + showLocalPreviewBackdrop = swipeBackResolution.preview != SwipeBackPreviewDescriptor.PreviousStackEntry, suppressNextPopAnimation = suppressNextPopAnimation, onSuppressNextPopAnimationConsumed = { suppressNextPopAnimation = false }, ), ) { child -> + val layerRole = resolveSwipeLayerRole( + child = child, + activeKey = activeEntry.key, + retainedPreviousKey = retainedPreviousKey, + ) + RenderChild( child = child.instance, - isOverlay = child.key == retainedPreviousKey, - onSwipeBackBlockedChanged = { blocked -> + isOverlay = layerRole == StackLayerRole.RetainedPrevious, + chatRenderModeOverride = when (layerRole) { + StackLayerRole.RetainedPrevious -> ChatRenderMode.SwipePreview + else -> null + }, + chatListPreviewMode = ChatListPreviewMode.Active, + onScreenSwipeBackStateChanged = { swipeState -> if (stack.active.instance === child.instance) { - isSwipeBackBlocked = blocked + activeScreenSwipeBackState = swipeState } }, ) @@ -246,36 +313,48 @@ fun MobileLayout(root: RootComponent) { } } -private fun isSwipeBackSupported(child: RootComponent.Child): Boolean = - when (child) { - is RootComponent.Child.ChatDetailChild, - is RootComponent.Child.ProfileChild, - is RootComponent.Child.SettingsChild, - is RootComponent.Child.EditProfileChild, - is RootComponent.Child.SessionsChild, - is RootComponent.Child.FoldersChild, - is RootComponent.Child.ChatSettingsChild, - is RootComponent.Child.DataStorageChild, - is RootComponent.Child.StorageUsageChild, - is RootComponent.Child.NetworkUsageChild, - is RootComponent.Child.PremiumChild, - is RootComponent.Child.PrivacyChild, - is RootComponent.Child.AdBlockChild, - is RootComponent.Child.PowerSavingChild, - is RootComponent.Child.NotificationsChild, - is RootComponent.Child.ProxyChild, - is RootComponent.Child.ProfileLogsChild, - is RootComponent.Child.AdminManageChild, - is RootComponent.Child.ChatEditChild, - is RootComponent.Child.MemberListChild, - is RootComponent.Child.ChatPermissionsChild, - is RootComponent.Child.StickersChild, - is RootComponent.Child.AboutChild, - is RootComponent.Child.NewChatChild -> true - is RootComponent.Child.DebugChild -> true - - else -> false +private fun resolveSwipeLayerRole( + child: Child.Created<*, RootComponent.Child>, + activeKey: Any, + retainedPreviousKey: Any?, +): StackLayerRole? { + return when { + child.key == retainedPreviousKey -> StackLayerRole.RetainedPrevious + child.key == activeKey -> StackLayerRole.Active + else -> null + } +} + +private fun commitSwipeBack( + root: RootComponent, + activeChild: RootComponent.Child, + resolution: SwipeBackResolution, + onSuppressStackPopAnimation: () -> Unit, +) { + when (resolution.action) { + ScreenSwipeBackAction.StackPop -> { + onSuppressStackPopAnimation() + when (activeChild) { + is RootComponent.Child.ChatDetailChild -> activeChild.component.onBackClicked() + else -> root.onBack() + } + } + + ScreenSwipeBackAction.LocalChatTopicClose -> { + when (activeChild) { + is RootComponent.Child.ChatDetailChild -> activeChild.component.onTopicClick(0) + else -> root.onBack() + } + } + + ScreenSwipeBackAction.LocalChatListArchiveReturn -> { + when (activeChild) { + is RootComponent.Child.ChatsChild -> activeChild.component.handleBack() + else -> root.onBack() + } + } } +} @Composable private fun mobileStackAnimation( @@ -283,6 +362,8 @@ private fun mobileStackAnimation( dragProgress: Float, isSwipeBackActive: Boolean, stackKeysAreUnique: Boolean, + showRetainedPreviousChild: Boolean, + showLocalPreviewBackdrop: Boolean, suppressNextPopAnimation: Boolean, onSuppressNextPopAnimationConsumed: () -> Unit, ): StackAnimation { @@ -328,7 +409,7 @@ private fun mobileStackAnimation( transition = null }, ) - val retainedPreviousChild = if (stackKeysAreUnique) { + val retainedPreviousChild = if (stackKeysAreUnique && showRetainedPreviousChild) { stack.items.dropLast(1).lastOrNull() } else { null @@ -372,7 +453,7 @@ private fun mobileStackAnimation( Box(modifier = modifier) { layers.forEach { layer -> - key(layer.child.key) { + key("${layer.child.key}:${layer.role}") { StackLayerChild( layer = layer, dragOffsetX = if (isSwipeBackActive) dragOffsetX else 0f, @@ -383,7 +464,9 @@ private fun mobileStackAnimation( } } - if (isSwipeBackActive && retainedPreviousChild != null) { + if (isSwipeBackActive && + (retainedPreviousChild != null || showLocalPreviewBackdrop) + ) { Box( modifier = Modifier .fillMaxSize() @@ -437,7 +520,7 @@ private fun Modifier.mobileStackLayerModifier( ): Modifier = fillMaxSize() .then( - if (role == StackLayerRole.RetainedPrevious) { + if (role == StackLayerRole.RetainedPrevious || role == StackLayerRole.LocalPreview) { Modifier.clearAndSetSemantics {} } else { Modifier @@ -448,6 +531,7 @@ private fun Modifier.mobileStackLayerModifier( StackLayerRole.Active, StackLayerRole.TransitionIncoming -> 1f + StackLayerRole.LocalPreview, StackLayerRole.RetainedPrevious, StackLayerRole.TransitionOutgoing -> 0f }, @@ -459,7 +543,8 @@ private fun Modifier.mobileStackLayerModifier( shadowElevation = if (dragOffsetX > 0f) 12f else 0f } - StackLayerRole.RetainedPrevious -> { + StackLayerRole.RetainedPrevious, + StackLayerRole.LocalPreview -> { translationX = if (dragOffsetX > 0f) { (dragProgress - 1f) * size.width * 0.08f @@ -504,6 +589,7 @@ private data class StackLayer( private enum class StackLayerRole { Active, + LocalPreview, RetainedPrevious, TransitionOutgoing, TransitionIncoming, diff --git a/app/src/main/java/org/monogram/app/components/RenderChild.kt b/app/src/main/java/org/monogram/app/components/RenderChild.kt index 68c75382..8e17a79a 100644 --- a/app/src/main/java/org/monogram/app/components/RenderChild.kt +++ b/app/src/main/java/org/monogram/app/components/RenderChild.kt @@ -1,16 +1,19 @@ package org.monogram.app.components import androidx.compose.runtime.Composable +import org.monogram.presentation.core.ui.ScreenSwipeBackState import org.monogram.presentation.features.auth.AuthContent import org.monogram.presentation.features.chats.conversation.ChatContent import org.monogram.presentation.features.chats.conversation.ChatRenderMode import org.monogram.presentation.features.chats.creation.NewChatContent import org.monogram.presentation.features.chats.list.ChatListContent +import org.monogram.presentation.features.chats.list.ChatListPreviewMode import org.monogram.presentation.features.profile.ProfileContent import org.monogram.presentation.features.profile.admin.AdminManageContent import org.monogram.presentation.features.profile.admin.ChatEditContent import org.monogram.presentation.features.profile.admin.ChatPermissionsContent import org.monogram.presentation.features.profile.admin.MemberListContent +import org.monogram.presentation.features.profile.contact.ContactEditContent import org.monogram.presentation.features.profile.logs.ProfileLogsContent import org.monogram.presentation.features.webview.InternalWebView import org.monogram.presentation.root.RootComponent @@ -38,17 +41,24 @@ import org.monogram.presentation.settings.storage.StorageUsageContent fun RenderChild( child: RootComponent.Child, isOverlay: Boolean = false, - onSwipeBackBlockedChanged: (Boolean) -> Unit = {}, + chatRenderModeOverride: ChatRenderMode? = null, + chatListPreviewMode: ChatListPreviewMode = ChatListPreviewMode.Active, + onScreenSwipeBackStateChanged: (ScreenSwipeBackState) -> Unit = {}, ) { when (child) { is RootComponent.Child.StartupChild -> StartupContent(child.component) is RootComponent.Child.AuthChild -> AuthContent(child.component) - is RootComponent.Child.ChatsChild -> ChatListContent(child.component) + is RootComponent.Child.ChatsChild -> ChatListContent( + component = child.component, + previewMode = chatListPreviewMode, + onSwipeBackStateChanged = onScreenSwipeBackStateChanged, + ) is RootComponent.Child.NewChatChild -> NewChatContent(child.component) is RootComponent.Child.ChatDetailChild -> ChatContent( component = child.component, - renderMode = if (isOverlay) ChatRenderMode.SwipePreview else ChatRenderMode.Active, - onSwipeBackBlockedChanged = onSwipeBackBlockedChanged, + renderMode = chatRenderModeOverride + ?: if (isOverlay) ChatRenderMode.SwipePreview else ChatRenderMode.Active, + onSwipeBackStateChanged = onScreenSwipeBackStateChanged, ) is RootComponent.Child.SettingsChild -> SettingsContent(child.component) @@ -69,6 +79,7 @@ fun RenderChild( is RootComponent.Child.ProfileLogsChild -> ProfileLogsContent(child.component) is RootComponent.Child.AdminManageChild -> AdminManageContent(child.component) is RootComponent.Child.ChatEditChild -> ChatEditContent(child.component) + is RootComponent.Child.ContactEditChild -> ContactEditContent(child.component) is RootComponent.Child.MemberListChild -> MemberListContent(child.component) is RootComponent.Child.ChatPermissionsChild -> ChatPermissionsContent(child.component) is RootComponent.Child.PasscodeChild -> PasscodeContent(child.component) diff --git a/app/src/main/java/org/monogram/app/components/SwipeBackResolver.kt b/app/src/main/java/org/monogram/app/components/SwipeBackResolver.kt new file mode 100644 index 00000000..2073a91a --- /dev/null +++ b/app/src/main/java/org/monogram/app/components/SwipeBackResolver.kt @@ -0,0 +1,120 @@ +package org.monogram.app.components + +import androidx.compose.runtime.Immutable +import org.monogram.presentation.core.ui.ScreenSwipeBackAction +import org.monogram.presentation.core.ui.ScreenSwipeBackPreview +import org.monogram.presentation.core.ui.ScreenSwipeBackState +import org.monogram.presentation.features.chats.list.ChatListPreviewMode +import org.monogram.presentation.features.chats.list.resolveArchiveReturnFolderId +import org.monogram.presentation.root.RootComponent + +@Immutable +internal data class SwipeBackResolution( + val isSupported: Boolean, + val isBlocked: Boolean, + val action: ScreenSwipeBackAction = ScreenSwipeBackAction.StackPop, + val preview: SwipeBackPreviewDescriptor = SwipeBackPreviewDescriptor.PreviousStackEntry, +) + +internal sealed interface SwipeBackPreviewDescriptor { + data object PreviousStackEntry : SwipeBackPreviewDescriptor + data object ChatForumList : SwipeBackPreviewDescriptor + data class ChatListFolder(val folderId: Int) : SwipeBackPreviewDescriptor +} + +internal fun resolveSwipeBack( + child: RootComponent.Child, + screenSwipeBackState: ScreenSwipeBackState = ScreenSwipeBackState(), + archivePreviewFolderId: Int? = null, +): SwipeBackResolution { + return when (child) { + is RootComponent.Child.ChatDetailChild -> { + SwipeBackResolution( + isSupported = screenSwipeBackState.isSupported, + isBlocked = screenSwipeBackState.isBlocked, + action = screenSwipeBackState.action, + preview = when (screenSwipeBackState.preview) { + ScreenSwipeBackPreview.ChatForumList -> SwipeBackPreviewDescriptor.ChatForumList + ScreenSwipeBackPreview.ChatListFolder -> { + archivePreviewFolderId?.let(SwipeBackPreviewDescriptor::ChatListFolder) + ?: SwipeBackPreviewDescriptor.PreviousStackEntry + } + + ScreenSwipeBackPreview.PreviousStackEntry -> SwipeBackPreviewDescriptor.PreviousStackEntry + }, + ) + } + + is RootComponent.Child.ChatsChild -> { + SwipeBackResolution( + isSupported = screenSwipeBackState.isSupported, + isBlocked = screenSwipeBackState.isBlocked, + action = screenSwipeBackState.action, + preview = when (screenSwipeBackState.preview) { + ScreenSwipeBackPreview.ChatListFolder -> { + archivePreviewFolderId?.let(SwipeBackPreviewDescriptor::ChatListFolder) + ?: SwipeBackPreviewDescriptor.PreviousStackEntry + } + + ScreenSwipeBackPreview.ChatForumList -> SwipeBackPreviewDescriptor.ChatForumList + ScreenSwipeBackPreview.PreviousStackEntry -> SwipeBackPreviewDescriptor.PreviousStackEntry + }, + ) + } + + else -> SwipeBackResolution( + isSupported = isGenericSwipeBackSupported(child), + isBlocked = false, + ) + } +} + +internal fun resolveArchivePreviewMode( + child: RootComponent.Child, + showAllChatsFolder: Boolean, +): ChatListPreviewMode { + val chatsChild = child as? RootComponent.Child.ChatsChild ?: return ChatListPreviewMode.Active + val state = chatsChild.component.state.value + val targetFolderId = resolveArchiveReturnFolderId( + folders = state.folders, + showAllChatsFolder = showAllChatsFolder, + lastNonArchiveFolderId = state.lastNonArchiveFolderId, + ) + return if (state.selectedFolderId == -2 && targetFolderId != -2) { + ChatListPreviewMode.FolderPreview(targetFolderId) + } else { + ChatListPreviewMode.Active + } +} + +private fun isGenericSwipeBackSupported(child: RootComponent.Child): Boolean = + when (child) { + is RootComponent.Child.ChatDetailChild, + is RootComponent.Child.ProfileChild, + is RootComponent.Child.SettingsChild, + is RootComponent.Child.EditProfileChild, + is RootComponent.Child.SessionsChild, + is RootComponent.Child.FoldersChild, + is RootComponent.Child.ChatSettingsChild, + is RootComponent.Child.DataStorageChild, + is RootComponent.Child.StorageUsageChild, + is RootComponent.Child.NetworkUsageChild, + is RootComponent.Child.PremiumChild, + is RootComponent.Child.PrivacyChild, + is RootComponent.Child.AdBlockChild, + is RootComponent.Child.PowerSavingChild, + is RootComponent.Child.NotificationsChild, + is RootComponent.Child.ProxyChild, + is RootComponent.Child.ProfileLogsChild, + is RootComponent.Child.AdminManageChild, + is RootComponent.Child.ChatEditChild, + is RootComponent.Child.ContactEditChild, + is RootComponent.Child.MemberListChild, + is RootComponent.Child.ChatPermissionsChild, + is RootComponent.Child.StickersChild, + is RootComponent.Child.AboutChild, + is RootComponent.Child.NewChatChild, + is RootComponent.Child.DebugChild -> true + + else -> false + } diff --git a/app/src/test/java/org/monogram/app/IncomingShareIntentResolverTest.kt b/app/src/test/java/org/monogram/app/IncomingShareIntentResolverTest.kt new file mode 100644 index 00000000..cd2ce500 --- /dev/null +++ b/app/src/test/java/org/monogram/app/IncomingShareIntentResolverTest.kt @@ -0,0 +1,59 @@ +package org.monogram.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class IncomingShareIntentResolverTest { + @Test + fun `normalizeIncomingSharePayload returns text only share`() { + val payload = normalizeIncomingSharePayload( + IncomingSharePayloadInput( + action = "android.intent.action.SEND", + text = " hello " + ) + ) + + assertEquals("hello", payload?.text) + assertEquals(emptyList(), payload?.uriStrings) + } + + @Test + fun `normalizeIncomingSharePayload merges clipData and extra stream without duplicates`() { + val payload = normalizeIncomingSharePayload( + IncomingSharePayloadInput( + action = "android.intent.action.SEND_MULTIPLE", + text = "payload", + clipDataUris = listOf("content://share/1", "content://share/2"), + extraStreamUris = listOf("content://share/1", "content://share/2") + ) + ) + + assertEquals("payload", payload?.text) + assertEquals(listOf("content://share/1", "content://share/2"), payload?.uriStrings) + } + + @Test + fun `normalizeIncomingSharePayload returns null for unsupported intent`() { + val payload = normalizeIncomingSharePayload( + IncomingSharePayloadInput( + action = "android.intent.action.VIEW", + text = "ignored" + ) + ) + + assertNull(payload) + } + + @Test + fun `normalizeIncomingSharePayload returns null for empty share`() { + val payload = normalizeIncomingSharePayload( + IncomingSharePayloadInput( + action = "android.intent.action.SEND", + text = null + ) + ) + + assertNull(payload) + } +} diff --git a/app/src/test/java/org/monogram/app/components/SwipeBackResolverTest.kt b/app/src/test/java/org/monogram/app/components/SwipeBackResolverTest.kt new file mode 100644 index 00000000..a94555fe --- /dev/null +++ b/app/src/test/java/org/monogram/app/components/SwipeBackResolverTest.kt @@ -0,0 +1,231 @@ +package org.monogram.app.components + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.FolderModel +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.repository.ForwardTarget +import org.monogram.presentation.core.ui.ScreenSwipeBackAction +import org.monogram.presentation.core.ui.ScreenSwipeBackPreview +import org.monogram.presentation.core.ui.ScreenSwipeBackState +import org.monogram.presentation.core.util.AppPreferences +import org.monogram.presentation.features.chats.conversation.ChatComponent +import org.monogram.presentation.features.chats.list.ChatListComponent +import org.monogram.presentation.features.chats.list.ChatListPreviewMode +import org.monogram.presentation.root.RootComponent +import java.lang.reflect.Proxy + +class SwipeBackResolverTest { + @Test + fun `normal chat resolves to stack pop`() { + val resolution = resolveSwipeBack( + child = RootComponent.Child.ChatDetailChild(fakeChatComponent()), + screenSwipeBackState = ScreenSwipeBackState( + isSupported = true, + isBlocked = false, + action = ScreenSwipeBackAction.StackPop, + preview = ScreenSwipeBackPreview.PreviousStackEntry, + ), + ) + + assertTrue(resolution.isSupported) + assertFalse(resolution.isBlocked) + assertEquals(ScreenSwipeBackAction.StackPop, resolution.action) + assertEquals(SwipeBackPreviewDescriptor.PreviousStackEntry, resolution.preview) + } + + @Test + fun `topic chat resolves to local forum preview`() { + val resolution = resolveSwipeBack( + child = RootComponent.Child.ChatDetailChild(fakeChatComponent()), + screenSwipeBackState = ScreenSwipeBackState( + isSupported = true, + isBlocked = false, + action = ScreenSwipeBackAction.LocalChatTopicClose, + preview = ScreenSwipeBackPreview.ChatForumList, + ), + ) + + assertTrue(resolution.isSupported) + assertEquals(ScreenSwipeBackAction.LocalChatTopicClose, resolution.action) + assertEquals(SwipeBackPreviewDescriptor.ChatForumList, resolution.preview) + } + + @Test + fun `archive chat list resolves to local folder preview`() { + val resolution = resolveSwipeBack( + child = RootComponent.Child.ChatsChild(fakeChatListComponent()), + screenSwipeBackState = ScreenSwipeBackState( + isSupported = true, + isBlocked = false, + action = ScreenSwipeBackAction.LocalChatListArchiveReturn, + preview = ScreenSwipeBackPreview.ChatListFolder, + ), + archivePreviewFolderId = 7, + ) + + assertTrue(resolution.isSupported) + assertEquals(ScreenSwipeBackAction.LocalChatListArchiveReturn, resolution.action) + assertEquals(SwipeBackPreviewDescriptor.ChatListFolder(7), resolution.preview) + } + + @Test + fun `blocked state disables swipe`() { + val resolution = resolveSwipeBack( + child = RootComponent.Child.ChatDetailChild(fakeChatComponent()), + screenSwipeBackState = ScreenSwipeBackState( + isSupported = true, + isBlocked = true, + action = ScreenSwipeBackAction.StackPop, + preview = ScreenSwipeBackPreview.PreviousStackEntry, + ), + ) + + assertTrue(resolution.isSupported) + assertTrue(resolution.isBlocked) + } + + @Test + fun `archive preview mode uses remembered folder`() { + val previewMode = resolveArchivePreviewMode( + child = RootComponent.Child.ChatsChild( + fakeChatListComponent( + ChatListComponent.State( + folders = listOf( + FolderModel(id = -1, title = "All chats"), + FolderModel(id = 3, title = "Work"), + FolderModel(id = 7, title = "Family"), + ), + selectedFolderId = -2, + lastNonArchiveFolderId = 7, + ) + ) + ), + showAllChatsFolder = true, + ) + + assertEquals(ChatListPreviewMode.FolderPreview(7), previewMode) + } + + @Test + fun `blocked archive state disables swipe`() { + val resolution = resolveSwipeBack( + child = RootComponent.Child.ChatsChild( + fakeChatListComponent( + ChatListComponent.State( + folders = listOf( + FolderModel(id = -1, title = "All chats"), + FolderModel(id = 3, title = "Work"), + ), + selectedFolderId = -2, + lastNonArchiveFolderId = 3, + isSearchActive = true, + ) + ) + ), + screenSwipeBackState = ScreenSwipeBackState( + isSupported = true, + isBlocked = true, + action = ScreenSwipeBackAction.LocalChatListArchiveReturn, + preview = ScreenSwipeBackPreview.ChatListFolder, + ), + archivePreviewFolderId = 3, + ) + + assertTrue(resolution.isSupported) + assertTrue(resolution.isBlocked) + assertEquals(SwipeBackPreviewDescriptor.ChatListFolder(3), resolution.preview) + } + + private fun fakeChatComponent(): ChatComponent { + return Proxy.newProxyInstance( + ChatComponent::class.java.classLoader, + arrayOf(ChatComponent::class.java), + ) { _, _, _ -> + throw UnsupportedOperationException("Not used by resolver tests") + } as ChatComponent + } + + private fun fakeChatListComponent( + state: ChatListComponent.State = ChatListComponent.State() + ): ChatListComponent { + return object : ChatListComponent { + override val state: StateFlow = MutableStateFlow(state) + override val uiState: StateFlow = + MutableStateFlow(ChatListComponent.UiState()) + override val foldersState: StateFlow = + MutableStateFlow(ChatListComponent.FoldersState()) + override val chatsState: StateFlow = + MutableStateFlow(ChatListComponent.ChatsState()) + override val selectionState: StateFlow = + MutableStateFlow(ChatListComponent.SelectionState()) + override val searchState: StateFlow = + MutableStateFlow(ChatListComponent.SearchState()) + override val appPreferences: AppPreferences + get() = throw UnsupportedOperationException("Not used by resolver tests") + + override fun onChatClicked(id: Long) = unsupported() + override fun onProfileClicked(id: Long) = unsupported() + override fun onMessageClicked(chatId: Long, messageId: Long) = unsupported() + override fun onSettingsClicked() = unsupported() + override fun onFolderClicked(id: Int) = unsupported() + override fun loadMore(folderId: Int?) = unsupported() + override fun loadMoreMessages() = unsupported() + override fun onChatLongClicked(id: Long) = unsupported() + override fun clearSelection() = unsupported() + override fun retryConnection() = unsupported() + override fun onSearchToggle() = unsupported() + override fun onSearchQueryChange(query: String) = unsupported() + override fun onSetEmojiStatus(customEmojiId: Long, statusPath: String?) = unsupported() + override fun onClearSearchHistory() = unsupported() + override fun onRemoveSearchHistoryItem(chatId: Long) = unsupported() + override fun onMuteSelected(mute: Boolean) = unsupported() + override fun onArchiveSelected(archive: Boolean) = unsupported() + override fun onPinSelected() = unsupported() + override fun onToggleReadSelected() = unsupported() + override fun onDeleteSelected() = unsupported() + override fun onMarkCurrentFolderRead() = unsupported() + override fun onLeaveSelected() = unsupported() + override fun onClearHistorySelected(revoke: Boolean) = unsupported() + override fun onReportSelected(reason: String) = unsupported() + override fun onArchivePinToggle() = unsupported() + override fun onConfirmForwarding( + sendCopy: Boolean, + removeCaption: Boolean, + commentText: String, + commentEntities: List + ) = unsupported() + + override fun onShareTopicSelected(chatId: Long, topicId: Int?) = unsupported() + override fun onDismissShareTopicPicker() = unsupported() + override fun onForwardTopicSelected(chatId: Long, topicId: Int?) = unsupported() + override fun onDismissForwardTopicPicker() = unsupported() + override fun onRemoveForwardTarget(target: ForwardTarget) = unsupported() + override fun onNewChatClicked() = unsupported() + override fun onProxySettingsClicked() = unsupported() + override fun onEditFoldersClicked() = unsupported() + override fun onDeleteFolder(folderId: Int) = unsupported() + override fun onEditFolder(folderId: Int) = unsupported() + override fun onOpenInstantView(url: String) = unsupported() + override fun onDismissInstantView() = unsupported() + override fun onOpenWebApp(url: String, botUserId: Long, botName: String) = unsupported() + override fun onDismissWebApp() = unsupported() + override fun onOpenWebView(url: String) = unsupported() + override fun onDismissWebView() = unsupported() + override fun onUpdateClicked() = unsupported() + override fun onProjectChannelSubscribe() = unsupported() + override fun onProjectChannelLater() = unsupported() + override fun handleBack(): Boolean = unsupported() + override fun onVisibleChatIdsChanged(ids: List) = unsupported() + override fun updateScrollPosition(folderId: Int, index: Int, offset: Int) = + unsupported() + + private fun unsupported(): Nothing = + throw UnsupportedOperationException("Not used by resolver tests") + } + } +} diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts index f65cb4d4..912cf4d3 100644 --- a/baselineprofile/build.gradle.kts +++ b/baselineprofile/build.gradle.kts @@ -10,6 +10,8 @@ android { defaultConfig { minSdk = 25 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + missingDimensionStrategy("tdlib", "official") + missingDimensionStrategy("runtime", "libre") } compileOptions { diff --git a/core/src/main/java/org/monogram/core/perf/ChatOpenPerfBridge.kt b/core/src/main/java/org/monogram/core/perf/ChatOpenPerfBridge.kt new file mode 100644 index 00000000..72204996 --- /dev/null +++ b/core/src/main/java/org/monogram/core/perf/ChatOpenPerfBridge.kt @@ -0,0 +1,118 @@ +package org.monogram.core.perf + +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +data class ChatOpenPerfSessionSnapshot( + val sessionId: String, + val source: String, + val target: String, + val requestCount: Int, + val replyFetchCount: Int, + val persistCount: Int, + val persistSkippedCount: Int +) + +object ChatOpenPerfBridge { + private data class SessionRecord( + val sessionId: String, + var source: String, + var target: String, + var requestCount: Int = 0, + var replyFetchCount: Int = 0, + var persistCount: Int = 0, + var persistSkippedCount: Int = 0 + ) + + private val sessions = ConcurrentHashMap() + + fun startSession( + chatId: Long, + threadId: Long?, + source: String, + target: String + ): ChatOpenPerfSessionSnapshot { + val record = SessionRecord( + sessionId = UUID.randomUUID().toString().take(8), + source = source, + target = target + ) + sessions[key(chatId, threadId)] = record + return record.snapshot() + } + + fun updateTarget(chatId: Long, threadId: Long?, target: String): ChatOpenPerfSessionSnapshot? { + val record = findRecord(chatId, threadId) ?: return null + synchronized(record) { + record.target = target + return record.snapshot() + } + } + + fun currentSession(chatId: Long, threadId: Long?): ChatOpenPerfSessionSnapshot? { + val record = findRecord(chatId, threadId) ?: return null + synchronized(record) { + return record.snapshot() + } + } + + fun recordHistoryRequest(chatId: Long, threadId: Long?): ChatOpenPerfSessionSnapshot? { + val record = findRecord(chatId, threadId) ?: return null + synchronized(record) { + record.requestCount += 1 + return record.snapshot() + } + } + + fun recordReplyFetch(chatId: Long, threadId: Long?): ChatOpenPerfSessionSnapshot? { + val record = findRecord(chatId, threadId) ?: return null + synchronized(record) { + record.requestCount += 1 + record.replyFetchCount += 1 + return record.snapshot() + } + } + + fun recordPersist( + chatId: Long, + threadId: Long?, + persistedCount: Int, + skippedCount: Int + ): ChatOpenPerfSessionSnapshot? { + val record = findRecord(chatId, threadId) ?: return null + synchronized(record) { + record.persistCount += persistedCount + record.persistSkippedCount += skippedCount + return record.snapshot() + } + } + + fun clearSession(chatId: Long, threadId: Long?, sessionId: String? = null) { + val key = key(chatId, threadId) + val record = sessions[key] ?: return + if (sessionId == null || record.sessionId == sessionId) { + sessions.remove(key, record) + } + } + + private fun findRecord(chatId: Long, threadId: Long?): SessionRecord? { + sessions[key(chatId, threadId)]?.let { return it } + return sessions.entries.firstOrNull { entry -> + entry.key.startsWith("$chatId:") + }?.value + } + + private fun key(chatId: Long, threadId: Long?): String = "$chatId:${threadId ?: 0L}" + + private fun SessionRecord.snapshot(): ChatOpenPerfSessionSnapshot { + return ChatOpenPerfSessionSnapshot( + sessionId = sessionId, + source = source, + target = target, + requestCount = requestCount, + replyFetchCount = replyFetchCount, + persistCount = persistCount, + persistSkippedCount = persistSkippedCount + ) + } +} diff --git a/core/src/main/java/org/monogram/core/perf/ChatOpenPerfDebug.kt b/core/src/main/java/org/monogram/core/perf/ChatOpenPerfDebug.kt new file mode 100644 index 00000000..5eb733e5 --- /dev/null +++ b/core/src/main/java/org/monogram/core/perf/ChatOpenPerfDebug.kt @@ -0,0 +1,49 @@ +package org.monogram.core.perf + +object ChatOpenPerfDebug { + const val TAG = "ChatConversation" + const val STREAM_PERF = "perf" + + fun buildLogMessage( + chatId: Long, + threadId: Long?, + event: String, + componentInstanceId: String? = null, + uiInstanceId: String? = null, + source: String? = null, + target: String? = null, + anchorId: Long? = null, + messagesBefore: Int? = null, + messagesAfter: Int? = null, + unreadCount: Int? = null, + requestCount: Int? = null, + replyFetchCount: Int? = null, + persistCount: Int? = null, + persistSkippedCount: Int? = null, + durationMs: Long? = null + ): String { + val session = ChatOpenPerfBridge.currentSession(chatId, threadId) + return buildString { + append("stream=").append(STREAM_PERF) + append(" event=").append(event) + append(" component=").append(componentInstanceId ?: "none") + append(" ui=").append(uiInstanceId ?: "none") + append(" chatId=").append(chatId) + append(" threadId=").append(threadId ?: 0L) + append(" sessionId=").append(session?.sessionId ?: "none") + append(" source=").append(source ?: session?.source ?: "none") + append(" target=").append(target ?: session?.target ?: "none") + append(" messagesBefore=").append(messagesBefore ?: 0) + append(" messagesAfter=").append(messagesAfter ?: 0) + append(" anchorId=").append(anchorId ?: 0L) + append(" unreadCount=").append(unreadCount ?: 0) + append(" requestCount=").append(requestCount ?: session?.requestCount ?: 0) + append(" replyFetchCount=").append(replyFetchCount ?: session?.replyFetchCount ?: 0) + append(" persistCount=").append(persistCount ?: session?.persistCount ?: 0) + append(" persistSkippedCount=").append( + persistSkippedCount ?: session?.persistSkippedCount ?: 0 + ) + append(" durationMs=").append(durationMs ?: 0L) + } + } +} diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 901d08a5..97e07ab4 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -18,7 +18,7 @@ android { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") } - val localProperties: Properties by rootProject.extra + val localProperties = rootProject.extra["localProperties"] as Properties val apiId = localProperties.getProperty("API_ID", "0") val apiHash = localProperties.getProperty("API_HASH", "") diff --git a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt index 267deb36..1114f074 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -79,6 +79,8 @@ class ChatModelFactory( var inviteLink: String? = null var hasAutomaticTranslation = false var personalAvatarPath: String? = null + var signMessages = false + var joinToSendMessages = false val isArchived = chat.positions.any { it.list is TdApi.ChatListArchive } @@ -133,6 +135,8 @@ class ChatModelFactory( username = it.usernames?.activeUsernames?.firstOrNull() usernames = it.usernames?.toDomain() hasAutomaticTranslation = it.hasAutomaticTranslation + signMessages = it.signMessages + joinToSendMessages = it.joinToSendMessages } ?: lazyLoad(cache.pendingSupergroups, type.supergroupId) { if (type.supergroupId == 0L) return@lazyLoad val result = gateway.execute(TdApi.GetSupergroup(type.supergroupId)) @@ -344,7 +348,9 @@ class ChatModelFactory( description = description, inviteLink = inviteLink, hasAutomaticTranslation = hasAutomaticTranslation, - personalAvatarPath = personalAvatarPath + personalAvatarPath = personalAvatarPath, + signMessages = signMessages, + joinToSendMessages = joinToSendMessages ) } diff --git a/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt index a5d9c6a5..e18b237b 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt @@ -18,13 +18,36 @@ interface ChatLocalDataSource { suspend fun clearAll() fun getMessagesForChat(chatId: Long): Flow> - suspend fun getMessagesOlder(chatId: Long, fromMessageId: Long, limit: Int): List - suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int): List - suspend fun getMessagesAround(chatId: Long, messageId: Long, limit: Int): List - suspend fun getLatestMessages(chatId: Long, limit: Int): List + suspend fun getMessagesOlder( + chatId: Long, + fromMessageId: Long, + limit: Int, + threadId: Long? = null + ): List + + suspend fun getMessagesNewer( + chatId: Long, + fromMessageId: Long, + limit: Int, + threadId: Long? = null + ): List + + suspend fun getMessagesAround( + chatId: Long, + messageId: Long, + limit: Int, + threadId: Long? = null + ): List + + suspend fun getLatestMessages( + chatId: Long, + limit: Int, + threadId: Long? = null + ): List suspend fun getMessagesByIds(chatId: Long, messageIds: List): List suspend fun insertMessage(message: MessageEntity) suspend fun insertMessages(messages: List) + suspend fun replaceMessage(message: MessageEntity) suspend fun replaceMessageId(chatId: Long, oldMessageId: Long, message: MessageEntity) suspend fun markAsRead(chatId: Long, upToMessageId: Long) suspend fun updateMessageContent( diff --git a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt index f9af7931..fa9ad8c5 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt @@ -74,15 +74,25 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { messages.getOrPut(chatId) { MutableStateFlow(emptyMap()) } .map { it.values.sortedByDescending { msg -> msg.date } } - override suspend fun getMessagesOlder(chatId: Long, fromMessageId: Long, limit: Int): List { - val chatMessages = messages[chatId]?.value?.values ?: return emptyList() + override suspend fun getMessagesOlder( + chatId: Long, + fromMessageId: Long, + limit: Int, + threadId: Long? + ): List { + val chatMessages = scopedMessages(chatId, threadId) return chatMessages.filter { it.id < fromMessageId } .sortedByDescending { it.date } .take(limit) } - override suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int): List { - val chatMessages = messages[chatId]?.value?.values ?: return emptyList() + override suspend fun getMessagesNewer( + chatId: Long, + fromMessageId: Long, + limit: Int, + threadId: Long? + ): List { + val chatMessages = scopedMessages(chatId, threadId) return chatMessages.filter { it.id > fromMessageId } .sortedBy { it.date } .take(limit) @@ -91,9 +101,10 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { override suspend fun getMessagesAround( chatId: Long, messageId: Long, - limit: Int + limit: Int, + threadId: Long? ): List { - val chatMessages = messages[chatId]?.value?.values ?: return emptyList() + val chatMessages = scopedMessages(chatId, threadId) val olderLimit = (limit + 1) / 2 val newerLimit = limit - olderLimit val olderAndTarget = chatMessages.filter { it.id <= messageId } @@ -106,8 +117,12 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { .sortedByDescending { it.date } } - override suspend fun getLatestMessages(chatId: Long, limit: Int): List { - val chatMessages = messages[chatId]?.value?.values ?: return emptyList() + override suspend fun getLatestMessages( + chatId: Long, + limit: Int, + threadId: Long? + ): List { + val chatMessages = scopedMessages(chatId, threadId) return chatMessages.sortedByDescending { it.date }.take(limit) } @@ -128,6 +143,10 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { messages.forEach { insertMessage(it) } } + override suspend fun replaceMessage(message: MessageEntity) { + insertMessage(message) + } + override suspend fun replaceMessageId( chatId: Long, oldMessageId: Long, @@ -278,4 +297,9 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { flow.update { it.filterValues { msg -> msg.createdAt >= timestamp } } } } + + private fun scopedMessages(chatId: Long, threadId: Long?): List { + val chatMessages = messages[chatId]?.value?.values ?: return emptyList() + return chatMessages.filter { message -> message.threadId == threadId } + } } diff --git a/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt index 9bc10065..b1787e63 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt @@ -47,19 +47,59 @@ class RoomChatLocalDataSource( override fun getMessagesForChat(chatId: Long): Flow> = messageDao.getMessagesForChat(chatId) - override suspend fun getMessagesOlder(chatId: Long, fromMessageId: Long, limit: Int) = messageDao.getMessagesOlder(chatId, fromMessageId, limit) + override suspend fun getMessagesOlder( + chatId: Long, + fromMessageId: Long, + limit: Int, + threadId: Long? + ) = + if (threadId == null) { + messageDao.getMainChatMessagesOlder(chatId, fromMessageId, limit) + } else { + messageDao.getThreadMessagesOlder(chatId, threadId, fromMessageId, limit) + } - override suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int) = messageDao.getMessagesNewer(chatId, fromMessageId, limit) + override suspend fun getMessagesNewer( + chatId: Long, + fromMessageId: Long, + limit: Int, + threadId: Long? + ) = + if (threadId == null) { + messageDao.getMainChatMessagesNewer(chatId, fromMessageId, limit) + } else { + messageDao.getThreadMessagesNewer(chatId, threadId, fromMessageId, limit) + } - override suspend fun getMessagesAround(chatId: Long, messageId: Long, limit: Int) = - messageDao.getMessagesAround( - chatId = chatId, - messageId = messageId, - olderLimit = (limit + 1) / 2, - newerLimit = limit / 2 - ) + override suspend fun getMessagesAround( + chatId: Long, + messageId: Long, + limit: Int, + threadId: Long? + ) = + if (threadId == null) { + messageDao.getMainChatMessagesAround( + chatId = chatId, + messageId = messageId, + olderLimit = (limit + 1) / 2, + newerLimit = limit / 2 + ) + } else { + messageDao.getThreadMessagesAround( + chatId = chatId, + threadId = threadId, + messageId = messageId, + olderLimit = (limit + 1) / 2, + newerLimit = limit / 2 + ) + } - override suspend fun getLatestMessages(chatId: Long, limit: Int) = messageDao.getLatestMessages(chatId, limit) + override suspend fun getLatestMessages(chatId: Long, limit: Int, threadId: Long?) = + if (threadId == null) { + messageDao.getMainChatLatestMessages(chatId, limit) + } else { + messageDao.getThreadLatestMessages(chatId, threadId, limit) + } override suspend fun getMessagesByIds(chatId: Long, messageIds: List) = if (messageIds.isEmpty()) emptyList() else messageDao.getMessagesByIds(chatId, messageIds) @@ -68,6 +108,8 @@ class RoomChatLocalDataSource( override suspend fun insertMessages(messages: List) = messageDao.insertMessages(messages) + override suspend fun replaceMessage(message: MessageEntity) = messageDao.insertMessage(message) + override suspend fun replaceMessageId( chatId: Long, oldMessageId: Long, diff --git a/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt index 186bf783..448d96ef 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/ChatRemoteSource.kt @@ -18,9 +18,12 @@ interface ChatRemoteSource { suspend fun setChatDescription(chatId: Long, description: String) suspend fun setChatUsername(chatId: Long, username: String) suspend fun setChatPermissions(chatId: Long, permissions: ChatPermissionsModel) + suspend fun setChatHasProtectedContent(chatId: Long, hasProtectedContent: Boolean) + suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean) + suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) + suspend fun setChatAvailableReactions(chatId: Long, availableReactions: List) suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) - suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean) suspend fun getChatLink(chatId: Long): String? suspend fun deleteFolder(folderId: Int) suspend fun muteChat(chatId: Long, muteFor: Int) diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageFileApi.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageFileApi.kt index 2f7d000c..f7286243 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/MessageFileApi.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageFileApi.kt @@ -2,6 +2,7 @@ package org.monogram.data.datasource.remote interface MessageFileApi { fun registerFileForMessage(fileId: Int, chatId: Long, messageId: Long) + fun registerSponsoredFileForMessage(fileId: Int, chatId: Long, messageId: Long) fun enqueueDownload( fileId: Int, priority: Int = 1, diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageFileCoordinator.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageFileCoordinator.kt index c4f6f403..5188aeac 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/MessageFileCoordinator.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageFileCoordinator.kt @@ -11,6 +11,10 @@ class MessageFileCoordinator( fileDownloadQueue.registry.register(fileId, chatId, messageId) } + override fun registerSponsoredFileForMessage(fileId: Int, chatId: Long, messageId: Long) { + fileDownloadQueue.registry.registerSponsored(fileId, chatId, messageId) + } + override fun enqueueDownload( fileId: Int, priority: Int, diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt index 7c88bb7e..d8740094 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt @@ -45,6 +45,13 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { sendCopy: Boolean = false ) suspend fun getMessage(chatId: Long, messageId: Long): TdApi.Message? + suspend fun getChatSponsoredMessages(chatId: Long): TdApi.SponsoredMessages? + suspend fun clickChatSponsoredMessage( + chatId: Long, + messageId: Long, + isMediaClick: Boolean, + fromFullscreen: Boolean = false + ): TdApi.Ok? suspend fun getMessageThread(chatId: Long, messageId: Long): TdApi.MessageThreadInfo? suspend fun getMessages(chatId: Long, fromMessageId: Long, offset: Int, limit: Int, threadId: Long?): TdApi.Messages? suspend fun getChatHistory(chatId: Long, fromMessageId: Long, offset: Int, limit: Int): TdApi.Messages? @@ -252,6 +259,13 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { threadId: Long? = null ): OlderMessagesPage + suspend fun getRemoteMessagesOlder( + chatId: Long, + fromMessageId: Long, + limit: Int, + threadId: Long? = null + ): RemoteOlderMessagesPage + suspend fun getMessagesNewer( chatId: Long, fromMessageId: Long, @@ -259,6 +273,13 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { threadId: Long? = null ): List + suspend fun getRemoteMessagesNewer( + chatId: Long, + fromMessageId: Long, + limit: Int, + threadId: Long? = null + ): RemoteMessageBatch + suspend fun getMessagesAround( chatId: Long, messageId: Long, @@ -266,6 +287,13 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { threadId: Long? = null ): List + suspend fun getRemoteMessagesAround( + chatId: Long, + messageId: Long, + limit: Int, + threadId: Long? = null + ): RemoteMessageBatch + suspend fun getPinnedMessageModel(chatId: Long, threadId: Long? = null): MessageModel? suspend fun getAllPinnedMessages(chatId: Long, threadId: Long? = null): List suspend fun getPinnedMessageCount(chatId: Long, threadId: Long? = null): Int diff --git a/data/src/main/java/org/monogram/data/datasource/remote/RemoteMessageBatch.kt b/data/src/main/java/org/monogram/data/datasource/remote/RemoteMessageBatch.kt new file mode 100644 index 00000000..836fefb0 --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/remote/RemoteMessageBatch.kt @@ -0,0 +1,21 @@ +package org.monogram.data.datasource.remote + +import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.MessageModel + +data class MessageMapOptions( + val resolveReplyPreviewFromNetwork: Boolean = true, + val allowAutoDownload: Boolean = true +) + +data class RemoteMessageBatch( + val rawMessages: List, + val models: List +) + +data class RemoteOlderMessagesPage( + val rawMessages: List, + val models: List, + val reachedOldest: Boolean, + val isRemote: Boolean = true +) diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt index c433b64c..c416c060 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdChatRemoteDataSource.kt @@ -84,13 +84,7 @@ class TdChatRemoteSource( override suspend fun setChatDescription(chatId: Long, description: String) { coRunCatching { - val chat = gateway.execute(TdApi.GetChat(chatId)) - val groupId = when (val type = chat.type) { - is TdApi.ChatTypeSupergroup -> type.supergroupId - is TdApi.ChatTypeBasicGroup -> type.basicGroupId - else -> return - } - gateway.execute(TdApi.SetChatDescription(groupId, description)) + gateway.execute(TdApi.SetChatDescription(chatId, description)) } } @@ -108,31 +102,61 @@ class TdChatRemoteSource( } } - override suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) { + override suspend fun setChatHasProtectedContent(chatId: Long, hasProtectedContent: Boolean) { + coRunCatching { + gateway.execute(TdApi.ToggleChatHasProtectedContent(chatId, hasProtectedContent)) + } + } + + override suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean) { coRunCatching { val chat = gateway.execute(TdApi.GetChat(chatId)) - val supergroupId = (chat.type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: chatId - gateway.execute(TdApi.SetChatSlowModeDelay(supergroupId, slowModeDelay)) + val supergroupId = (chat.type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: return + gateway.execute(TdApi.ToggleSupergroupSignMessages(supergroupId, signMessages, true)) } } - override suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) { + override suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) { coRunCatching { val chat = gateway.execute(TdApi.GetChat(chatId)) - val type = chat.type as? TdApi.ChatTypeSupergroup ?: return - gateway.execute(TdApi.ToggleSupergroupIsForum(type.supergroupId, isForum, isForum)) + val supergroupId = (chat.type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: return + gateway.execute( + TdApi.ToggleSupergroupJoinToSendMessages( + supergroupId, + joinToSendMessages + ) + ) } } - override suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean) { + override suspend fun setChatAvailableReactions(chatId: Long, availableReactions: List) { coRunCatching { val chat = gateway.execute(TdApi.GetChat(chatId)) - if (chat.type is TdApi.ChatTypeSupergroup) { - val supergroupId = (chat.type as TdApi.ChatTypeSupergroup).supergroupId - gateway.execute(TdApi.ToggleSupergroupHasAutomaticTranslation(supergroupId, isTranslatable)) + val reactionTypes = + availableReactions.map { TdApi.ReactionTypeEmoji(it) }.toTypedArray() + val reactions = if (availableReactions.isEmpty()) { + TdApi.ChatAvailableReactionsAll() } else { - gateway.execute(TdApi.ToggleChatIsTranslatable(chatId, isTranslatable)) + TdApi.ChatAvailableReactionsSome(reactionTypes.map { it as TdApi.ReactionType } + .toTypedArray(), 3) } + gateway.execute(TdApi.SetChatAvailableReactions(chatId, reactions)) + } + } + + override suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) { + coRunCatching { + val chat = gateway.execute(TdApi.GetChat(chatId)) + val supergroupId = (chat.type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: chatId + gateway.execute(TdApi.SetChatSlowModeDelay(supergroupId, slowModeDelay)) + } + } + + override suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) { + coRunCatching { + val chat = gateway.execute(TdApi.GetChat(chatId)) + val type = chat.type as? TdApi.ChatTypeSupergroup ?: return + gateway.execute(TdApi.ToggleSupergroupIsForum(type.supergroupId, isForum, isForum)) } } diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt index e0c092c0..c129bad1 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt @@ -18,6 +18,9 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider +import org.monogram.core.perf.ChatOpenPerfBridge +import org.monogram.core.perf.ChatOpenPerfDebug +import org.monogram.data.BuildConfig import org.monogram.data.chats.ChatCache import org.monogram.data.compat.buildInputAnimation import org.monogram.data.compat.buildInputDocument @@ -116,8 +119,14 @@ class TdMessageRemoteDataSource( private val messageUpdateJobs = ConcurrentHashMap, Job>() private val lastProgressMap = ConcurrentHashMap() private val lastDownloadActiveMap = ConcurrentHashMap() + private val lastCancelledEmissionAt = ConcurrentHashMap() init { + fileDownloadQueue.setObserver(object : FileDownloadQueue.Observer { + override fun onDownloadCancelled(fileId: Int) { + emitCancelledForFile(fileId) + } + }) scope.launch { for (task in sendQueue) { try { @@ -160,6 +169,26 @@ class TdMessageRemoteDataSource( } } + override suspend fun getChatSponsoredMessages(chatId: Long): TdApi.SponsoredMessages? { + return safeExecute(TdApi.GetChatSponsoredMessages(chatId)) + } + + override suspend fun clickChatSponsoredMessage( + chatId: Long, + messageId: Long, + isMediaClick: Boolean, + fromFullscreen: Boolean + ): TdApi.Ok? { + return safeExecute( + TdApi.ClickChatSponsoredMessage( + chatId, + messageId, + isMediaClick, + fromFullscreen + ) + ) + } + suspend fun getChat(chatId: Long): TdApi.Chat? { cache.getChat(chatId)?.let { return it } val deferred = chatRequests.getOrPut(chatId) { @@ -180,17 +209,29 @@ class TdMessageRemoteDataSource( override suspend fun getMessagesOlder(chatId: Long, fromMessageId: Long, limit: Int, threadId: Long?): OlderMessagesPage { if (fromMessageId == 0L) { - val messages = loadMessages(chatId, fromMessageId, 0, limit, threadId) + val batch = loadMessageBatch( + chatId = chatId, + fromMessageId = fromMessageId, + offset = 0, + limit = limit, + threadId = threadId + ) val page = OlderMessagesPage( - messages = messages, - reachedOldest = messages.isEmpty(), + messages = batch.models, + reachedOldest = batch.models.isEmpty(), isRemote = true ) return page } - val messages = loadMessages(chatId, fromMessageId, 0, limit + 1, threadId) - val filtered = messages.filter { it.id != fromMessageId }.take(limit) + val batch = loadMessageBatch( + chatId = chatId, + fromMessageId = fromMessageId, + offset = 0, + limit = limit + 1, + threadId = threadId + ) + val filtered = batch.models.filter { it.id != fromMessageId }.take(limit) val page = OlderMessagesPage( messages = filtered, reachedOldest = filtered.isEmpty(), @@ -199,12 +240,96 @@ class TdMessageRemoteDataSource( return page } + override suspend fun getRemoteMessagesOlder( + chatId: Long, + fromMessageId: Long, + limit: Int, + threadId: Long? + ): RemoteOlderMessagesPage { + if (fromMessageId == 0L) { + val batch = loadMessageBatch( + chatId = chatId, + fromMessageId = fromMessageId, + offset = 0, + limit = limit, + threadId = threadId, + options = MessageMapOptions( + resolveReplyPreviewFromNetwork = false, + allowAutoDownload = false + ) + ) + return RemoteOlderMessagesPage( + rawMessages = batch.rawMessages, + models = batch.models, + reachedOldest = batch.models.isEmpty() + ) + } + + val batch = loadMessageBatch( + chatId = chatId, + fromMessageId = fromMessageId, + offset = 0, + limit = limit + 1, + threadId = threadId, + options = MessageMapOptions( + resolveReplyPreviewFromNetwork = false, + allowAutoDownload = false + ) + ) + val filteredPairs = batch.rawMessages.zip(batch.models) + .filter { (_, model) -> model.id != fromMessageId } + .take(limit) + return RemoteOlderMessagesPage( + rawMessages = filteredPairs.map { it.first }, + models = filteredPairs.map { it.second }, + reachedOldest = filteredPairs.isEmpty() + ) + } + override suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int, threadId: Long?): List { - return loadMessages(chatId, fromMessageId, -limit, limit, threadId) + return getRemoteMessagesNewer(chatId, fromMessageId, limit, threadId).models } override suspend fun getMessagesAround(chatId: Long, messageId: Long, limit: Int, threadId: Long?): List { - return loadMessages(chatId, messageId, -limit / 2, limit, threadId) + return getRemoteMessagesAround(chatId, messageId, limit, threadId).models + } + + override suspend fun getRemoteMessagesNewer( + chatId: Long, + fromMessageId: Long, + limit: Int, + threadId: Long? + ): RemoteMessageBatch { + return loadMessageBatch( + chatId = chatId, + fromMessageId = fromMessageId, + offset = -limit, + limit = limit, + threadId = threadId, + options = MessageMapOptions( + resolveReplyPreviewFromNetwork = false, + allowAutoDownload = false + ) + ) + } + + override suspend fun getRemoteMessagesAround( + chatId: Long, + messageId: Long, + limit: Int, + threadId: Long? + ): RemoteMessageBatch { + return loadMessageBatch( + chatId = chatId, + fromMessageId = messageId, + offset = -limit / 2, + limit = limit, + threadId = threadId, + options = MessageMapOptions( + resolveReplyPreviewFromNetwork = false, + allowAutoDownload = false + ) + ) } override suspend fun getChatPinnedMessage(chatId: Long): TdApi.Message? = @@ -505,7 +630,26 @@ class TdMessageRemoteDataSource( } } - private suspend fun loadMessages(chatId: Long, fromMessageId: Long, offset: Int, limit: Int, threadId: Long? = null): List = withContext(dispatcherProvider.io) { + private suspend fun loadMessageBatch( + chatId: Long, + fromMessageId: Long, + offset: Int, + limit: Int, + threadId: Long? = null, + options: MessageMapOptions = MessageMapOptions() + ): RemoteMessageBatch = withContext(dispatcherProvider.io) { + ChatOpenPerfBridge.recordHistoryRequest(chatId, threadId) + if (BuildConfig.DEBUG) { + Log.d( + ChatOpenPerfDebug.TAG, + ChatOpenPerfDebug.buildLogMessage( + chatId = chatId, + threadId = threadId, + event = "chat_history_request_start", + anchorId = fromMessageId + ) + ) + } val historyResult = getChatHistoryInternal(chatId, fromMessageId, offset, limit, threadId) ?: throw IllegalStateException( "Failed to load history for chatId=$chatId fromMessageId=$fromMessageId offset=$offset limit=$limit threadId=$threadId" @@ -518,11 +662,19 @@ class TdMessageRemoteDataSource( is TdApi.MessageThreadInfo -> historyResult.messages else -> emptyArray() } - messages.map { msg -> + val models = messages.map { msg -> cache.putMessage(msg) async { try { - withTimeout(5000) { messageMapper.mapMessageToModelSync(msg, lastReadInbox, lastReadOutbox, isChatOpen = true) } + withTimeout(5000) { + messageMapper.mapMessageToModelSync( + msg = msg, + inboxLimit = lastReadInbox, + outboxLimit = lastReadOutbox, + isChatOpen = true, + options = options + ) + } } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -531,6 +683,23 @@ class TdMessageRemoteDataSource( } } }.awaitAll() + RemoteMessageBatch( + rawMessages = messages.toList(), + models = models + ) + .also { + if (BuildConfig.DEBUG) { + Log.d( + ChatOpenPerfDebug.TAG, + ChatOpenPerfDebug.buildLogMessage( + chatId = chatId, + threadId = threadId, + event = "chat_history_request_end", + anchorId = fromMessageId + ) + ) + } + } } private suspend fun getChatHistoryInternal(chatId: Long, fromMessageId: Long, offset: Int, limit: Int, threadId: Long? = null): TdApi.Object? { @@ -1880,20 +2049,7 @@ class TdMessageRemoteDataSource( } else if (isCancelled) { lastProgressMap.remove(file.id) Log.d("DownloadDebug", "td.downloadCancelled.emit: fileId=${file.id}") - val entries = fileIdToMessageMap[file.id] - if (!entries.isNullOrEmpty()) { - scope.launch { - entries.forEach { (chatId, messageId) -> - messageDownloadFlow.emit( - MessageDownloadEvent.Cancelled( - chatId = chatId, - messageId = messageId, - fileId = file.id - ) - ) - } - } - } + emitCancelledForFile(file.id) } if (isUC) { @@ -1979,6 +2135,31 @@ class TdMessageRemoteDataSource( refreshJobs.values.forEach { it.cancel() }; refreshJobs.clear() messageUpdateJobs.values.forEach { it.cancel() }; messageUpdateJobs.clear() lastProgressMap.clear() + fileDownloadQueue.setObserver(null) + } + + private fun emitCancelledForFile(fileId: Int) { + if (fileId == 0) return + lastProgressMap.remove(fileId) + val now = System.currentTimeMillis() + val previous = lastCancelledEmissionAt[fileId] + if (previous != null && now - previous < 250L) return + lastCancelledEmissionAt[fileId] = now + + val entries = fileIdToMessageMap[fileId] + if (!entries.isNullOrEmpty()) { + scope.launch { + entries.forEach { (chatId, messageId) -> + messageDownloadFlow.emit( + MessageDownloadEvent.Cancelled( + chatId = chatId, + messageId = messageId, + fileId = fileId + ) + ) + } + } + } } private companion object { diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt index 0d74961b..fea29fad 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt @@ -67,6 +67,17 @@ class TdUserRemoteDataSource( } } + override suspend fun getCloseFriendIds(): LongArray = + coRunCatching { gateway.execute(TdApi.GetCloseFriends()).userIds }.getOrDefault(longArrayOf()) + + override suspend fun setCloseFriendIds(userIds: LongArray) { + gateway.execute(TdApi.SetCloseFriends(userIds)) + } + + override suspend fun sharePhoneNumber(userId: Long) { + gateway.execute(TdApi.SharePhoneNumber(userId)) + } + override suspend fun searchPublicChat(username: String): TdApi.Chat? = coRunCatching { gateway.execute(TdApi.SearchPublicChat(username)) }.getOrNull() @@ -127,6 +138,10 @@ class TdUserRemoteDataSource( override suspend fun getPremiumLimit(limitType: TdApi.PremiumLimitType): TdApi.PremiumLimit? = coRunCatching { gateway.execute(TdApi.GetPremiumLimit(limitType)) }.getOrNull() + override suspend fun setSponsoredMessagesEnabled(enabled: Boolean) { + coRunCatching { gateway.execute(TdApi.ToggleHasSponsoredMessagesEnabled(enabled)) } + } + override suspend fun getBotFullInfo(userId: Long): TdApi.UserFullInfo? { if (userId == 0L) return null return coRunCatching { gateway.execute(TdApi.GetUserFullInfo(userId)) }.getOrNull() diff --git a/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt index 59e8e4e1..51202f8a 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt @@ -16,6 +16,9 @@ interface UserRemoteDataSource { suspend fun searchContacts(query: String): TdApi.Users? suspend fun addContact(userId: Long, contact: TdApi.ImportedContact, sharePhoneNumber: Boolean) suspend fun removeContacts(userIds: LongArray) + suspend fun getCloseFriendIds(): LongArray + suspend fun setCloseFriendIds(userIds: LongArray) + suspend fun sharePhoneNumber(userId: Long) suspend fun searchPublicChat(username: String): TdApi.Chat? suspend fun getChatMember(chatId: Long, userId: Long): TdApi.ChatMember? suspend fun getSupergroupMembers( @@ -28,6 +31,7 @@ interface UserRemoteDataSource { suspend fun getPremiumState(): TdApi.PremiumState? suspend fun getPremiumFeatures(source: TdApi.PremiumSource): TdApi.PremiumFeatures? suspend fun getPremiumLimit(limitType: TdApi.PremiumLimitType): TdApi.PremiumLimit? + suspend fun setSponsoredMessagesEnabled(enabled: Boolean) suspend fun getBotFullInfo(userId: Long): TdApi.UserFullInfo? suspend fun getChatStatistics(chatId: Long, isDark: Boolean): TdApi.ChatStatistics? suspend fun getChatRevenueStatistics(chatId: Long, isDark: Boolean): TdApi.ChatRevenueStatistics? diff --git a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt index d21f2651..024c1cbc 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt @@ -60,7 +60,7 @@ import org.monogram.data.db.model.WallpaperEntity SponsorEntity::class, TextCompositionStyleEntity::class ], - version = 35, + version = 37, exportSchema = false ) abstract class MonogramDatabase : RoomDatabase() { diff --git a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt index fb5e1a5c..58cc0632 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt @@ -349,6 +349,52 @@ object MonogramMigrations { } } + val MIGRATION_35_36 = object : Migration(35, 36) { + override fun migrate(db: SupportSQLiteDatabase) { + db.addColumn("messages", "replyMarkupData", "TEXT") + db.addColumn("messages", "reactionsData", "TEXT") + db.addColumn("messages", "threadId", "INTEGER") + db.addColumn("messages", "viaBotUserId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "viaBotName", "TEXT") + db.addColumn("messages", "senderPersonalAvatarPath", "TEXT") + db.addColumn("messages", "senderCustomTitle", "TEXT") + db.addColumn("messages", "isSenderAdmin", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "isSenderVerified", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "isSenderPremium", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "senderStatusEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "senderStatusEmojiPath", "TEXT") + db.addColumn("messages", "canBeEdited", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "canBeForwarded", "INTEGER NOT NULL DEFAULT 1") + db.addColumn("messages", "canBeDeletedOnlyForSelf", "INTEGER NOT NULL DEFAULT 1") + db.addColumn("messages", "canBeDeletedForAllUsers", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "canBeSaved", "INTEGER NOT NULL DEFAULT 1") + db.addColumn("messages", "canGetMessageThread", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "canGetStatistics", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "canGetRevenueStatistics", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "canGetMediaStatistics", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "canGetReadReceipts", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "canGetViewers", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "readDate", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "hasUnreadMention", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "hasUnreadReactions", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "isPinned", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "containsUnreadPollVotes", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("messages", "suggestedPostInfoData", "TEXT") + db.addColumn("messages", "factCheckData", "TEXT") + } + } + + val MIGRATION_36_37 = object : Migration(36, 37) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_messages_chatId_threadId_date` ON `messages` (`chatId`, `threadId`, `date`)" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_messages_chatId_threadId_id` ON `messages` (`chatId`, `threadId`, `id`)" + ) + } + } + private fun SupportSQLiteDatabase.addColumn(table: String, column: String, definition: String) { execSQL("ALTER TABLE `$table` ADD COLUMN `$column` $definition") } diff --git a/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt b/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt index d2f6e189..47ef1c65 100644 --- a/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt +++ b/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt @@ -12,41 +12,112 @@ interface MessageDao { @Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY date DESC") fun getMessagesForChat(chatId: Long): Flow> - @Query("SELECT * FROM messages WHERE chatId = :chatId AND id < :fromMessageId ORDER BY date DESC LIMIT :limit") - suspend fun getMessagesOlder(chatId: Long, fromMessageId: Long, limit: Int): List + @Query( + "SELECT * FROM messages WHERE chatId = :chatId AND threadId IS NULL AND id < :fromMessageId ORDER BY date DESC LIMIT :limit" + ) + suspend fun getMainChatMessagesOlder( + chatId: Long, + fromMessageId: Long, + limit: Int + ): List - @Query("SELECT * FROM messages WHERE chatId = :chatId AND id > :fromMessageId ORDER BY date ASC LIMIT :limit") - suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int): List + @Query( + "SELECT * FROM messages WHERE chatId = :chatId AND threadId = :threadId AND id < :fromMessageId ORDER BY date DESC LIMIT :limit" + ) + suspend fun getThreadMessagesOlder( + chatId: Long, + threadId: Long, + fromMessageId: Long, + limit: Int + ): List + + @Query( + "SELECT * FROM messages WHERE chatId = :chatId AND threadId IS NULL AND id > :fromMessageId ORDER BY date ASC LIMIT :limit" + ) + suspend fun getMainChatMessagesNewer( + chatId: Long, + fromMessageId: Long, + limit: Int + ): List + + @Query( + "SELECT * FROM messages WHERE chatId = :chatId AND threadId = :threadId AND id > :fromMessageId ORDER BY date ASC LIMIT :limit" + ) + suspend fun getThreadMessagesNewer( + chatId: Long, + threadId: Long, + fromMessageId: Long, + limit: Int + ): List @Query( """ SELECT * FROM messages WHERE chatId = :chatId + AND threadId IS NULL AND id IN ( SELECT id FROM messages - WHERE chatId = :chatId AND id <= :messageId + WHERE chatId = :chatId AND threadId IS NULL AND id <= :messageId ORDER BY date DESC LIMIT :olderLimit ) UNION SELECT * FROM messages WHERE chatId = :chatId + AND threadId IS NULL AND id IN ( SELECT id FROM messages - WHERE chatId = :chatId AND id > :messageId + WHERE chatId = :chatId AND threadId IS NULL AND id > :messageId ORDER BY date ASC LIMIT :newerLimit ) ORDER BY date DESC """ ) - suspend fun getMessagesAround( + suspend fun getMainChatMessagesAround( chatId: Long, messageId: Long, olderLimit: Int, newerLimit: Int ): List - @Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY date DESC LIMIT :limit") - suspend fun getLatestMessages(chatId: Long, limit: Int): List + @Query( + """ + SELECT * FROM messages + WHERE chatId = :chatId + AND threadId = :threadId + AND id IN ( + SELECT id FROM messages + WHERE chatId = :chatId AND threadId = :threadId AND id <= :messageId + ORDER BY date DESC LIMIT :olderLimit + ) + UNION + SELECT * FROM messages + WHERE chatId = :chatId + AND threadId = :threadId + AND id IN ( + SELECT id FROM messages + WHERE chatId = :chatId AND threadId = :threadId AND id > :messageId + ORDER BY date ASC LIMIT :newerLimit + ) + ORDER BY date DESC + """ + ) + suspend fun getThreadMessagesAround( + chatId: Long, + threadId: Long, + messageId: Long, + olderLimit: Int, + newerLimit: Int + ): List + + @Query("SELECT * FROM messages WHERE chatId = :chatId AND threadId IS NULL ORDER BY date DESC LIMIT :limit") + suspend fun getMainChatLatestMessages(chatId: Long, limit: Int): List + + @Query("SELECT * FROM messages WHERE chatId = :chatId AND threadId = :threadId ORDER BY date DESC LIMIT :limit") + suspend fun getThreadLatestMessages( + chatId: Long, + threadId: Long, + limit: Int + ): List @Query("SELECT * FROM messages WHERE chatId = :chatId AND id IN (:messageIds)") suspend fun getMessagesByIds(chatId: Long, messageIds: List): List diff --git a/data/src/main/java/org/monogram/data/db/model/MessageEntity.kt b/data/src/main/java/org/monogram/data/db/model/MessageEntity.kt index a1b5cf25..fc6a6f2e 100644 --- a/data/src/main/java/org/monogram/data/db/model/MessageEntity.kt +++ b/data/src/main/java/org/monogram/data/db/model/MessageEntity.kt @@ -9,6 +9,8 @@ import androidx.room.Index indices = [ Index(value = ["chatId", "date"]), Index(value = ["chatId", "id"]), + Index(value = ["chatId", "threadId", "date"]), + Index(value = ["chatId", "threadId", "id"]), Index(value = ["createdAt"]) ] ) @@ -27,18 +29,48 @@ data class MessageEntity( val date: Int, val isOutgoing: Boolean, val isRead: Boolean, + val replyMarkupData: String? = null, + val reactionsData: String? = null, val replyToMessageId: Long = 0L, val replyToPreview: String? = null, val replyToPreviewType: String? = null, val replyToPreviewText: String? = null, val replyToPreviewSenderName: String? = null, val replyCount: Int = 0, + val threadId: Long? = null, + val viaBotUserId: Long = 0L, + val viaBotName: String? = null, + val senderPersonalAvatarPath: String? = null, + val senderCustomTitle: String? = null, + val isSenderAdmin: Boolean = false, + val isSenderVerified: Boolean = false, + val isSenderPremium: Boolean = false, + val senderStatusEmojiId: Long = 0L, + val senderStatusEmojiPath: String? = null, val forwardFromName: String? = null, val forwardFromId: Long = 0L, val forwardOriginChatId: Long? = null, val forwardOriginMessageId: Long? = null, val forwardDate: Int = 0, val editDate: Int = 0, + val canBeEdited: Boolean = false, + val canBeForwarded: Boolean = true, + val canBeDeletedOnlyForSelf: Boolean = true, + val canBeDeletedForAllUsers: Boolean = false, + val canBeSaved: Boolean = true, + val canGetMessageThread: Boolean = false, + val canGetStatistics: Boolean = false, + val canGetRevenueStatistics: Boolean = false, + val canGetMediaStatistics: Boolean = false, + val canGetReadReceipts: Boolean = false, + val canGetViewers: Boolean = false, + val readDate: Int = 0, + val hasUnreadMention: Boolean = false, + val hasUnreadReactions: Boolean = false, + val isPinned: Boolean = false, + val containsUnreadPollVotes: Boolean = false, + val suggestedPostInfoData: String? = null, + val factCheckData: String? = null, val mediaAlbumId: Long = 0L, val entities: String? = null, val viewCount: Int = 0, diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 00a5af9d..59418283 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -78,6 +78,7 @@ import org.monogram.data.mapper.ChatMapper import org.monogram.data.mapper.CustomEmojiLoader import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.NetworkMapper +import org.monogram.data.mapper.SponsoredMessageMapper import org.monogram.data.mapper.StorageMapper import org.monogram.data.mapper.TdFileHelper import org.monogram.data.mapper.WebPageMapper @@ -93,6 +94,7 @@ import org.monogram.data.repository.BotRepositoryImpl import org.monogram.data.repository.ChatInfoRepositoryImpl import org.monogram.data.repository.ChatStatisticsRepositoryImpl import org.monogram.data.repository.ChatsListRepositoryImpl +import org.monogram.data.repository.ContactEditRepositoryImpl import org.monogram.data.repository.DraftLinkPreviewResolver import org.monogram.data.repository.EmojiRepositoryImpl import org.monogram.data.repository.GifRepositoryImpl @@ -102,6 +104,7 @@ import org.monogram.data.repository.LocationRepositoryImpl import org.monogram.data.repository.MessageRepositoryImpl import org.monogram.data.repository.NetworkStatisticsRepositoryImpl import org.monogram.data.repository.NotificationSettingsRepositoryImpl +import org.monogram.data.repository.PinnedMessageVisibilityRepositoryImpl import org.monogram.data.repository.PollRepositoryImpl import org.monogram.data.repository.PremiumRepositoryImpl import org.monogram.data.repository.PrivacyRepositoryImpl @@ -131,6 +134,7 @@ import org.monogram.domain.repository.ChatOperationsRepository import org.monogram.domain.repository.ChatSearchRepository import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.domain.repository.ChatStatisticsRepository +import org.monogram.domain.repository.ContactEditRepository import org.monogram.domain.repository.EmojiRepository import org.monogram.domain.repository.FileRepository import org.monogram.domain.repository.ForumTopicsRepository @@ -143,6 +147,7 @@ import org.monogram.domain.repository.MessageRepository import org.monogram.domain.repository.NetworkStatisticsRepository import org.monogram.domain.repository.NotificationSettingsRepository import org.monogram.domain.repository.PaymentRepository +import org.monogram.domain.repository.PinnedMessageVisibilityRepository import org.monogram.domain.repository.PlayerDataSourceFactory import org.monogram.domain.repository.PollRepository import org.monogram.domain.repository.PremiumRepository @@ -271,7 +276,9 @@ val dataModule = module { MonogramMigrations.MIGRATION_31_32, MonogramMigrations.MIGRATION_32_33, MonogramMigrations.MIGRATION_33_34, - MonogramMigrations.MIGRATION_34_35 + MonogramMigrations.MIGRATION_34_35, + MonogramMigrations.MIGRATION_35_36, + MonogramMigrations.MIGRATION_36_37 ) .build() } @@ -341,6 +348,13 @@ val dataModule = module { ) } + single { + ContactEditRepositoryImpl( + userRepository = get(), + userRemoteDataSource = get() + ) + } + single { ProfilePhotoRepositoryImpl( remote = get(), @@ -653,6 +667,7 @@ val dataModule = module { messageRemoteDataSource = get(), cache = get(), fileHelper = get(), + sponsoredMessageMapper = get(), fileDataSource = get(), fxEmbedRemoteDataSource = get(), draftLinkPreviewResolver = get(), @@ -666,6 +681,19 @@ val dataModule = module { ) } + single { + SponsoredMessageMapper( + fileHelper = get(), + contentMapper = get() + ) + } + + single { + PinnedMessageVisibilityRepositoryImpl( + keyValueDao = get() + ) + } + single { get() } single { get() } single { get() } diff --git a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt index c2825403..0ab4bca7 100644 --- a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt +++ b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt @@ -29,6 +29,10 @@ class FileDownloadQueue( private val scope: CoroutineScope, private val dispatcherProvider: DispatcherProvider ) { + interface Observer { + fun onDownloadCancelled(fileId: Int) + } + enum class DownloadType { VIDEO, GIF, STICKER, VIDEO_NOTE, DEFAULT } private data class DownloadRequest( @@ -89,6 +93,9 @@ class FileDownloadQueue( private val trigger = Channel(Channel.CONFLATED) + @Volatile + private var observer: Observer? = null + init { scope.launch(dispatcherProvider.default) { while (isActive) { @@ -648,6 +655,10 @@ class FileDownloadQueue( } } + fun setObserver(observer: Observer?) { + this.observer = observer + } + fun clearSuppression(fileId: Int) { if (suppressedAutoDownloadIds.remove(fileId)) { Log.d("DownloadDebug", "queue.suppression.cleared: fileId=$fileId") @@ -671,6 +682,7 @@ class FileDownloadQueue( fun notifyDownloadCancelled(fileId: Int) { downloadWaiters.remove(fileId)?.cancel() + observer?.onDownloadCancelled(fileId) } fun notifyUploadComplete(fileId: Int) { @@ -683,6 +695,7 @@ class FileDownloadQueue( private fun isStillRelevant(fileId: Int): Boolean { if (manualDownloadIds.contains(fileId)) return true + if (registry.sponsoredFileIds.contains(fileId)) return true if (registry.standaloneFileIds.contains(fileId)) return true val type = fileDownloadTypes[fileId] if (type == DownloadType.STICKER || type == DownloadType.VIDEO_NOTE) return true diff --git a/data/src/main/java/org/monogram/data/infra/FileMessageRegistry.kt b/data/src/main/java/org/monogram/data/infra/FileMessageRegistry.kt index d2d9fe3b..dfab839e 100644 --- a/data/src/main/java/org/monogram/data/infra/FileMessageRegistry.kt +++ b/data/src/main/java/org/monogram/data/infra/FileMessageRegistry.kt @@ -6,6 +6,7 @@ class FileMessageRegistry { val fileIdToMessageMap = ConcurrentHashMap>>() private val messageToFileMap = ConcurrentHashMap, MutableSet>() val standaloneFileIds = ConcurrentHashMap.newKeySet() + val sponsoredFileIds = ConcurrentHashMap.newKeySet() fun register(fileId: Int, chatId: Long, messageId: Long) { val key = chatId to messageId @@ -13,13 +14,21 @@ class FileMessageRegistry { messageToFileMap.computeIfAbsent(key) { ConcurrentHashMap.newKeySet() }.add(fileId) } + fun registerSponsored(fileId: Int, chatId: Long, messageId: Long) { + register(fileId, chatId, messageId) + sponsoredFileIds.add(fileId) + } + fun removeMessages(chatId: Long, messageIds: List) { messageIds.forEach { messageId -> val key = chatId to messageId messageToFileMap.remove(key)?.forEach { fileId -> fileIdToMessageMap[fileId]?.let { set -> set.remove(key) - if (set.isEmpty()) fileIdToMessageMap.remove(fileId) + if (set.isEmpty()) { + fileIdToMessageMap.remove(fileId) + sponsoredFileIds.remove(fileId) + } } } } @@ -35,7 +44,10 @@ class FileMessageRegistry { fileIds.forEach { fileId -> fileIdToMessageMap[fileId]?.let { set -> set.remove(key) - if (set.isEmpty()) fileIdToMessageMap.remove(fileId) + if (set.isEmpty()) { + fileIdToMessageMap.remove(fileId) + sponsoredFileIds.remove(fileId) + } } } it.remove() diff --git a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt index ddbf1bb0..9891e905 100644 --- a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt +++ b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt @@ -76,7 +76,7 @@ class OfflineWarmup( userIds.add(chat.privateUserId) } chat.messageSenderId?.takeIf { it > 0 }?.let { userIds.add(it) } - messageDao.getLatestMessages(chat.id, 20) + messageDao.getMainChatLatestMessages(chat.id, 20) .asSequence() .map { it.senderId } .filter { it > 0L } @@ -205,7 +205,7 @@ class OfflineWarmup( private suspend fun warmupMessages(chats: List) { val targetChats = chats.take(30) for (chat in targetChats) { - val alreadyCachedCount = messageDao.getLatestMessages(chat.id, 25).size + val alreadyCachedCount = messageDao.getMainChatLatestMessages(chat.id, 25).size if (alreadyCachedCount >= 25) { continue } diff --git a/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt b/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt index ffb2eb19..0d79a8d0 100644 --- a/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt +++ b/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt @@ -6,6 +6,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi @@ -16,6 +19,7 @@ import org.monogram.data.gateway.TelegramGateway import org.monogram.data.mapper.updateSponsorIds import org.monogram.domain.repository.AuthRepository import org.monogram.domain.repository.AuthStep +import org.monogram.domain.repository.SponsorState import java.util.concurrent.atomic.AtomicBoolean private const val TAG = "SponsorSync" @@ -35,10 +39,13 @@ class SponsorSyncManager( private val scope: CoroutineScope, private val gateway: TelegramGateway, private val sponsorDao: SponsorDao, - private val authRepository: AuthRepository + private val authRepository: AuthRepository, + private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.IO ) { private val started = AtomicBoolean(false) private val syncInProgress = AtomicBoolean(false) + private val _sponsorState = MutableStateFlow(SponsorState()) + val sponsorState: StateFlow = _sponsorState.asStateFlow() @Volatile private var sponsorChatId = SPONSOR_CHANNEL_ID @@ -52,7 +59,7 @@ class SponsorSyncManager( fun start() { if (!started.compareAndSet(false, true)) return - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { loadFromDatabase() watchTelegramUpdates() @@ -86,7 +93,7 @@ class SponsorSyncManager( } fun forceSync() { - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { syncOnce(force = true, reason = "manual") } } @@ -94,6 +101,11 @@ class SponsorSyncManager( private suspend fun loadFromDatabase() { val cachedIds = sponsorDao.getAllIds().toSet() updateSponsorIds(cachedIds) + _sponsorState.value = _sponsorState.value.copy( + supporterIds = cachedIds, + supportersCount = cachedIds.size, + isLoaded = cachedIds.isNotEmpty() + ) Log.d(TAG, "Loaded ${cachedIds.size} sponsor ids from DB") } @@ -135,7 +147,7 @@ class SponsorSyncManager( } private fun watchTelegramUpdates() { - scope.launch(Dispatchers.IO) { + scope.launch(ioDispatcher) { gateway.updates.collect { update -> when (update) { is TdApi.UpdateConnectionState -> { @@ -174,7 +186,7 @@ class SponsorSyncManager( private fun requestEventSync(reason: String, force: Boolean) { eventSyncJob?.cancel() - eventSyncJob = scope.launch(Dispatchers.IO) { + eventSyncJob = scope.launch(ioDispatcher) { delay(EVENT_DEBOUNCE_MS) syncOnce(force = force, reason = reason) } @@ -184,6 +196,7 @@ class SponsorSyncManager( if (!syncInProgress.compareAndSet(false, true)) return SyncOutcome.BUSY try { + _sponsorState.value = _sponsorState.value.copy(isSyncInProgress = true) if (authRepository.authState.value !is AuthStep.Ready) { Log.d(TAG, "Skipping sponsor sync: user is not authorized") return SyncOutcome.SKIPPED @@ -233,6 +246,12 @@ class SponsorSyncManager( if (parsedIds.isEmpty()) { Log.w(TAG, "Parsed empty sponsor list, keeping existing ${oldIds.size} ids") updateSponsorIds(oldIds) + _sponsorState.value = _sponsorState.value.copy( + supporterIds = oldIds, + supportersCount = oldIds.size, + isLoaded = true, + lastSyncAt = System.currentTimeMillis() + ) return SyncOutcome.SUCCESS } @@ -247,6 +266,12 @@ class SponsorSyncManager( }) updateSponsorIds(actualIds) + _sponsorState.value = _sponsorState.value.copy( + supporterIds = actualIds, + supportersCount = actualIds.size, + isLoaded = true, + lastSyncAt = now + ) val added = actualIds - oldIds Log.d( @@ -260,6 +285,7 @@ class SponsorSyncManager( Log.e(TAG, "Sponsor sync failed", t) return SyncOutcome.FAILED } finally { + _sponsorState.value = _sponsorState.value.copy(isSyncInProgress = false) syncInProgress.set(false) } } diff --git a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt index 5369f192..bd59604d 100644 --- a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt @@ -4,6 +4,7 @@ import org.drinkless.tdlib.TdApi import org.monogram.core.date.DateFormatManager import org.monogram.data.compat.extractTextDraft import org.monogram.data.db.model.ChatEntity +import org.monogram.domain.models.ChatAvailableReactionsModel import org.monogram.domain.models.ChatModel import org.monogram.domain.models.ChatType import org.monogram.domain.models.MessageEntity @@ -69,10 +70,20 @@ class ChatMapper( description: String? = null, inviteLink: String? = null, hasAutomaticTranslation: Boolean = false, - personalAvatarPath: String? = null + personalAvatarPath: String? = null, + signMessages: Boolean = false, + joinToSendMessages: Boolean = false ): ChatModel { val permissions = chat.permissions.toDomainChatPermissions() val isChannel = chat.type.isChannelType() + val availableReactions = when (val reactions = chat.availableReactions) { + is TdApi.ChatAvailableReactionsSome -> ChatAvailableReactionsModel.Some( + reactions = reactions.reactions.mapNotNull { (it as? TdApi.ReactionTypeEmoji)?.emoji }, + maxReactionCount = reactions.maxReactionCount + ) + + else -> ChatAvailableReactionsModel.All + } val draftText = chat.draftMessage?.extractTextDraft() val draftEntities = emptyList() @@ -118,6 +129,9 @@ class ChatMapper( hasProtectedContent = chat.hasProtectedContent, isTranslatable = chat.isTranslatable, hasAutomaticTranslation = hasAutomaticTranslation, + signMessages = signMessages, + joinToSendMessages = joinToSendMessages, + availableReactions = availableReactions, messageAutoDeleteTime = chat.messageAutoDeleteTime, canBeDeletedOnlyForSelf = chat.canBeDeletedOnlyForSelf, canBeDeletedForAllUsers = chat.canBeDeletedForAllUsers, diff --git a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt index 5e07c7bc..d5033313 100644 --- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt @@ -1,5 +1,6 @@ package org.monogram.data.mapper +import android.util.Log import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -7,12 +8,18 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.drinkless.tdlib.TdApi +import org.monogram.core.perf.ChatOpenPerfBridge +import org.monogram.core.perf.ChatOpenPerfDebug +import org.monogram.data.BuildConfig import org.monogram.data.chats.ChatCache +import org.monogram.data.datasource.remote.MessageMapOptions import org.monogram.data.gateway.TelegramGateway import org.monogram.data.mapper.message.ContentMappingContext import org.monogram.data.mapper.message.MessageContentMapper import org.monogram.data.mapper.message.MessagePersistenceMapper import org.monogram.data.mapper.message.MessageSenderResolver +import org.monogram.data.mapper.message.toDomain +import org.monogram.domain.models.FactCheckModel import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.ForwardOriginType import org.monogram.domain.models.MessageContent @@ -20,6 +27,7 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageReactionModel import org.monogram.domain.models.MessageSendingState import org.monogram.domain.models.ReactionSender +import org.monogram.domain.models.SuggestedPostInfoModel import org.monogram.domain.repository.StringProvider import org.monogram.domain.repository.UserRepository @@ -47,7 +55,8 @@ class MessageMapper internal constructor( suspend fun mapMessageToModel( msg: TdApi.Message, isChatOpen: Boolean = false, - isReply: Boolean = false + isReply: Boolean = false, + options: MessageMapOptions = MessageMapOptions() ): MessageModel = coroutineScope { withTimeoutOrNull(MESSAGE_MAP_TIMEOUT_MS) { val sender = senderResolver.resolveSender(msg) @@ -55,16 +64,19 @@ class MessageMapper internal constructor( val (replyToMsgId, replyToMsg) = resolveReplyInfo( msg = msg, isChatOpen = isChatOpen, - isReply = isReply + isReply = isReply, + options = options ) val forwardInfo = resolveForwardInfo(msg) val views = msg.interactionInfo?.viewCount val replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0 val sendingState = resolveSendingState(msg) - val reactions = resolveReactions(msg, isReply, isChatOpen) + val reactions = resolveReactions(msg, isReply, isChatOpen, options) val threadId = resolveThreadId(msg) val viaBotName = resolveViaBotName(msg) + val factCheck = msg.factCheck?.toDomain() + val suggestedPostInfo = msg.suggestedPostInfo?.toDomain() createMessageModel( msg = msg, @@ -80,6 +92,7 @@ class MessageMapper internal constructor( mediaAlbumId = msg.mediaAlbumId, sendingState = sendingState, isChatOpen = isChatOpen, + mapOptions = options, readDate = 0, reactions = reactions, isSenderVerified = sender.isSenderVerified, @@ -93,9 +106,11 @@ class MessageMapper internal constructor( isSenderAdmin = sender.isSenderAdmin, isSenderPremium = sender.isSenderPremium, senderStatusEmojiId = sender.senderStatusEmojiId, - senderStatusEmojiPath = sender.senderStatusEmojiPath + senderStatusEmojiPath = sender.senderStatusEmojiPath, + factCheck = factCheck, + suggestedPostInfo = suggestedPostInfo ) - } ?: mapMessageToModelFallback(msg, isChatOpen, isReply) + } ?: mapMessageToModelFallback(msg, isChatOpen, isReply, options) } suspend fun getMessageReadDate(chatId: Long, messageId: Long, messageDate: Int): Int { @@ -126,10 +141,11 @@ class MessageMapper internal constructor( inboxLimit: Long, outboxLimit: Long, isChatOpen: Boolean = false, - isReply: Boolean = false + isReply: Boolean = false, + options: MessageMapOptions = MessageMapOptions() ): MessageModel { val isRead = if (msg.isOutgoing) msg.id <= outboxLimit else msg.id <= inboxLimit - val baseModel = mapMessageToModel(msg, isChatOpen, isReply) + val baseModel = mapMessageToModel(msg, isChatOpen, isReply, options) return baseModel.copy(isRead = isRead) } @@ -147,6 +163,7 @@ class MessageMapper internal constructor( mediaAlbumId: Long = 0L, sendingState: MessageSendingState? = null, isChatOpen: Boolean = false, + mapOptions: MessageMapOptions = MessageMapOptions(), readDate: Int = 0, reactions: List = emptyList(), isSenderVerified: Boolean = false, @@ -160,9 +177,13 @@ class MessageMapper internal constructor( isSenderAdmin: Boolean = false, isSenderPremium: Boolean = false, senderStatusEmojiId: Long = 0L, - senderStatusEmojiPath: String? = null + senderStatusEmojiPath: String? = null, + factCheck: FactCheckModel? = null, + suggestedPostInfo: SuggestedPostInfoModel? = null ): MessageModel { - val networkAutoDownload = isChatOpen && fileHelper.isNetworkAutoDownloadEnabled() + val networkAutoDownload = isChatOpen && + mapOptions.allowAutoDownload && + fileHelper.isNetworkAutoDownloadEnabled() val isActuallyUploading = msg.sendingState is TdApi.MessageSendingStatePending val content = contentMapper.mapContent( @@ -223,7 +244,13 @@ class MessageMapper internal constructor( viaBotName = viaBotName, isSenderPremium = isSenderPremium, senderStatusEmojiId = senderStatusEmojiId, - senderStatusEmojiPath = senderStatusEmojiPath + senderStatusEmojiPath = senderStatusEmojiPath, + isPinned = msg.isPinned, + hasUnreadMention = msg.containsUnreadMention, + hasUnreadReactions = !msg.unreadReactions.isNullOrEmpty(), + containsUnreadPollVotes = msg.containsUnreadPollVotes, + factCheck = factCheck, + suggestedPostInfo = suggestedPostInfo ) } @@ -245,15 +272,40 @@ class MessageMapper internal constructor( private suspend fun resolveReplyInfo( msg: TdApi.Message, isChatOpen: Boolean, - isReply: Boolean + isReply: Boolean, + options: MessageMapOptions ): Pair { if (isReply || msg.replyTo == null) return null to null val replyTo = msg.replyTo if (replyTo !is TdApi.MessageReplyToMessage) return null to null val replyToMsgId = replyTo.messageId + if (!options.resolveReplyPreviewFromNetwork) { + val cachedReply = cache.getMessage(msg.chatId, replyToMsgId) + val cachedReplyModel = cachedReply?.let { + mapMessageToModel( + msg = it, + isChatOpen = false, + isReply = true, + options = options + ).copy(replyToMsg = null, replyToMsgId = null) + } + return replyToMsgId to cachedReplyModel + } val repliedMessage = try { withTimeout(500) { + ChatOpenPerfBridge.recordReplyFetch(msg.chatId, resolveThreadId(msg)) + if (BuildConfig.DEBUG) { + Log.d( + ChatOpenPerfDebug.TAG, + ChatOpenPerfDebug.buildLogMessage( + chatId = msg.chatId, + threadId = resolveThreadId(msg), + event = "chat_reply_fetch", + anchorId = replyToMsgId + ) + ) + } cache.getMessage(msg.chatId, replyToMsgId) ?: gateway.execute(TdApi.GetMessage(msg.chatId, replyToMsgId)).also { cache.putMessage(it) } } @@ -265,7 +317,8 @@ class MessageMapper internal constructor( mapMessageToModel( msg = it, isChatOpen = isChatOpen, - isReply = true + isReply = true, + options = options ).copy(replyToMsg = null, replyToMsgId = null) } @@ -365,7 +418,8 @@ class MessageMapper internal constructor( private suspend fun resolveReactions( msg: TdApi.Message, isReply: Boolean, - isChatOpen: Boolean + isChatOpen: Boolean, + options: MessageMapOptions ): List { if (isReply) return emptyList() val reactionItems = msg.interactionInfo?.reactions?.reactions ?: return emptyList() @@ -442,7 +496,9 @@ class MessageMapper internal constructor( emojiId = emojiId, chatId = msg.chatId, messageId = msg.id, - autoDownload = isChatOpen && fileHelper.isNetworkAutoDownloadEnabled() + autoDownload = isChatOpen && + options.allowAutoDownload && + fileHelper.isNetworkAutoDownloadEnabled() ) path = customEmojiLoader.getPathIfValid(emojiId) } @@ -492,7 +548,8 @@ class MessageMapper internal constructor( private fun mapMessageToModelFallback( msg: TdApi.Message, isChatOpen: Boolean, - isReply: Boolean + isReply: Boolean, + options: MessageMapOptions ): MessageModel { val sender = senderResolver.resolveFallbackSender(msg) return createMessageModel( @@ -501,6 +558,7 @@ class MessageMapper internal constructor( senderId = sender.senderId, senderAvatar = sender.senderAvatar, isChatOpen = isChatOpen, + mapOptions = options, isReply = isReply, senderPersonalAvatar = sender.senderPersonalAvatar, senderCustomTitle = sender.senderCustomTitle, @@ -515,4 +573,4 @@ class MessageMapper internal constructor( private companion object { private const val MESSAGE_MAP_TIMEOUT_MS = 2500L } -} \ No newline at end of file +} diff --git a/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt b/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt index c173a813..c3e78871 100644 --- a/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt @@ -1,7 +1,14 @@ package org.monogram.data.mapper import org.drinkless.tdlib.TdApi -import org.monogram.domain.models.* +import org.monogram.data.mapper.message.ReplyMarkupPayload +import org.monogram.domain.models.InlineKeyboardButtonModel +import org.monogram.domain.models.InlineKeyboardButtonType +import org.monogram.domain.models.KeyboardButtonModel +import org.monogram.domain.models.KeyboardButtonType +import org.monogram.domain.models.ReplyMarkupModel +import org.monogram.data.mapper.message.toDomain as payloadToDomain +import org.monogram.data.mapper.message.toPayload as domainToPayload internal fun TdApi.ReplyMarkup?.toDomainReplyMarkup(): ReplyMarkupModel? { return when (this) { @@ -69,4 +76,8 @@ internal fun TdApi.ReplyMarkup?.toDomainReplyMarkup(): ReplyMarkupModel? { is TdApi.ReplyMarkupForceReply -> ReplyMarkupModel.ForceReply(isPersonal, inputFieldPlaceholder) else -> null } -} \ No newline at end of file +} + +internal fun ReplyMarkupModel.toPayload(): ReplyMarkupPayload = domainToPayload() + +internal fun ReplyMarkupPayload.toDomainReplyMarkup(): ReplyMarkupModel = payloadToDomain() diff --git a/data/src/main/java/org/monogram/data/mapper/SponsoredMessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/SponsoredMessageMapper.kt new file mode 100644 index 00000000..95976eac --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/SponsoredMessageMapper.kt @@ -0,0 +1,106 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.data.mapper.message.ContentMappingContext +import org.monogram.data.mapper.message.MessageContentMapper +import org.monogram.domain.models.AdvertisementSponsorModel +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.SponsoredMessageModel + +internal class SponsoredMessageMapper( + private val fileHelper: TdFileHelper, + private val contentMapper: MessageContentMapper +) { + fun map(chatId: Long, message: TdApi.SponsoredMessage): SponsoredMessageModel { + val sponsor = message.sponsor + val mappedContent = mapContent(chatId, message) + val sponsorPhotoPath = sponsor.photo + ?.sizes + ?.asSequence() + ?.map { it.photo } + ?.mapNotNull(fileHelper::resolveLocalFilePath) + ?.firstOrNull() + return SponsoredMessageModel( + messageId = message.messageId, + isRecommended = message.isRecommended, + canBeReported = message.canBeReported, + title = message.title.ifBlank { null }, + buttonText = message.buttonText.ifBlank { null }, + additionalInfo = message.additionalInfo.ifBlank { null }, + accentColorId = message.accentColorId, + backgroundCustomEmojiId = message.backgroundCustomEmojiId, + content = mappedContent, + sponsor = AdvertisementSponsorModel( + url = sponsor.url, + photoPath = sponsorPhotoPath, + info = sponsor.info.ifBlank { null } + ) + ) + } + + private fun mapContent(chatId: Long, message: TdApi.SponsoredMessage): MessageContent { + val tdMessage = TdApi.Message().apply { + id = message.messageId + this.chatId = chatId + date = 0 + isOutgoing = false + content = message.content + } + val mappedContent = contentMapper.mapContent( + tdMessage, + ContentMappingContext( + chatId = chatId, + messageId = message.messageId, + senderId = 0L, + senderName = message.title.orEmpty(), + networkAutoDownload = true, + isActuallyUploading = false + ) + ) + enqueueSponsoredMediaDownload(chatId, message.messageId, mappedContent) + return mappedContent + } + + private fun enqueueSponsoredMediaDownload( + chatId: Long, + messageId: Long, + content: MessageContent + ) { + when (content) { + is MessageContent.Photo -> { + if (content.path.isNullOrBlank() && content.fileId != 0) { + fileHelper.registerSponsoredCachedFile(content.fileId, chatId, messageId) + fileHelper.enqueueDownload( + fileId = content.fileId, + priority = 16, + downloadType = org.monogram.data.datasource.remote.TdMessageRemoteDataSource.DownloadType.DEFAULT + ) + } + } + + is MessageContent.Video -> { + if (content.path.isNullOrBlank() && content.fileId != 0 && !content.supportsStreaming) { + fileHelper.registerSponsoredCachedFile(content.fileId, chatId, messageId) + fileHelper.enqueueDownload( + fileId = content.fileId, + priority = 16, + downloadType = org.monogram.data.datasource.remote.TdMessageRemoteDataSource.DownloadType.VIDEO + ) + } + } + + is MessageContent.Gif -> { + if (content.path.isNullOrBlank() && content.fileId != 0) { + fileHelper.registerSponsoredCachedFile(content.fileId, chatId, messageId) + fileHelper.enqueueDownload( + fileId = content.fileId, + priority = 16, + downloadType = org.monogram.data.datasource.remote.TdMessageRemoteDataSource.DownloadType.GIF + ) + } + } + + else -> Unit + } + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt b/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt index 7dcf0c6e..1ad17b70 100644 --- a/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt +++ b/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt @@ -64,6 +64,12 @@ class TdFileHelper( } } + fun registerSponsoredCachedFile(fileId: Int, chatId: Long, messageId: Long) { + if (fileId != 0) { + fileApi.registerSponsoredFileForMessage(fileId, chatId, messageId) + } + } + fun enqueueDownload( fileId: Int, priority: Int, diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt index 665f7fd3..eab2c0a5 100644 --- a/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt @@ -4,14 +4,18 @@ import org.drinkless.tdlib.TdApi import org.monogram.data.chats.ChatCache import org.monogram.data.mapper.SenderNameResolver import org.monogram.data.mapper.TdFileHelper +import org.monogram.data.mapper.toDomainReplyMarkup import org.monogram.data.mapper.toPageBlock +import org.monogram.data.mapper.toPayload import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.ForwardOriginType import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageEntityType import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageReactionModel import org.monogram.domain.models.PollType +import org.monogram.domain.models.ReactionSender import org.monogram.domain.models.webapp.PageBlock import org.monogram.domain.models.webapp.RichText import org.monogram.domain.repository.StringProvider @@ -91,6 +95,29 @@ internal class MessagePersistenceMapper( val replyToMessageId = (msg.replyTo as? TdApi.MessageReplyToMessage)?.messageId ?: 0L val replyToPreview = buildReplyPreview(msg) val forwardOrigin = msg.forwardInfo?.origin?.let(::extractForwardOrigin) + val cachedSenderUser = senderId.takeIf { it > 0L }?.let(cache::getUser) + val cachedSenderFullInfo = senderId.takeIf { it > 0L }?.let(cache::getUserFullInfo) + val senderPersonalAvatarPath = cachedSenderFullInfo + ?.personalPhoto + ?.sizes + ?.firstOrNull() + ?.photo + ?.let(fileHelper::resolveLocalFilePath) + val senderStatusEmojiId = when (val type = cachedSenderUser?.emojiStatus?.type) { + is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId + is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId + else -> 0L + } + val senderStatusEmojiPath: String? = null + val viaBotName = msg.viaBotUserId + .takeIf { it != 0L } + ?.let(cache::getUser) + ?.let { user -> + user.usernames?.editableUsername ?: user.firstName?.takeIf { it.isNotBlank() } + } + val isServiceMessage = content.type == "service" + val hasInteraction = msg.interactionInfo != null + val reactions = resolveReactions(msg) return MessageDbEntity( id = msg.id, @@ -107,18 +134,53 @@ internal class MessagePersistenceMapper( date = resolveMessageDate(msg), isOutgoing = msg.isOutgoing, isRead = false, + replyMarkupData = msg.replyMarkup + ?.toDomainReplyMarkup() + ?.toPayload() + ?.encode(), + reactionsData = reactions + ?.takeIf { it.reactions.isNotEmpty() } + ?.encode(), replyToMessageId = replyToMessageId, replyToPreview = replyToPreview?.let(::encodeReplyPreview), replyToPreviewType = replyToPreview?.contentType, replyToPreviewText = replyToPreview?.text, replyToPreviewSenderName = replyToPreview?.senderName, replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0, + threadId = resolveThreadId(msg), + viaBotUserId = msg.viaBotUserId, + viaBotName = viaBotName, + senderPersonalAvatarPath = senderPersonalAvatarPath, + senderCustomTitle = null, + isSenderAdmin = false, + isSenderVerified = cachedSenderUser?.verificationStatus?.isVerified ?: false, + isSenderPremium = cachedSenderUser?.isPremium ?: false, + senderStatusEmojiId = senderStatusEmojiId, + senderStatusEmojiPath = senderStatusEmojiPath, forwardFromName = forwardOrigin?.fromName, forwardFromId = forwardOrigin?.fromId ?: 0L, forwardOriginChatId = forwardOrigin?.originChatId, forwardOriginMessageId = forwardOrigin?.originMessageId, forwardDate = msg.forwardInfo?.date ?: 0, editDate = msg.editDate, + canBeEdited = msg.isOutgoing && !isServiceMessage, + canBeForwarded = !isServiceMessage, + canBeDeletedOnlyForSelf = true, + canBeDeletedForAllUsers = msg.isOutgoing, + canBeSaved = msg.canBeSaved, + canGetMessageThread = msg.interactionInfo?.replyInfo != null, + canGetStatistics = hasInteraction, + canGetRevenueStatistics = false, + canGetMediaStatistics = false, + canGetReadReceipts = hasInteraction, + canGetViewers = hasInteraction, + readDate = 0, + hasUnreadMention = msg.containsUnreadMention, + hasUnreadReactions = !msg.unreadReactions.isNullOrEmpty(), + isPinned = msg.isPinned, + containsUnreadPollVotes = msg.containsUnreadPollVotes, + suggestedPostInfoData = msg.suggestedPostInfo?.toDomain()?.toPayload()?.encode(), + factCheckData = msg.factCheck?.toDomain()?.toPayload()?.encode(), mediaAlbumId = msg.mediaAlbumId, entities = entitiesEncoded, viewCount = msg.interactionInfo?.viewCount ?: 0, @@ -339,6 +401,11 @@ internal class MessagePersistenceMapper( val meta = decodeMeta(entity.contentMeta) val mediaFileId = entity.mediaFileId.takeIf { it != 0 } ?: 0 entity.mediaPath?.takeIf { it.isNotBlank() } + val replyMarkup = decodeReplyMarkupPayload(entity.replyMarkupData)?.toDomain() + val reactions = decodeReactionsPayload(entity.reactionsData)?.toDomain().orEmpty() + val factCheck = decodeFactCheckPayload(entity.factCheckData)?.toDomain() + val suggestedPostInfo = + decodeSuggestedPostInfoPayload(entity.suggestedPostInfoData)?.toDomain() val replyToMsgId = entity.replyToMessageId.takeIf { it != 0L } val replyPreview = resolveReplyPreview(entity) val replyPreviewModel = @@ -361,17 +428,19 @@ internal class MessagePersistenceMapper( cachedSenderChat != null -> fileHelper.resolveLocalFilePath(cachedSenderChat.photo?.small) else -> null } - val resolvedSenderPersonalAvatar = cache.getUserFullInfo(entity.senderId) - ?.personalPhoto - ?.sizes - ?.firstOrNull() - ?.photo - ?.let { fileHelper.resolveLocalFilePath(it) } + val resolvedSenderPersonalAvatar = entity.senderPersonalAvatarPath + ?.takeIf { it.isNotBlank() } + ?: cache.getUserFullInfo(entity.senderId) + ?.personalPhoto + ?.sizes + ?.firstOrNull() + ?.photo + ?.let { fileHelper.resolveLocalFilePath(it) } val senderStatusEmojiId = when (val type = cachedSenderUser?.emojiStatus?.type) { is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId - else -> 0L + else -> entity.senderStatusEmojiId } val forwardInfo = entity.forwardFromName @@ -623,6 +692,8 @@ internal class MessagePersistenceMapper( senderId = entity.senderId, senderAvatar = resolvedSenderAvatar, senderPersonalAvatar = resolvedSenderPersonalAvatar, + senderCustomTitle = entity.senderCustomTitle, + isSenderAdmin = entity.isSenderAdmin, isRead = entity.isRead, replyToMsgId = replyToMsgId, replyToMsg = replyPreviewModel, @@ -631,10 +702,34 @@ internal class MessagePersistenceMapper( editDate = entity.editDate, views = entity.viewCount, viewCount = entity.viewCount, + readDate = entity.readDate, + reactions = reactions, replyCount = entity.replyCount, - isSenderVerified = cachedSenderUser?.verificationStatus?.isVerified ?: false, - isSenderPremium = cachedSenderUser?.isPremium ?: false, - senderStatusEmojiId = senderStatusEmojiId + isSenderVerified = entity.isSenderVerified || (cachedSenderUser?.verificationStatus?.isVerified == true), + threadId = entity.threadId, + canBeEdited = entity.canBeEdited, + canBeForwarded = entity.canBeForwarded, + canBeDeletedOnlyForSelf = entity.canBeDeletedOnlyForSelf, + canBeDeletedForAllUsers = entity.canBeDeletedForAllUsers, + canBeSaved = entity.canBeSaved, + canGetMessageThread = entity.canGetMessageThread, + canGetStatistics = entity.canGetStatistics, + canGetRevenueStatistics = entity.canGetRevenueStatistics, + canGetMediaStatistics = entity.canGetMediaStatistics, + canGetReadReceipts = entity.canGetReadReceipts, + canGetViewers = entity.canGetViewers, + replyMarkup = replyMarkup, + viaBotUserId = entity.viaBotUserId, + viaBotName = entity.viaBotName, + isSenderPremium = entity.isSenderPremium || (cachedSenderUser?.isPremium == true), + senderStatusEmojiId = senderStatusEmojiId, + senderStatusEmojiPath = entity.senderStatusEmojiPath, + isPinned = entity.isPinned, + hasUnreadMention = entity.hasUnreadMention, + hasUnreadReactions = entity.hasUnreadReactions, + containsUnreadPollVotes = entity.containsUnreadPollVotes, + factCheck = factCheck, + suggestedPostInfo = suggestedPostInfo ) } @@ -914,6 +1009,65 @@ internal class MessagePersistenceMapper( } } + private fun resolveReactions(msg: TdApi.Message): ReactionsPayload? { + val reactions = msg.interactionInfo?.reactions?.reactions.orEmpty().mapNotNull { reaction -> + val recentSenders = reaction.recentSenderIds.orEmpty().map { sender -> + when (sender) { + is TdApi.MessageSenderUser -> { + val user = cache.getUser(sender.userId) + ReactionSender( + id = sender.userId, + name = SenderNameResolver.fromPartsOrBlank( + user?.firstName, + user?.lastName + ), + avatar = user?.profilePhoto?.small?.let(fileHelper::resolveLocalFilePath) + ) + } + + is TdApi.MessageSenderChat -> { + val chat = cache.getChat(sender.chatId) + ReactionSender( + id = sender.chatId, + name = chat?.title.orEmpty(), + avatar = chat?.photo?.small?.let(fileHelper::resolveLocalFilePath) + ) + } + + else -> ReactionSender(0L) + } + } + + when (val type = reaction.type) { + is TdApi.ReactionTypeEmoji -> MessageReactionModel( + emoji = type.emoji, + count = reaction.totalCount, + isChosen = reaction.isChosen, + recentSenders = recentSenders + ) + + is TdApi.ReactionTypeCustomEmoji -> MessageReactionModel( + customEmojiId = type.customEmojiId, + count = reaction.totalCount, + isChosen = reaction.isChosen, + recentSenders = recentSenders + ) + + else -> null + } + } + + return reactions.takeIf { it.isNotEmpty() }?.toPayload() + } + + private fun resolveThreadId(msg: TdApi.Message): Long? { + return when (val topic = msg.topicId) { + is TdApi.MessageTopicForum -> topic.forumTopicId.toLong() + is TdApi.MessageTopicThread -> topic.messageThreadId + else -> null + } + } + private fun encodeEntities(content: TdApi.MessageContent): String? { val formatted = when (content) { is TdApi.MessageText -> content.text @@ -1008,36 +1162,6 @@ internal class MessagePersistenceMapper( } } - private fun Array?.toDomainEntities(): List { - if (isNullOrEmpty()) return emptyList() - return mapNotNull { entity -> - val type = when (val entityType = entity.type) { - is TdApi.TextEntityTypeBold -> MessageEntityType.Bold - is TdApi.TextEntityTypeItalic -> MessageEntityType.Italic - is TdApi.TextEntityTypeUnderline -> MessageEntityType.Underline - is TdApi.TextEntityTypeStrikethrough -> MessageEntityType.Strikethrough - is TdApi.TextEntityTypeSpoiler -> MessageEntityType.Spoiler - is TdApi.TextEntityTypeCode -> MessageEntityType.Code - is TdApi.TextEntityTypePre -> MessageEntityType.Pre() - is TdApi.TextEntityTypePreCode -> MessageEntityType.Pre(entityType.language) - is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl(entityType.url) - is TdApi.TextEntityTypeMention -> MessageEntityType.Mention - is TdApi.TextEntityTypeMentionName -> MessageEntityType.TextMention(entityType.userId) - is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag - is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand - is TdApi.TextEntityTypeUrl -> MessageEntityType.Url - is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email - is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber - is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber - is TdApi.TextEntityTypeCustomEmoji -> MessageEntityType.CustomEmoji(entityType.customEmojiId) - is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote - is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable - else -> MessageEntityType.Other(entityType.javaClass.simpleName) - } - MessageEntity(entity.offset, entity.length, type) - } - } - private fun mapPaidMediaPayload(media: TdApi.PaidMedia): PaidMediaItemPayload { return when (media) { is TdApi.PaidMediaPreview -> PaidMediaItemPayload.Preview( diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageTdExtensions.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageTdExtensions.kt new file mode 100644 index 00000000..565ca35f --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/message/MessageTdExtensions.kt @@ -0,0 +1,61 @@ +package org.monogram.data.mapper.message + +import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.FactCheckModel +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageEntityType +import org.monogram.domain.models.SuggestedPostInfoModel +import org.monogram.domain.models.SuggestedPostPriceModel +import org.monogram.domain.models.SuggestedPostStateModel + +internal fun TdApi.FactCheck.toDomain(): FactCheckModel = FactCheckModel( + text = text?.text.orEmpty(), + entities = text?.entities.toDomainEntities(), + countryCode = countryCode.takeIf { it.isNotBlank() } +) + +internal fun TdApi.SuggestedPostInfo.toDomain(): SuggestedPostInfoModel = SuggestedPostInfoModel( + price = when (val value = price) { + is TdApi.SuggestedPostPriceStar -> SuggestedPostPriceModel.Star(value.starCount) + is TdApi.SuggestedPostPriceTon -> SuggestedPostPriceModel.Ton(value.toncoinCentCount) + else -> null + }, + sendDate = sendDate, + state = when (state) { + is TdApi.SuggestedPostStateApproved -> SuggestedPostStateModel.APPROVED + is TdApi.SuggestedPostStateDeclined -> SuggestedPostStateModel.DECLINED + else -> SuggestedPostStateModel.PENDING + }, + canBeApproved = canBeApproved, + canBeDeclined = canBeDeclined +) + +internal fun Array?.toDomainEntities(): List { + if (isNullOrEmpty()) return emptyList() + return mapNotNull { entity -> + val type = when (val entityType = entity.type) { + is TdApi.TextEntityTypeBold -> MessageEntityType.Bold + is TdApi.TextEntityTypeItalic -> MessageEntityType.Italic + is TdApi.TextEntityTypeUnderline -> MessageEntityType.Underline + is TdApi.TextEntityTypeStrikethrough -> MessageEntityType.Strikethrough + is TdApi.TextEntityTypeSpoiler -> MessageEntityType.Spoiler + is TdApi.TextEntityTypeCode -> MessageEntityType.Code + is TdApi.TextEntityTypePre -> MessageEntityType.Pre() + is TdApi.TextEntityTypePreCode -> MessageEntityType.Pre(entityType.language) + is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl(entityType.url) + is TdApi.TextEntityTypeMention -> MessageEntityType.Mention + is TdApi.TextEntityTypeMentionName -> MessageEntityType.TextMention(entityType.userId) + is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag + is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand + is TdApi.TextEntityTypeUrl -> MessageEntityType.Url + is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email + is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber + is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber + is TdApi.TextEntityTypeCustomEmoji -> MessageEntityType.CustomEmoji(entityType.customEmojiId) + is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote + is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable + else -> MessageEntityType.Other(entityType.javaClass.simpleName) + } + MessageEntity(entity.offset, entity.length, type) + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageUiPayloads.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageUiPayloads.kt new file mode 100644 index 00000000..309ec35d --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/message/MessageUiPayloads.kt @@ -0,0 +1,420 @@ +package org.monogram.data.mapper.message + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.monogram.domain.models.FactCheckModel +import org.monogram.domain.models.InlineKeyboardButtonModel +import org.monogram.domain.models.InlineKeyboardButtonType +import org.monogram.domain.models.KeyboardButtonModel +import org.monogram.domain.models.KeyboardButtonType +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageReactionModel +import org.monogram.domain.models.ReactionSender +import org.monogram.domain.models.ReplyMarkupModel +import org.monogram.domain.models.SuggestedPostInfoModel +import org.monogram.domain.models.SuggestedPostPriceModel +import org.monogram.domain.models.SuggestedPostStateModel + +private val messageUiPayloadJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +internal fun ReplyMarkupPayload.encode(): String = + messageUiPayloadJson.encodeToString(ReplyMarkupPayload.serializer(), this) + +internal fun decodeReplyMarkupPayload(raw: String?): ReplyMarkupPayload? { + if (raw.isNullOrBlank()) return null + return runCatching { + messageUiPayloadJson.decodeFromString(ReplyMarkupPayload.serializer(), raw) + }.getOrNull() +} + +internal fun ReactionsPayload.encode(): String = + messageUiPayloadJson.encodeToString(ReactionsPayload.serializer(), this) + +internal fun decodeReactionsPayload(raw: String?): ReactionsPayload? { + if (raw.isNullOrBlank()) return null + return runCatching { + messageUiPayloadJson.decodeFromString(ReactionsPayload.serializer(), raw) + }.getOrNull() +} + +internal fun FactCheckPayload.encode(): String = + messageUiPayloadJson.encodeToString(FactCheckPayload.serializer(), this) + +internal fun decodeFactCheckPayload(raw: String?): FactCheckPayload? { + if (raw.isNullOrBlank()) return null + return runCatching { + messageUiPayloadJson.decodeFromString(FactCheckPayload.serializer(), raw) + }.getOrNull() +} + +internal fun SuggestedPostInfoPayload.encode(): String = + messageUiPayloadJson.encodeToString(SuggestedPostInfoPayload.serializer(), this) + +internal fun decodeSuggestedPostInfoPayload(raw: String?): SuggestedPostInfoPayload? { + if (raw.isNullOrBlank()) return null + return runCatching { + messageUiPayloadJson.decodeFromString(SuggestedPostInfoPayload.serializer(), raw) + }.getOrNull() +} + +@Serializable +internal sealed interface ReplyMarkupPayload { + @Serializable + data class InlineKeyboard( + val rows: List> = emptyList() + ) : ReplyMarkupPayload + + @Serializable + data class ShowKeyboard( + val rows: List> = emptyList(), + val isPersistent: Boolean = false, + val resizeKeyboard: Boolean = false, + val oneTime: Boolean = false, + val isPersonal: Boolean = false, + val inputFieldPlaceholder: String = "" + ) : ReplyMarkupPayload + + @Serializable + data class RemoveKeyboard(val isPersonal: Boolean = false) : ReplyMarkupPayload + + @Serializable + data class ForceReply( + val isPersonal: Boolean = false, + val inputFieldPlaceholder: String = "" + ) : ReplyMarkupPayload +} + +@Serializable +internal data class InlineKeyboardButtonPayload( + val text: String, + val type: InlineKeyboardButtonTypePayload +) + +@Serializable +internal sealed interface InlineKeyboardButtonTypePayload { + @Serializable + data class Url(val url: String) : InlineKeyboardButtonTypePayload + + @Serializable + data class Callback(val data: ByteArray) : InlineKeyboardButtonTypePayload + + @Serializable + data class WebApp(val url: String) : InlineKeyboardButtonTypePayload + + @Serializable + data class LoginUrl(val url: String, val id: Long) : InlineKeyboardButtonTypePayload + + @Serializable + data class SwitchInline(val query: String) : InlineKeyboardButtonTypePayload + + @Serializable + data class Buy(val slug: String? = null) : InlineKeyboardButtonTypePayload + + @Serializable + data class User(val userId: Long) : InlineKeyboardButtonTypePayload + + @Serializable + data object Unsupported : InlineKeyboardButtonTypePayload +} + +@Serializable +internal data class KeyboardButtonPayload( + val text: String, + val type: KeyboardButtonTypePayload +) + +@Serializable +internal sealed interface KeyboardButtonTypePayload { + @Serializable + data object Text : KeyboardButtonTypePayload + + @Serializable + data object RequestPhoneNumber : KeyboardButtonTypePayload + + @Serializable + data object RequestLocation : KeyboardButtonTypePayload + + @Serializable + data class RequestPoll( + val forceQuiz: Boolean = false, + val forceRegular: Boolean = false + ) : KeyboardButtonTypePayload + + @Serializable + data class WebApp(val url: String) : KeyboardButtonTypePayload + + @Serializable + data class RequestUsers(val id: Int) : KeyboardButtonTypePayload + + @Serializable + data class RequestChat(val id: Int) : KeyboardButtonTypePayload + + @Serializable + data object Unsupported : KeyboardButtonTypePayload +} + +@Serializable +internal data class ReactionsPayload( + val reactions: List = emptyList() +) + +@Serializable +internal data class MessageReactionPayload( + val emoji: String? = null, + val customEmojiId: Long? = null, + val customEmojiPath: String? = null, + val count: Int, + val isChosen: Boolean, + val recentSenders: List = emptyList() +) + +@Serializable +internal data class ReactionSenderPayload( + val id: Long, + val name: String = "", + val avatar: String? = null +) + +@Serializable +internal data class FactCheckPayload( + val text: String, + val entities: List = emptyList(), + val countryCode: String? = null +) + +@Serializable +internal data class SuggestedPostInfoPayload( + val price: SuggestedPostPricePayload? = null, + val sendDate: Int = 0, + val state: SuggestedPostStatePayload = SuggestedPostStatePayload.PENDING, + val canBeApproved: Boolean = false, + val canBeDeclined: Boolean = false +) + +@Serializable +internal sealed interface SuggestedPostPricePayload { + @Serializable + data class Star(val starCount: Long) : SuggestedPostPricePayload + + @Serializable + data class Ton(val toncoinCentCount: Long) : SuggestedPostPricePayload +} + +@Serializable +internal enum class SuggestedPostStatePayload { + PENDING, + APPROVED, + DECLINED +} + +internal fun ReplyMarkupModel.toPayload(): ReplyMarkupPayload = when (this) { + is ReplyMarkupModel.InlineKeyboard -> ReplyMarkupPayload.InlineKeyboard( + rows = rows.map { row -> + row.map { button -> + InlineKeyboardButtonPayload( + text = button.text, + type = button.type.toPayload() + ) + } + } + ) + + is ReplyMarkupModel.ShowKeyboard -> ReplyMarkupPayload.ShowKeyboard( + rows = rows.map { row -> + row.map { button -> + KeyboardButtonPayload( + text = button.text, + type = button.type.toPayload() + ) + } + }, + isPersistent = isPersistent, + resizeKeyboard = resizeKeyboard, + oneTime = oneTime, + isPersonal = isPersonal, + inputFieldPlaceholder = inputFieldPlaceholder + ) + + is ReplyMarkupModel.RemoveKeyboard -> ReplyMarkupPayload.RemoveKeyboard(isPersonal) + is ReplyMarkupModel.ForceReply -> ReplyMarkupPayload.ForceReply( + isPersonal, + inputFieldPlaceholder + ) +} + +internal fun ReplyMarkupPayload.toDomain(): ReplyMarkupModel = when (this) { + is ReplyMarkupPayload.InlineKeyboard -> ReplyMarkupModel.InlineKeyboard( + rows = rows.map { row -> + row.map { button -> + InlineKeyboardButtonModel( + text = button.text, + type = button.type.toDomain() + ) + } + } + ) + + is ReplyMarkupPayload.ShowKeyboard -> ReplyMarkupModel.ShowKeyboard( + rows = rows.map { row -> + row.map { button -> + KeyboardButtonModel( + text = button.text, + type = button.type.toDomain() + ) + } + }, + isPersistent = isPersistent, + resizeKeyboard = resizeKeyboard, + oneTime = oneTime, + isPersonal = isPersonal, + inputFieldPlaceholder = inputFieldPlaceholder + ) + + is ReplyMarkupPayload.RemoveKeyboard -> ReplyMarkupModel.RemoveKeyboard(isPersonal) + is ReplyMarkupPayload.ForceReply -> ReplyMarkupModel.ForceReply( + isPersonal, + inputFieldPlaceholder + ) +} + +internal fun InlineKeyboardButtonType.toPayload(): InlineKeyboardButtonTypePayload = when (this) { + is InlineKeyboardButtonType.Url -> InlineKeyboardButtonTypePayload.Url(url) + is InlineKeyboardButtonType.Callback -> InlineKeyboardButtonTypePayload.Callback(data) + is InlineKeyboardButtonType.WebApp -> InlineKeyboardButtonTypePayload.WebApp(url) + is InlineKeyboardButtonType.LoginUrl -> InlineKeyboardButtonTypePayload.LoginUrl(url, id) + is InlineKeyboardButtonType.SwitchInline -> InlineKeyboardButtonTypePayload.SwitchInline(query) + is InlineKeyboardButtonType.Buy -> InlineKeyboardButtonTypePayload.Buy(slug) + is InlineKeyboardButtonType.User -> InlineKeyboardButtonTypePayload.User(userId) + InlineKeyboardButtonType.Unsupported -> InlineKeyboardButtonTypePayload.Unsupported +} + +internal fun InlineKeyboardButtonTypePayload.toDomain(): InlineKeyboardButtonType = when (this) { + is InlineKeyboardButtonTypePayload.Url -> InlineKeyboardButtonType.Url(url) + is InlineKeyboardButtonTypePayload.Callback -> InlineKeyboardButtonType.Callback(data) + is InlineKeyboardButtonTypePayload.WebApp -> InlineKeyboardButtonType.WebApp(url) + is InlineKeyboardButtonTypePayload.LoginUrl -> InlineKeyboardButtonType.LoginUrl(url, id) + is InlineKeyboardButtonTypePayload.SwitchInline -> InlineKeyboardButtonType.SwitchInline(query) + is InlineKeyboardButtonTypePayload.Buy -> InlineKeyboardButtonType.Buy(slug) + is InlineKeyboardButtonTypePayload.User -> InlineKeyboardButtonType.User(userId) + InlineKeyboardButtonTypePayload.Unsupported -> InlineKeyboardButtonType.Unsupported +} + +internal fun KeyboardButtonType.toPayload(): KeyboardButtonTypePayload = when (this) { + KeyboardButtonType.Text -> KeyboardButtonTypePayload.Text + KeyboardButtonType.RequestPhoneNumber -> KeyboardButtonTypePayload.RequestPhoneNumber + KeyboardButtonType.RequestLocation -> KeyboardButtonTypePayload.RequestLocation + is KeyboardButtonType.RequestPoll -> KeyboardButtonTypePayload.RequestPoll( + forceQuiz, + forceRegular + ) + + is KeyboardButtonType.WebApp -> KeyboardButtonTypePayload.WebApp(url) + is KeyboardButtonType.RequestUsers -> KeyboardButtonTypePayload.RequestUsers(id) + is KeyboardButtonType.RequestChat -> KeyboardButtonTypePayload.RequestChat(id) + KeyboardButtonType.Unsupported -> KeyboardButtonTypePayload.Unsupported +} + +internal fun KeyboardButtonTypePayload.toDomain(): KeyboardButtonType = when (this) { + KeyboardButtonTypePayload.Text -> KeyboardButtonType.Text + KeyboardButtonTypePayload.RequestPhoneNumber -> KeyboardButtonType.RequestPhoneNumber + KeyboardButtonTypePayload.RequestLocation -> KeyboardButtonType.RequestLocation + is KeyboardButtonTypePayload.RequestPoll -> KeyboardButtonType.RequestPoll( + forceQuiz, + forceRegular + ) + + is KeyboardButtonTypePayload.WebApp -> KeyboardButtonType.WebApp(url) + is KeyboardButtonTypePayload.RequestUsers -> KeyboardButtonType.RequestUsers(id) + is KeyboardButtonTypePayload.RequestChat -> KeyboardButtonType.RequestChat(id) + KeyboardButtonTypePayload.Unsupported -> KeyboardButtonType.Unsupported +} + +internal fun List.toPayload(): ReactionsPayload = ReactionsPayload( + reactions = map { reaction -> + MessageReactionPayload( + emoji = reaction.emoji, + customEmojiId = reaction.customEmojiId, + customEmojiPath = reaction.customEmojiPath, + count = reaction.count, + isChosen = reaction.isChosen, + recentSenders = reaction.recentSenders.map { sender -> + ReactionSenderPayload( + id = sender.id, + name = sender.name, + avatar = sender.avatar + ) + } + ) + } +) + +internal fun ReactionsPayload.toDomain(): List = reactions.map { reaction -> + MessageReactionModel( + emoji = reaction.emoji, + customEmojiId = reaction.customEmojiId, + customEmojiPath = reaction.customEmojiPath, + count = reaction.count, + isChosen = reaction.isChosen, + recentSenders = reaction.recentSenders.map { sender -> + ReactionSender( + id = sender.id, + name = sender.name, + avatar = sender.avatar + ) + } + ) +} + +internal fun FactCheckModel.toPayload(): FactCheckPayload = FactCheckPayload( + text = text, + entities = entities, + countryCode = countryCode +) + +internal fun FactCheckPayload.toDomain(): FactCheckModel = FactCheckModel( + text = text, + entities = entities, + countryCode = countryCode +) + +internal fun SuggestedPostInfoModel.toPayload(): SuggestedPostInfoPayload = + SuggestedPostInfoPayload( + price = price?.toPayload(), + sendDate = sendDate, + state = state.toPayload(), + canBeApproved = canBeApproved, + canBeDeclined = canBeDeclined + ) + +internal fun SuggestedPostInfoPayload.toDomain(): SuggestedPostInfoModel = SuggestedPostInfoModel( + price = price?.toDomain(), + sendDate = sendDate, + state = state.toDomain(), + canBeApproved = canBeApproved, + canBeDeclined = canBeDeclined +) + +internal fun SuggestedPostPriceModel.toPayload(): SuggestedPostPricePayload = when (this) { + is SuggestedPostPriceModel.Star -> SuggestedPostPricePayload.Star(starCount) + is SuggestedPostPriceModel.Ton -> SuggestedPostPricePayload.Ton(toncoinCentCount) +} + +internal fun SuggestedPostPricePayload.toDomain(): SuggestedPostPriceModel = when (this) { + is SuggestedPostPricePayload.Star -> SuggestedPostPriceModel.Star(starCount) + is SuggestedPostPricePayload.Ton -> SuggestedPostPriceModel.Ton(toncoinCentCount) +} + +internal fun SuggestedPostStateModel.toPayload(): SuggestedPostStatePayload = when (this) { + SuggestedPostStateModel.PENDING -> SuggestedPostStatePayload.PENDING + SuggestedPostStateModel.APPROVED -> SuggestedPostStatePayload.APPROVED + SuggestedPostStateModel.DECLINED -> SuggestedPostStatePayload.DECLINED +} + +internal fun SuggestedPostStatePayload.toDomain(): SuggestedPostStateModel = when (this) { + SuggestedPostStatePayload.PENDING -> SuggestedPostStateModel.PENDING + SuggestedPostStatePayload.APPROVED -> SuggestedPostStateModel.APPROVED + SuggestedPostStatePayload.DECLINED -> SuggestedPostStateModel.DECLINED +} diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index 5a302898..c7608394 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -700,26 +700,22 @@ class ChatsListRepositoryImpl( return searchManager.searchMessages(query, offset, limit) } - override fun toggleMuteChats(chatIds: Set, mute: Boolean) { + override suspend fun toggleMuteChats(chatIds: Set, mute: Boolean) { val muteFor = if (mute) Int.MAX_VALUE else 0 chatIds.forEach { chatId -> - scope.launch(dispatchers.io) { - chatRemoteSource.muteChat(chatId, muteFor) - refreshChat(chatId) - } + chatRemoteSource.muteChat(chatId, muteFor) + refreshChat(chatId) } } - override fun toggleArchiveChats(chatIds: Set, archive: Boolean) { + override suspend fun toggleArchiveChats(chatIds: Set, archive: Boolean) { chatIds.forEach { chatId -> - scope.launch(dispatchers.io) { - chatRemoteSource.archiveChat(chatId, archive) - refreshChat(chatId) - } + chatRemoteSource.archiveChat(chatId, archive) + refreshChat(chatId) } } - override fun togglePinChats(chatIds: Set, pin: Boolean, folderId: Int) { + override suspend fun togglePinChats(chatIds: Set, pin: Boolean, folderId: Int) { val chatList: TdApi.ChatList = when (folderId) { -1 -> TdApi.ChatListMain() -2 -> TdApi.ChatListArchive() @@ -727,23 +723,19 @@ class ChatsListRepositoryImpl( } chatIds.forEach { chatId -> - scope.launch(dispatchers.io) { - chatRemoteSource.toggleChatIsPinned(chatList, chatId, pin) - refreshChat(chatId) - } + chatRemoteSource.toggleChatIsPinned(chatList, chatId, pin) + refreshChat(chatId) } } - override fun toggleReadChats(chatIds: Set, markAsUnread: Boolean) { + override suspend fun toggleReadChats(chatIds: Set, markAsUnread: Boolean) { chatIds.forEach { chatId -> - scope.launch(dispatchers.io) { - if (markAsUnread) { - chatRemoteSource.toggleChatIsMarkedAsUnread(chatId, true) - } else { - chatRemoteSource.markChatAsRead(chatId) - } - refreshChat(chatId) + if (markAsUnread) { + chatRemoteSource.toggleChatIsMarkedAsUnread(chatId, true) + } else { + chatRemoteSource.markChatAsRead(chatId) } + refreshChat(chatId) } } @@ -760,31 +752,25 @@ class ChatsListRepositoryImpl( markChatsAsRead(chatIds) } - override fun deleteChats(chatIds: Set) { + override suspend fun deleteChats(chatIds: Set) { chatIds.forEach { chatId -> - scope.launch(dispatchers.io) { - chatRemoteSource.deleteChat(chatId) - triggerUpdate(chatId) - } + chatRemoteSource.deleteChat(chatId) + triggerUpdate(chatId) } } - override fun leaveChats(chatIds: Set) { + override suspend fun leaveChats(chatIds: Set) { chatIds.forEach { chatId -> - scope.launch(dispatchers.io) { - chatRemoteSource.leaveChat(chatId) - refreshChat(chatId) - } - } - } - - override fun leaveChat(chatId: Long) { - scope.launch(dispatchers.io) { chatRemoteSource.leaveChat(chatId) refreshChat(chatId) } } + override suspend fun leaveChat(chatId: Long) { + chatRemoteSource.leaveChat(chatId) + refreshChat(chatId) + } + override fun setArchivePinned(pinned: Boolean) { appPreferences.setArchivePinned(pinned) } @@ -834,19 +820,15 @@ class ChatsListRepositoryImpl( refreshChat(chatId) } - override fun clearChatHistory(chatId: Long, revoke: Boolean) { - scope.launch(dispatchers.io) { - chatRemoteSource.clearChatHistory(chatId, revoke) - refreshChat(chatId) - } + override suspend fun clearChatHistory(chatId: Long, revoke: Boolean) { + chatRemoteSource.clearChatHistory(chatId, revoke) + refreshChat(chatId) } - override fun clearChatHistories(chatIds: Set, revoke: Boolean) { + override suspend fun clearChatHistories(chatIds: Set, revoke: Boolean) { chatIds.forEach { chatId -> - scope.launch(dispatchers.io) { - chatRemoteSource.clearChatHistory(chatId, revoke) - refreshChat(chatId) - } + chatRemoteSource.clearChatHistory(chatId, revoke) + refreshChat(chatId) } } @@ -854,17 +836,13 @@ class ChatsListRepositoryImpl( return chatRemoteSource.getChatLink(chatId) } - override fun reportChat(chatId: Long, reason: String, messageIds: List) { - scope.launch(dispatchers.io) { - chatRemoteSource.reportChat(chatId, reason, messageIds) - } + override suspend fun reportChat(chatId: Long, reason: String, messageIds: List) { + chatRemoteSource.reportChat(chatId, reason, messageIds) } - override fun reportChats(chatIds: Set, reason: String, messageIds: List) { + override suspend fun reportChats(chatIds: Set, reason: String, messageIds: List) { chatIds.forEach { chatId -> - scope.launch(dispatchers.io) { - chatRemoteSource.reportChat(chatId, reason, messageIds) - } + chatRemoteSource.reportChat(chatId, reason, messageIds) } } @@ -913,6 +891,22 @@ class ChatsListRepositoryImpl( chatRemoteSource.setChatPermissions(chatId, permissions) } + override suspend fun setChatHasProtectedContent(chatId: Long, hasProtectedContent: Boolean) { + chatRemoteSource.setChatHasProtectedContent(chatId, hasProtectedContent) + } + + override suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean) { + chatRemoteSource.setChatSignMessages(chatId, signMessages) + } + + override suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) { + chatRemoteSource.setChatJoinToSendMessages(chatId, joinToSendMessages) + } + + override suspend fun setChatAvailableReactions(chatId: Long, availableReactions: List) { + chatRemoteSource.setChatAvailableReactions(chatId, availableReactions) + } + override suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) { chatRemoteSource.setChatSlowModeDelay(chatId, slowModeDelay) } @@ -921,10 +915,6 @@ class ChatsListRepositoryImpl( chatRemoteSource.toggleChatIsForum(chatId, isForum) } - override suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean) { - chatRemoteSource.toggleChatIsTranslatable(chatId, isTranslatable) - } - override fun getDatabaseSize(): Long { return if (databaseFile.exists()) databaseFile.length() else 0L } diff --git a/data/src/main/java/org/monogram/data/repository/ContactEditRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ContactEditRepositoryImpl.kt new file mode 100644 index 00000000..a819d76d --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/ContactEditRepositoryImpl.kt @@ -0,0 +1,57 @@ +package org.monogram.data.repository + +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.UserRemoteDataSource +import org.monogram.domain.models.UserModel +import org.monogram.domain.repository.ContactEditRepository +import org.monogram.domain.repository.UserRepository + +class ContactEditRepositoryImpl( + private val userRepository: UserRepository, + private val userRemoteDataSource: UserRemoteDataSource +) : ContactEditRepository { + + override suspend fun getContact(userId: Long): UserModel? = userRepository.getUser(userId) + + override suspend fun getNeedPhoneNumberPrivacyException(userId: Long): Boolean { + return userRepository.resolveUserChatFullInfo(userId)?.needPhoneNumberPrivacyException == true + } + + override suspend fun upsertContact( + user: UserModel, + sharePhoneNumber: Boolean + ): UserModel? { + userRemoteDataSource.addContact( + userId = user.id, + contact = user.toImportedContact(), + sharePhoneNumber = sharePhoneNumber + ) + userRepository.refreshUserFullInfo(user.id) + return userRepository.getUser(user.id) + } + + override suspend fun removeContact(userId: Long) { + userRemoteDataSource.removeContacts(longArrayOf(userId)) + } + + override suspend fun setCloseFriend( + userId: Long, + isCloseFriend: Boolean + ): UserModel? { + val closeFriends = userRemoteDataSource.getCloseFriendIds().toMutableSet() + if (isCloseFriend) { + closeFriends += userId + } else { + closeFriends -= userId + } + userRemoteDataSource.setCloseFriendIds(closeFriends.toLongArray()) + return userRepository.getUser(userId) + } +} + +private fun UserModel.toImportedContact() = TdApi.ImportedContact( + phoneNumber.orEmpty(), + firstName, + lastName.orEmpty(), + null +) diff --git a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt index c862d2a1..584241af 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -1,6 +1,7 @@ package org.monogram.data.repository import android.content.Context +import android.os.Trace import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -16,6 +17,9 @@ import org.json.JSONArray import org.json.JSONObject import org.json.JSONTokener import org.monogram.core.DispatcherProvider +import org.monogram.core.perf.ChatOpenPerfBridge +import org.monogram.core.perf.ChatOpenPerfDebug +import org.monogram.data.BuildConfig import org.monogram.data.chats.ChatCache import org.monogram.data.compat.buildDraftMessageTextContent import org.monogram.data.compat.buildTdChatPermissions @@ -33,6 +37,7 @@ import org.monogram.data.db.model.TextCompositionStyleEntity import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.mapper.MessageMapper +import org.monogram.data.mapper.SponsoredMessageMapper import org.monogram.data.mapper.TdFileHelper import org.monogram.data.mapper.map import org.monogram.data.mapper.toDomain @@ -54,6 +59,7 @@ import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageSenderModel import org.monogram.domain.models.MessageViewerModel import org.monogram.domain.models.PollDraft +import org.monogram.domain.models.SponsoredMessagesFeedModel import org.monogram.domain.models.UserModel import org.monogram.domain.models.webapp.InstantViewModel import org.monogram.domain.models.webapp.InvoiceModel @@ -71,8 +77,9 @@ import org.monogram.domain.repository.ProfileMediaFilter import org.monogram.domain.repository.SearchChatMessagesResult import org.monogram.domain.repository.TextCompositionStyleModel import java.io.File +import java.util.concurrent.ConcurrentHashMap -class MessageRepositoryImpl( +internal class MessageRepositoryImpl( private val context: Context, private val gateway: TelegramGateway, private val updates: UpdateDispatcher, @@ -80,6 +87,7 @@ class MessageRepositoryImpl( private val messageRemoteDataSource: MessageRemoteDataSource, private val cache: ChatCache, private val fileHelper: TdFileHelper, + private val sponsoredMessageMapper: SponsoredMessageMapper, private val fileDataSource: FileDataSource, private val fxEmbedRemoteDataSource: FxEmbedRemoteDataSource, private val draftLinkPreviewResolver: DraftLinkPreviewResolver, @@ -91,11 +99,15 @@ class MessageRepositoryImpl( private val keyValueDao: KeyValueDao, private val textCompositionStyleDao: TextCompositionStyleDao ) : MessageRepository { + private data class RichMessageCacheKey(val chatId: Long, val messageId: Long) + private val fixedDraftLinkPreviewFetcher = FixedDraftLinkPreviewFetcher( draftLinkPreviewResolver = draftLinkPreviewResolver, draftLinkPreviewRemoteDataSource = messageRemoteDataSource, fixedPreviewRemoteDataSource = fxEmbedRemoteDataSource ) + private val fullRichMessageCache = + ConcurrentHashMap() private val _textCompositionStyles = MutableStateFlow>(emptyList()) private val hardResetFlagKey = "cache_hard_reset_v2" @@ -169,6 +181,18 @@ class MessageRepositoryImpl( } } } + + scope.launch(dispatcherProvider.io) { + messageEditedFlow.collect { message -> + val key = RichMessageCacheKey(chatId = message.chatId, messageId = message.id) + val richContent = message.content as? MessageContent.RichMessage + when { + richContent == null -> fullRichMessageCache.remove(key) + richContent.isFull -> fullRichMessageCache[key] = richContent + else -> fullRichMessageCache.remove(key) + } + } + } } private suspend fun performHardCacheResetIfNeeded() { @@ -200,8 +224,7 @@ class MessageRepositoryImpl( private suspend fun processCachedUpdate(update: TdApi.Update) { when (update) { is TdApi.UpdateNewMessage -> { - val entity = messageMapper.mapToEntity(update.message, ::resolveSenderName) - chatLocalDataSource.insertMessage(entity) + persistMappedMessage(update.message) } is TdApi.UpdateMessageSendSucceeded -> { @@ -213,22 +236,19 @@ class MessageRepositoryImpl( ) } - is TdApi.UpdateMessageContent -> { - val extracted = messageMapper.extractCachedContent(update.newContent) + is TdApi.UpdateMessageSendFailed -> { + val entity = messageMapper.mapToEntity(update.message, ::resolveSenderName) + chatLocalDataSource.replaceMessageId( + chatId = update.message.chatId, + oldMessageId = update.oldMessageId, + message = entity + ) + } - if (update.newContent is TdApi.MessagePhoto && extracted.text.isBlank()) { - val refreshed = messageRemoteDataSource.getMessage(update.chatId, update.messageId) - if (refreshed != null) { - chatLocalDataSource.insertMessage( - messageMapper.mapToEntity( - refreshed, - ::resolveSenderName - ) - ) - return - } - } + is TdApi.UpdateMessageContent -> { + if (refreshPersistedMessage(update.chatId, update.messageId)) return + val extracted = messageMapper.extractCachedContent(update.newContent) chatLocalDataSource.updateMessageContent( chatId = update.chatId, messageId = update.messageId, @@ -242,18 +262,11 @@ class MessageRepositoryImpl( } is TdApi.UpdateMessageEdited -> { - val updated = messageRemoteDataSource.getMessage(update.chatId, update.messageId) - if (updated != null) { - chatLocalDataSource.insertMessage( - messageMapper.mapToEntity( - updated, - ::resolveSenderName - ) - ) - } + refreshPersistedMessage(update.chatId, update.messageId) } is TdApi.UpdateMessageInteractionInfo -> { + if (refreshPersistedMessage(update.chatId, update.messageId)) return chatLocalDataSource.updateInteractionInfo( chatId = update.chatId, messageId = update.messageId, @@ -263,6 +276,42 @@ class MessageRepositoryImpl( ) } + is TdApi.UpdateMessageReaction, + is TdApi.UpdateMessageReactions, + is TdApi.UpdateMessageUnreadReactions, + is TdApi.UpdateMessageFactCheck, + is TdApi.UpdateMessageSuggestedPostInfo, + is TdApi.UpdateMessageIsPinned, + is TdApi.UpdateMessageContainsUnreadPollVotes, + is TdApi.UpdateMessageLiveLocationViewed, + is TdApi.UpdateMessageContentOpened -> { + val chatId = when (update) { + is TdApi.UpdateMessageReaction -> update.chatId + is TdApi.UpdateMessageReactions -> update.chatId + is TdApi.UpdateMessageUnreadReactions -> update.chatId + is TdApi.UpdateMessageFactCheck -> update.chatId + is TdApi.UpdateMessageSuggestedPostInfo -> update.chatId + is TdApi.UpdateMessageIsPinned -> update.chatId + is TdApi.UpdateMessageContainsUnreadPollVotes -> update.chatId + is TdApi.UpdateMessageLiveLocationViewed -> update.chatId + is TdApi.UpdateMessageContentOpened -> update.chatId + else -> 0L + } + val messageId = when (update) { + is TdApi.UpdateMessageReaction -> update.messageId + is TdApi.UpdateMessageReactions -> update.messageId + is TdApi.UpdateMessageUnreadReactions -> update.messageId + is TdApi.UpdateMessageFactCheck -> update.messageId + is TdApi.UpdateMessageSuggestedPostInfo -> update.messageId + is TdApi.UpdateMessageIsPinned -> update.messageId + is TdApi.UpdateMessageContainsUnreadPollVotes -> update.messageId + is TdApi.UpdateMessageLiveLocationViewed -> update.messageId + is TdApi.UpdateMessageContentOpened -> update.messageId + else -> 0L + } + refreshPersistedMessage(chatId, messageId) + } + is TdApi.UpdateChatReadInbox -> { chatLocalDataSource.markAsRead(update.chatId, update.lastReadInboxMessageId) } @@ -289,6 +338,18 @@ class MessageRepositoryImpl( } } + private suspend fun persistMappedMessage(message: TdApi.Message) { + chatLocalDataSource.replaceMessage( + messageMapper.mapToEntity(message, ::resolveSenderName) + ) + } + + private suspend fun refreshPersistedMessage(chatId: Long, messageId: Long): Boolean { + val refreshed = messageRemoteDataSource.getMessage(chatId, messageId) ?: return false + persistMappedMessage(refreshed) + return true + } + override suspend fun openChat(chatId: Long) { messageRemoteDataSource.setChatOpened(chatId) try { @@ -615,21 +676,33 @@ class MessageRepositoryImpl( ): OlderMessagesPage = withContext(dispatcherProvider.io) { val cached = if (fromMessageId == 0L) { - val cachedEntities = chatLocalDataSource.getLatestMessages(chatId, limit) + val cachedEntities = chatLocalDataSource.getLatestMessages(chatId, limit, threadId) mapLocalMessages(cachedEntities) } else { emptyList() } try { - val remotePage = messageRemoteDataSource.getMessagesOlder(chatId, fromMessageId, limit, threadId) - persistRemoteMessages(chatId, remotePage.messages) - remotePage + traceSection("chat_history_tdlib") { + val remotePage = messageRemoteDataSource.getRemoteMessagesOlder( + chatId, + fromMessageId, + limit, + threadId + ) + persistRemoteBatch(chatId, remotePage.rawMessages, remotePage.models, threadId) + OlderMessagesPage( + messages = remotePage.models, + reachedOldest = remotePage.reachedOldest, + isRemote = remotePage.isRemote + ) + } } catch (e: Exception) { val fallbackMessages = if (cached.isNotEmpty()) { cached } else { - val local = chatLocalDataSource.getMessagesOlder(chatId, fromMessageId, limit) + val local = + chatLocalDataSource.getMessagesOlder(chatId, fromMessageId, limit, threadId) mapLocalMessages(local) } OlderMessagesPage( @@ -640,9 +713,13 @@ class MessageRepositoryImpl( } } - override suspend fun getCachedMessages(chatId: Long, limit: Int): List = + override suspend fun getCachedMessages( + chatId: Long, + limit: Int, + threadId: Long? + ): List = withContext(dispatcherProvider.io) { - val local = chatLocalDataSource.getLatestMessages(chatId, limit) + val local = chatLocalDataSource.getLatestMessages(chatId, limit, threadId) mapLocalMessages(local) } @@ -654,11 +731,23 @@ class MessageRepositoryImpl( ): List = withContext(dispatcherProvider.io) { try { - val remoteMessages = messageRemoteDataSource.getMessagesNewer(chatId, fromMessageId, limit, threadId) - persistRemoteMessages(chatId, remoteMessages) - remoteMessages + traceSection("chat_history_tdlib") { + val remoteBatch = messageRemoteDataSource.getRemoteMessagesNewer( + chatId, + fromMessageId, + limit, + threadId + ) + persistRemoteBatch( + chatId, + remoteBatch.rawMessages, + remoteBatch.models, + threadId + ) + remoteBatch.models + } } catch (e: Exception) { - chatLocalDataSource.getMessagesNewer(chatId, fromMessageId, limit) + chatLocalDataSource.getMessagesNewer(chatId, fromMessageId, limit, threadId) .let { mapLocalMessages(it) } } } @@ -666,20 +755,22 @@ class MessageRepositoryImpl( override suspend fun getCachedMessagesNewer( chatId: Long, fromMessageId: Long, - limit: Int + limit: Int, + threadId: Long? ): List = withContext(dispatcherProvider.io) { - chatLocalDataSource.getMessagesNewer(chatId, fromMessageId, limit) + chatLocalDataSource.getMessagesNewer(chatId, fromMessageId, limit, threadId) .let { mapLocalMessages(it) } } override suspend fun getCachedMessagesAround( chatId: Long, messageId: Long, - limit: Int + limit: Int, + threadId: Long? ): List = withContext(dispatcherProvider.io) { - chatLocalDataSource.getMessagesAround(chatId, messageId, limit) + chatLocalDataSource.getMessagesAround(chatId, messageId, limit, threadId) .let { mapLocalMessages(it) } } @@ -691,11 +782,24 @@ class MessageRepositoryImpl( ): List = withContext(dispatcherProvider.io) { try { - val remoteMessages = messageRemoteDataSource.getMessagesAround(chatId, messageId, limit, threadId) - persistRemoteMessages(chatId, remoteMessages) - remoteMessages + traceSection("chat_history_tdlib") { + val remoteBatch = messageRemoteDataSource.getRemoteMessagesAround( + chatId, + messageId, + limit, + threadId + ) + persistRemoteBatch( + chatId, + remoteBatch.rawMessages, + remoteBatch.models, + threadId + ) + remoteBatch.models + } } catch (e: Exception) { - val local = chatLocalDataSource.getMessagesAround(chatId, messageId, limit) + val local = + chatLocalDataSource.getMessagesAround(chatId, messageId, limit, threadId) mapLocalMessages(local) } } @@ -996,8 +1100,14 @@ class MessageRepositoryImpl( chatId: Long, messageId: Long ): MessageContent.RichMessage? { - return messageRemoteDataSource.getFullRichMessage(chatId, messageId) + val key = RichMessageCacheKey(chatId = chatId, messageId = messageId) + fullRichMessageCache[key]?.let { return it } + val resolved = messageRemoteDataSource.getFullRichMessage(chatId, messageId) ?.toDomainRichMessage(chatId = chatId, messageId = messageId) + if (resolved != null) { + fullRichMessageCache[key] = resolved + } + return resolved } override suspend fun getDraftLinkPreview(request: DraftLinkPreviewRequest): DraftLinkPreview? { @@ -1039,6 +1149,35 @@ class MessageRepositoryImpl( return preview } + override suspend fun getChannelSponsoredMessages(chatId: Long): SponsoredMessagesFeedModel? { + return withContext(dispatcherProvider.io) { + val result = messageRemoteDataSource.getChatSponsoredMessages(chatId) + if (result == null) return@withContext null + val mapped = result.messages.map { sponsoredMessageMapper.map(chatId, it) } + if (mapped.isEmpty()) return@withContext null + SponsoredMessagesFeedModel( + messages = mapped, + messagesBetween = result.messagesBetween + ) + } + } + + override suspend fun clickChannelSponsoredMessage( + chatId: Long, + messageId: Long, + isMediaClick: Boolean, + fromFullscreen: Boolean + ) { + withContext(dispatcherProvider.io) { + messageRemoteDataSource.clickChatSponsoredMessage( + chatId = chatId, + messageId = messageId, + isMediaClick = isMediaClick, + fromFullscreen = fromFullscreen + ) + } + } + override suspend fun searchMessages( chatId: Long, query: String, @@ -1742,26 +1881,54 @@ class MessageRepositoryImpl( } } - private fun persistRemoteMessages(chatId: Long, remoteMessages: List) { + private fun persistRemoteBatch( + chatId: Long, + rawMessages: List, + remoteMessages: List, + threadId: Long? + ) { scope.launch(dispatcherProvider.io) { + if (remoteMessages.isEmpty() || rawMessages.isEmpty()) return@launch val existingById = chatLocalDataSource .getMessagesByIds(chatId, remoteMessages.map { it.id }) .associateBy { it.id } - val entities = remoteMessages.mapNotNull { model -> - val existing = existingById[model.id] + var skippedCount = 0 + val entities = rawMessages.zip(remoteMessages).mapNotNull { (rawMessage, model) -> + val existing = existingById[rawMessage.id] if (existing != null && existing.isSamePersistedMessage(model)) { + skippedCount += 1 return@mapNotNull null } - messageRemoteDataSource.getMessage(model.chatId, model.id)?.let { message -> - messageMapper.mapToEntity(message, ::resolveSenderName) - } + messageMapper.mapToEntity(rawMessage, ::resolveSenderName) } if (entities.isNotEmpty()) { - chatLocalDataSource.insertMessages(entities) + traceSection("chat_history_persist") { + chatLocalDataSource.insertMessages(entities) + } + } + ChatOpenPerfBridge.recordPersist(chatId, threadId, entities.size, skippedCount) + if (BuildConfig.DEBUG) { + Log.d( + ChatOpenPerfDebug.TAG, + ChatOpenPerfDebug.buildLogMessage( + chatId = chatId, + threadId = threadId, + event = "chat_history_persist_end" + ) + ) } } } + private inline fun traceSection(section: String, block: () -> T): T { + Trace.beginSection(section) + return try { + block() + } finally { + Trace.endSection() + } + } + private fun org.monogram.data.db.model.MessageEntity.isSamePersistedMessage(model: MessageModel): Boolean { return id == model.id && chatId == model.chatId && diff --git a/data/src/main/java/org/monogram/data/repository/PinnedMessageVisibilityRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/PinnedMessageVisibilityRepositoryImpl.kt new file mode 100644 index 00000000..8ea803be --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/PinnedMessageVisibilityRepositoryImpl.kt @@ -0,0 +1,34 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import org.monogram.data.db.dao.KeyValueDao +import org.monogram.data.db.model.KeyValueEntity +import org.monogram.domain.repository.PinnedMessageVisibilityRepository + +class PinnedMessageVisibilityRepositoryImpl( + private val keyValueDao: KeyValueDao +) : PinnedMessageVisibilityRepository { + override fun observeHidden(chatId: Long): Flow = + keyValueDao.observeValue(key(chatId)) + .map { it != null } + .distinctUntilChanged() + + override suspend fun isHidden(chatId: Long): Boolean = + keyValueDao.getValue(key(chatId)) != null + + override suspend fun hide(chatId: Long) { + keyValueDao.insertValue(KeyValueEntity(key(chatId), "1")) + } + + override suspend fun show(chatId: Long) { + keyValueDao.deleteValue(key(chatId)) + } + + private fun key(chatId: Long): String = "$KEY_PREFIX$chatId" + + private companion object { + const val KEY_PREFIX = "hidden_pinned_message_" + } +} diff --git a/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt index 8d8f2583..0fd27277 100644 --- a/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt @@ -27,4 +27,8 @@ class PremiumRepositoryImpl( val tdType = limitType.toApi() ?: return 0 return remote.getPremiumLimit(tdType)?.premiumValue ?: 0 } -} \ No newline at end of file + + override suspend fun setSponsoredMessagesEnabled(enabled: Boolean) { + remote.setSponsoredMessagesEnabled(enabled) + } +} diff --git a/data/src/main/java/org/monogram/data/repository/SponsorRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/SponsorRepositoryImpl.kt index ec18f228..dab375d4 100644 --- a/data/src/main/java/org/monogram/data/repository/SponsorRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/SponsorRepositoryImpl.kt @@ -6,6 +6,8 @@ import org.monogram.domain.repository.SponsorRepository class SponsorRepositoryImpl( private val sponsorSyncManager: SponsorSyncManager ) : SponsorRepository { + override val sponsorState = sponsorSyncManager.sponsorState + override fun forceSponsorSync() { sponsorSyncManager.forceSync() } diff --git a/data/src/test/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSourceTest.kt b/data/src/test/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSourceTest.kt index 8a973e24..bf83bd70 100644 --- a/data/src/test/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSourceTest.kt +++ b/data/src/test/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSourceTest.kt @@ -2,6 +2,8 @@ package org.monogram.data.datasource.cache import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import org.monogram.data.db.model.ChatEntity import org.monogram.data.db.model.MessageEntity @@ -77,6 +79,48 @@ class InMemoryChatLocalDataSourceTest { assertEquals(listOf(30L, 40L), result.map { it.id }) } + @Test + fun `replaceMessage preserves ordering and replaces rich fields`() = runBlocking { + val source = InMemoryChatLocalDataSource() + source.insertMessage( + message( + id = 100L, + chatId = 1L, + content = "old", + date = 100, + replyMarkupData = "markup-old", + reactionsData = "reactions-old", + isPinned = false, + hasUnreadReactions = false + ) + ) + source.insertMessage(message(id = 200L, chatId = 1L, content = "newer", date = 200)) + + source.replaceMessage( + message( + id = 100L, + chatId = 1L, + content = "updated", + date = 100, + replyMarkupData = "markup-new", + reactionsData = "reactions-new", + isPinned = true, + hasUnreadReactions = true + ) + ) + + val result = source.getLatestMessages(chatId = 1L, limit = 10) + val replaced = result.last { it.id == 100L } + + assertEquals(listOf(200L, 100L), result.map { it.id }) + assertEquals("updated", replaced.content) + assertEquals("markup-new", replaced.replyMarkupData) + assertEquals("reactions-new", replaced.reactionsData) + assertTrue(replaced.isPinned) + assertTrue(replaced.hasUnreadReactions) + assertFalse(replaced.replyMarkupData == "markup-old") + } + private fun chat(id: Long, order: Long, isPinned: Boolean): ChatEntity = ChatEntity( id = id, @@ -96,7 +140,16 @@ class InMemoryChatLocalDataSourceTest { onlineCount = 0 ) - private fun message(id: Long, chatId: Long, content: String, date: Int = 100): MessageEntity = + private fun message( + id: Long, + chatId: Long, + content: String, + date: Int = 100, + replyMarkupData: String? = null, + reactionsData: String? = null, + isPinned: Boolean = false, + hasUnreadReactions: Boolean = false + ): MessageEntity = MessageEntity( id = id, chatId = chatId, @@ -105,6 +158,10 @@ class InMemoryChatLocalDataSourceTest { content = content, date = date, isOutgoing = true, - isRead = false + isRead = false, + replyMarkupData = replyMarkupData, + reactionsData = reactionsData, + isPinned = isPinned, + hasUnreadReactions = hasUnreadReactions ) } diff --git a/data/src/test/java/org/monogram/data/datasource/remote/TdChatRemoteSourceTest.kt b/data/src/test/java/org/monogram/data/datasource/remote/TdChatRemoteSourceTest.kt new file mode 100644 index 00000000..14a51302 --- /dev/null +++ b/data/src/test/java/org/monogram/data/datasource/remote/TdChatRemoteSourceTest.kt @@ -0,0 +1,54 @@ +package org.monogram.data.datasource.remote + +import android.net.ConnectivityManager +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.drinkless.tdlib.TdApi +import org.junit.Assert.assertEquals +import org.junit.Test +import org.monogram.data.gateway.TelegramGateway +import sun.misc.Unsafe +import java.lang.reflect.Field + +class TdChatRemoteSourceTest { + + @Test + fun `setChatDescription uses chat id directly`() = runBlocking { + val gateway = CapturingTelegramGateway() + val remote = TdChatRemoteSource( + gateway = gateway, + connectivityManager = unsafeConnectivityManager() + ) + + remote.setChatDescription(chatId = 123L, description = "New description") + + assertEquals(123L, gateway.lastSetChatDescription?.chatId) + assertEquals("New description", gateway.lastSetChatDescription?.description) + } + + private class CapturingTelegramGateway : TelegramGateway { + override val updates = MutableSharedFlow() + override val isAuthenticated = MutableStateFlow(false) + var lastSetChatDescription: TdApi.SetChatDescription? = null + + @Suppress("UNCHECKED_CAST") + override suspend fun execute(function: TdApi.Function): T { + return when (function) { + is TdApi.SetChatDescription -> { + lastSetChatDescription = function + TdApi.Ok() as T + } + + else -> error("Unexpected function: ${function.javaClass.simpleName}") + } + } + } + + private fun unsafeConnectivityManager(): ConnectivityManager { + val field: Field = Unsafe::class.java.getDeclaredField("theUnsafe") + field.isAccessible = true + val unsafe = field.get(null) as Unsafe + return unsafe.allocateInstance(ConnectivityManager::class.java) as ConnectivityManager + } +} diff --git a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt index f71c6f3d..b9210c65 100644 --- a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt +++ b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt @@ -394,10 +394,21 @@ class ConnectionManagerTest { chatId: Long, permissions: org.monogram.domain.models.ChatPermissionsModel ) = Unit + override suspend fun setChatHasProtectedContent( + chatId: Long, + hasProtectedContent: Boolean + ) = Unit + + override suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean) = Unit + override suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) = + Unit + override suspend fun setChatAvailableReactions( + chatId: Long, + availableReactions: List + ) = Unit override suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) = Unit override suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) = Unit - override suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean) = Unit override suspend fun getChatLink(chatId: Long): String? = null override suspend fun deleteFolder(folderId: Int) = Unit override suspend fun muteChat(chatId: Long, muteFor: Int) = Unit @@ -545,6 +556,7 @@ class ConnectionManagerTest { override val chatListMessageLines = MutableStateFlow(2) override val showChatListPhotos = MutableStateFlow(true) override val showReactions = MutableStateFlow(true) + override val showSponsoredMessagesForPremium = MutableStateFlow(false) override val privateChatsNotifications = MutableStateFlow(true) override val groupsNotifications = MutableStateFlow(true) override val channelsNotifications = MutableStateFlow(true) @@ -593,6 +605,10 @@ class ConnectionManagerTest { isAutoBestProxyEnabled.value = enabled } + override fun setShowSponsoredMessagesForPremium(enabled: Boolean) { + showSponsoredMessagesForPremium.value = enabled + } + override fun setProxyNetworkMode(networkType: ProxyNetworkType, mode: ProxyNetworkMode) { proxyNetworkRules.value = proxyNetworkRules.value.toMutableMap().apply { this[networkType] = (this[networkType] ?: ProxyNetworkRule(mode)).copy(mode = mode) diff --git a/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt b/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt new file mode 100644 index 00000000..6a287135 --- /dev/null +++ b/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt @@ -0,0 +1,117 @@ +package org.monogram.data.infra + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.drinkless.tdlib.TdApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.core.DispatcherProvider +import org.monogram.data.chats.ChatCache +import org.monogram.data.gateway.TelegramGateway + +@OptIn(ExperimentalCoroutinesApi::class) +class FileDownloadQueueTest { + + @Test + fun `cancel pending download notifies observer without waiting for update file`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val queue = createQueue(dispatcher, this) + val cancelled = mutableListOf() + queue.setObserver(object : FileDownloadQueue.Observer { + override fun onDownloadCancelled(fileId: Int) { + cancelled += fileId + } + }) + queue.registry.register(fileId = 42, chatId = 1L, messageId = 2L) + + queue.enqueue(fileId = 42, priority = 1, type = FileDownloadQueue.DownloadType.DEFAULT) + runCurrent() + + queue.cancelDownload(fileId = 42, force = true, suppress = false) + runCurrent() + + assertEquals(listOf(42), cancelled) + assertTrue(!queue.isFileQueued(42)) + } + + @Test + fun `manual enqueue after suppression is accepted`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val queue = createQueue(dispatcher, this) + + queue.cancelDownload(fileId = 73, force = true, suppress = true) + runCurrent() + + queue.enqueue( + fileId = 73, + priority = 32, + type = FileDownloadQueue.DownloadType.DEFAULT, + ignoreSuppression = true + ) + runCurrent() + + assertTrue(queue.isFileQueued(73)) + } + + private fun createQueue( + dispatcher: CoroutineDispatcher, + scope: TestScope + ): FileDownloadQueue { + return FileDownloadQueue( + gateway = FakeTelegramGateway(), + registry = FileMessageRegistry(), + cache = ChatCache(), + scope = scope.backgroundScope, + dispatcherProvider = TestDispatcherProvider(dispatcher) + ) + } + + private class TestDispatcherProvider( + private val dispatcher: CoroutineDispatcher + ) : DispatcherProvider { + override val main: CoroutineDispatcher = dispatcher + override val io: CoroutineDispatcher = dispatcher + override val default: CoroutineDispatcher = dispatcher + override val mainImmediate: CoroutineDispatcher = dispatcher + } + + private class FakeTelegramGateway : TelegramGateway { + override val updates = MutableSharedFlow() + override val isAuthenticated = MutableStateFlow(false) + + @Suppress("UNCHECKED_CAST") + override suspend fun execute(function: TdApi.Function): T { + return when (function) { + is TdApi.CancelDownloadFile -> TdApi.Ok() as T + is TdApi.GetFile -> TdApi.File().apply { + id = function.fileId + local = TdApi.LocalFile().apply { + path = "" + isDownloadingActive = false + isDownloadingCompleted = false + } + remote = TdApi.RemoteFile() + } as T + + is TdApi.DownloadFile -> TdApi.File().apply { + id = function.fileId + local = TdApi.LocalFile().apply { + path = "" + isDownloadingActive = false + isDownloadingCompleted = false + } + remote = TdApi.RemoteFile() + } as T + + else -> error("Unexpected function ${function.javaClass.simpleName}") + } + } + } +} diff --git a/data/src/test/java/org/monogram/data/infra/SponsorSyncManagerTest.kt b/data/src/test/java/org/monogram/data/infra/SponsorSyncManagerTest.kt new file mode 100644 index 00000000..edfc53e2 --- /dev/null +++ b/data/src/test/java/org/monogram/data/infra/SponsorSyncManagerTest.kt @@ -0,0 +1,200 @@ +package org.monogram.data.infra + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.drinkless.tdlib.TdApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.data.db.dao.SponsorDao +import org.monogram.data.db.model.SponsorEntity +import org.monogram.data.gateway.TelegramGateway +import org.monogram.domain.repository.AuthError +import org.monogram.domain.repository.AuthRepository +import org.monogram.domain.repository.AuthStep +import org.monogram.domain.repository.AuthUiStatus + +@OptIn(ExperimentalCoroutinesApi::class) +class SponsorSyncManagerTest { + + @Test + fun `loads cached sponsor ids into state on start`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val environment = TestEnvironment( + scope = TestScope(dispatcher), + dispatcher = dispatcher, + cachedIds = listOf(11L, 22L) + ) + + environment.createManager() + environment.flush() + + val state = environment.manager.sponsorState.value + assertEquals(setOf(11L, 22L), state.supporterIds) + assertEquals(2, state.supportersCount) + assertTrue(state.isLoaded) + assertFalse(state.isSyncInProgress) + environment.close() + } + + @Test + fun `successful empty sync marks state as loaded`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val environment = TestEnvironment( + scope = TestScope(dispatcher), + dispatcher = dispatcher, + cachedIds = emptyList() + ).apply { + historyResult = TdApi.Messages(0, emptyArray()) + } + + environment.createManager() + environment.flush() + + environment.manager.forceSync() + environment.flush() + + val state = environment.manager.sponsorState.value + assertTrue(state.isLoaded) + assertEquals(0, state.supportersCount) + assertTrue(state.supporterIds.isEmpty()) + assertFalse(state.isSyncInProgress) + environment.close() + } + + @Test + fun `manual sync publishes parsed supporter count`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val environment = TestEnvironment( + scope = TestScope(dispatcher), + dispatcher = dispatcher, + cachedIds = emptyList() + ).apply { + historyResult = TdApi.Messages( + 0, + arrayOf( + sponsorMessage(3L, "100, 200"), + sponsorMessage(2L, "300") + ) + ) + } + + environment.createManager() + environment.flush() + + environment.manager.forceSync() + environment.flush() + + val state = environment.manager.sponsorState.value + assertTrue(state.isLoaded) + assertEquals(3, state.supportersCount) + assertEquals(setOf(100L, 200L, 300L), state.supporterIds) + assertTrue(state.lastSyncAt > 0L) + environment.close() + } + + private fun sponsorMessage(id: Long, text: String): TdApi.Message { + return TdApi.Message().apply { + this.id = id + this.chatId = -1003640797855L + this.content = TdApi.MessageText(TdApi.FormattedText(text, emptyArray()), null, null) + } + } + + private class TestEnvironment( + val scope: TestScope, + private val dispatcher: CoroutineDispatcher, + cachedIds: List + ) { + val sponsorDao = FakeSponsorDao(cachedIds) + val gateway = FakeTelegramGateway() + val authRepository = FakeAuthRepository() + lateinit var manager: SponsorSyncManager + var historyResult: TdApi.Messages = TdApi.Messages(0, emptyArray()) + + fun createManager() { + gateway.historyProvider = { historyResult } + manager = SponsorSyncManager( + scope = scope, + gateway = gateway, + sponsorDao = sponsorDao, + authRepository = authRepository, + ioDispatcher = dispatcher + ) + } + + fun flush() { + scope.testScheduler.runCurrent() + } + + fun close() { + scope.cancel() + } + } + + private class FakeSponsorDao( + cachedIds: List + ) : SponsorDao { + private val items = linkedMapOf().apply { + cachedIds.forEach { id -> + put(id, SponsorEntity(userId = id, sourceChannelId = 0L, updatedAt = 1L)) + } + } + + override suspend fun getAllIds(): List = items.keys.toList() + + override suspend fun insertAll(items: List) { + items.forEach { this.items[it.userId] = it } + } + + override suspend fun clearAll() { + items.clear() + } + + override suspend fun deleteNotIn(ids: List) { + items.keys.retainAll(ids.toSet()) + } + + override suspend fun getLatestUpdatedAt(): Long? = items.values.maxOfOrNull { it.updatedAt } + } + + private class FakeTelegramGateway : TelegramGateway { + private val _updates = MutableSharedFlow() + private val _isAuthenticated = MutableStateFlow(true) + var historyProvider: suspend () -> TdApi.Messages = { TdApi.Messages(0, emptyArray()) } + + override suspend fun execute(function: TdApi.Function): T { + @Suppress("UNCHECKED_CAST") + return when (function) { + is TdApi.SearchPublicChat -> TdApi.Chat().apply { id = -1003640797855L } as T + is TdApi.GetChatHistory -> historyProvider() as T + else -> error("Unexpected function ${function::class.java.simpleName}") + } + } + + override val updates: SharedFlow = _updates + override val isAuthenticated: StateFlow = _isAuthenticated + } + + private class FakeAuthRepository : AuthRepository { + override val authState = MutableStateFlow(AuthStep.Ready) + override val authUiStatus = MutableStateFlow(AuthUiStatus.Idle) + override val errors = MutableSharedFlow() + + override fun sendPhone(phone: String) = Unit + override fun resendCode() = Unit + override fun sendCode(code: String) = Unit + override fun sendPassword(password: String) = Unit + override fun retryLastAction() = Unit + override fun reset() = Unit + } +} diff --git a/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt b/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt new file mode 100644 index 00000000..236d26d8 --- /dev/null +++ b/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt @@ -0,0 +1,196 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.drinkless.tdlib.TdApi +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.data.datasource.remote.UserRemoteDataSource +import org.monogram.domain.models.ChatFullInfoModel +import org.monogram.domain.models.UserModel +import org.monogram.domain.repository.UserRepository + +class ContactEditRepositoryImplTest { + + @Test + fun `upsertContact forwards explicit share phone flag`() = runTest { + val remote = FakeUserRemoteDataSource() + val repository = ContactEditRepositoryImpl( + userRepository = FakeUserRepository(), + userRemoteDataSource = remote + ) + + repository.upsertContact( + user = UserModel( + id = 7L, + firstName = "Ada", + lastName = "Lovelace", + phoneNumber = "+123" + ), + sharePhoneNumber = false + ) + + assertEquals(7L, remote.addContactUserId) + assertFalse(remote.addContactSharePhoneNumber) + assertEquals("Ada", remote.addContact?.firstName) + assertEquals("Lovelace", remote.addContact?.lastName) + } + + @Test + fun `setCloseFriend appends user id when enabling`() = runTest { + val remote = FakeUserRemoteDataSource(closeFriendIds = longArrayOf(1L, 2L)) + val repository = ContactEditRepositoryImpl( + userRepository = FakeUserRepository(), + userRemoteDataSource = remote + ) + + repository.setCloseFriend(userId = 7L, isCloseFriend = true) + + assertArrayEquals(longArrayOf(1L, 2L, 7L), remote.recordedCloseFriendIds) + } + + @Test + fun `setCloseFriend removes user id when disabling`() = runTest { + val remote = FakeUserRemoteDataSource(closeFriendIds = longArrayOf(1L, 7L, 2L)) + val repository = ContactEditRepositoryImpl( + userRepository = FakeUserRepository(), + userRemoteDataSource = remote + ) + + repository.setCloseFriend(userId = 7L, isCloseFriend = false) + + assertArrayEquals(longArrayOf(1L, 2L), remote.recordedCloseFriendIds) + } + + @Test + fun `getNeedPhoneNumberPrivacyException delegates to user full info`() = runTest { + val repository = ContactEditRepositoryImpl( + userRepository = FakeUserRepository( + fullInfo = ChatFullInfoModel(needPhoneNumberPrivacyException = true) + ), + userRemoteDataSource = FakeUserRemoteDataSource() + ) + + assertTrue(repository.getNeedPhoneNumberPrivacyException(7L)) + } +} + +private class FakeUserRepository( + private val user: UserModel? = UserModel(id = 7L, firstName = "Ada"), + private val fullInfo: ChatFullInfoModel? = null +) : UserRepository { + override val currentUserFlow = MutableStateFlow(null) + override val anyUserUpdateFlow: Flow = emptyFlow() + + override suspend fun getMe(): UserModel = user ?: UserModel(0L, "Unknown") + override suspend fun getUser(userId: Long): UserModel? = user + override suspend fun getUserFullInfo(userId: Long): UserModel? = user + override suspend fun refreshUserFullInfo(userId: Long) = Unit + override suspend fun resolveUserChatFullInfo(userId: Long): ChatFullInfoModel? = fullInfo + override fun getUserFlow(userId: Long): Flow = emptyFlow() + override fun logOut() = Unit + override suspend fun getContacts(): List = emptyList() + override suspend fun searchContacts(query: String): List = emptyList() + override suspend fun addContact(user: UserModel) = Unit + override suspend fun removeContact(userId: Long) = Unit + override suspend fun setCachedSimCountryIso(iso: String?) = Unit +} + +private class FakeUserRemoteDataSource( + private val closeFriendIds: LongArray = longArrayOf() +) : UserRemoteDataSource { + var addContactUserId: Long = 0L + var addContact: TdApi.ImportedContact? = null + var addContactSharePhoneNumber: Boolean = true + var recordedCloseFriendIds: LongArray = longArrayOf() + + override suspend fun getUser(userId: Long): TdApi.User? = null + override suspend fun getMe(): TdApi.User? = null + override suspend fun getUserFullInfo(userId: Long): TdApi.UserFullInfo? = null + override suspend fun getSupergroupFullInfo(supergroupId: Long): TdApi.SupergroupFullInfo? = null + override suspend fun getBasicGroupFullInfo(basicGroupId: Long): TdApi.BasicGroupFullInfo? = null + override suspend fun getSupergroup(supergroupId: Long): TdApi.Supergroup? = null + override suspend fun getChat(chatId: Long): TdApi.Chat? = null + override suspend fun getMessage(chatId: Long, messageId: Long): TdApi.Message? = null + override suspend fun getUserProfilePhotos( + userId: Long, + offset: Int, + limit: Int + ): TdApi.ChatPhotos? = null + + override suspend fun getContacts(): TdApi.Users? = null + override suspend fun searchContacts(query: String): TdApi.Users? = null + override suspend fun addContact( + userId: Long, + contact: TdApi.ImportedContact, + sharePhoneNumber: Boolean + ) { + addContactUserId = userId + addContact = contact + addContactSharePhoneNumber = sharePhoneNumber + } + + override suspend fun removeContacts(userIds: LongArray) = Unit + override suspend fun getCloseFriendIds(): LongArray = closeFriendIds + override suspend fun setCloseFriendIds(userIds: LongArray) { + recordedCloseFriendIds = userIds + } + + override suspend fun sharePhoneNumber(userId: Long) = Unit + override suspend fun searchPublicChat(username: String): TdApi.Chat? = null + override suspend fun getChatMember(chatId: Long, userId: Long): TdApi.ChatMember? = null + override suspend fun getSupergroupMembers( + supergroupId: Long, + filter: TdApi.SupergroupMembersFilter, + offset: Int, + limit: Int + ): TdApi.ChatMembers? = null + + override suspend fun getBasicGroupMembers(basicGroupId: Long): TdApi.BasicGroupFullInfo? = null + override suspend fun getPremiumState(): TdApi.PremiumState? = null + override suspend fun getPremiumFeatures(source: TdApi.PremiumSource): TdApi.PremiumFeatures? = + null + + override suspend fun getPremiumLimit(limitType: TdApi.PremiumLimitType): TdApi.PremiumLimit? = + null + + override suspend fun setSponsoredMessagesEnabled(enabled: Boolean) = Unit + override suspend fun getBotFullInfo(userId: Long): TdApi.UserFullInfo? = null + override suspend fun getChatStatistics(chatId: Long, isDark: Boolean): TdApi.ChatStatistics? = + null + + override suspend fun getChatRevenueStatistics( + chatId: Long, + isDark: Boolean + ): TdApi.ChatRevenueStatistics? = null + + override suspend fun getStatisticsGraph( + chatId: Long, + token: String, + x: Long + ): TdApi.StatisticalGraph? = null + + override suspend fun logout() = Unit + override suspend fun setName(firstName: String, lastName: String) = Unit + override suspend fun setBio(bio: String) = Unit + override suspend fun setUsername(username: String) = Unit + override suspend fun setEmojiStatus(customEmojiId: Long?) = Unit + override suspend fun setProfilePhoto(path: String) = Unit + override suspend fun setBirthdate(birthdate: TdApi.Birthdate?) = Unit + override suspend fun setPersonalChat(chatId: Long) = Unit + override suspend fun setBusinessBio(bio: String) = Unit + override suspend fun setBusinessLocation(location: TdApi.BusinessLocation?) = Unit + override suspend fun setBusinessOpeningHours(hours: TdApi.BusinessOpeningHours?) = Unit + override suspend fun toggleUsernameIsActive(username: String, isActive: Boolean) = Unit + override suspend fun reorderActiveUsernames(usernames: Array) = Unit + override suspend fun setChatMemberStatus( + chatId: Long, + userId: Long, + status: TdApi.ChatMemberStatus + ) = Unit +} diff --git a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt index 20cdb36b..d01a2be2 100644 --- a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt @@ -1,5 +1,6 @@ package org.monogram.domain.models +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -32,6 +33,9 @@ data class ChatModel( val hasProtectedContent: Boolean = false, val isTranslatable: Boolean = false, val hasAutomaticTranslation: Boolean = false, + val signMessages: Boolean = false, + val joinToSendMessages: Boolean = false, + val availableReactions: ChatAvailableReactionsModel = ChatAvailableReactionsModel.All, val messageAutoDeleteTime: Int = 0, val canBeDeletedOnlyForSelf: Boolean = false, val canBeDeletedForAllUsers: Boolean = false, @@ -84,6 +88,21 @@ data class ChatModel( enum class ChatType { PRIVATE, BASIC_GROUP, SUPERGROUP, SECRET } + +@Serializable +sealed class ChatAvailableReactionsModel { + @Serializable + @SerialName("all") + data object All : ChatAvailableReactionsModel() + + @Serializable + @SerialName("some") + data class Some( + val reactions: List = emptyList(), + val maxReactionCount: Int = 1 + ) : ChatAvailableReactionsModel() +} + @Serializable data class ChatPermissionsModel( val canSendBasicMessages: Boolean = true, diff --git a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt index 19e69807..3f54c8f5 100644 --- a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt @@ -46,9 +46,40 @@ data class MessageModel( val viaBotName: String? = null, val isSenderPremium: Boolean = false, val senderStatusEmojiId: Long = 0L, - val senderStatusEmojiPath: String? = null + val senderStatusEmojiPath: String? = null, + val isPinned: Boolean = false, + val hasUnreadMention: Boolean = false, + val hasUnreadReactions: Boolean = false, + val containsUnreadPollVotes: Boolean = false, + val factCheck: FactCheckModel? = null, + val suggestedPostInfo: SuggestedPostInfoModel? = null ) +data class FactCheckModel( + val text: String, + val entities: List = emptyList(), + val countryCode: String? = null +) + +data class SuggestedPostInfoModel( + val price: SuggestedPostPriceModel? = null, + val sendDate: Int = 0, + val state: SuggestedPostStateModel = SuggestedPostStateModel.PENDING, + val canBeApproved: Boolean = false, + val canBeDeclined: Boolean = false +) + +sealed interface SuggestedPostPriceModel { + data class Star(val starCount: Long) : SuggestedPostPriceModel + data class Ton(val toncoinCentCount: Long) : SuggestedPostPriceModel +} + +enum class SuggestedPostStateModel { + PENDING, + APPROVED, + DECLINED +} + data class ForwardInfo( val date: Int, val fromId: Long, diff --git a/domain/src/main/java/org/monogram/domain/models/SponsoredMessageModel.kt b/domain/src/main/java/org/monogram/domain/models/SponsoredMessageModel.kt new file mode 100644 index 00000000..41a4f057 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/SponsoredMessageModel.kt @@ -0,0 +1,25 @@ +package org.monogram.domain.models + +data class AdvertisementSponsorModel( + val url: String, + val photoPath: String? = null, + val info: String? = null +) + +data class SponsoredMessageModel( + val messageId: Long, + val isRecommended: Boolean, + val canBeReported: Boolean, + val title: String? = null, + val buttonText: String? = null, + val additionalInfo: String? = null, + val accentColorId: Int = 0, + val backgroundCustomEmojiId: Long = 0L, + val content: MessageContent, + val sponsor: AdvertisementSponsorModel +) + +data class SponsoredMessagesFeedModel( + val messages: List, + val messagesBetween: Int +) diff --git a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt index 97e52340..d33ebbf1 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt @@ -72,6 +72,7 @@ interface AppPreferencesProvider { val chatListMessageLines: StateFlow val showChatListPhotos: StateFlow val showReactions: StateFlow + val showSponsoredMessagesForPremium: StateFlow val privateChatsNotifications: StateFlow val groupsNotifications: StateFlow @@ -128,6 +129,7 @@ interface AppPreferencesProvider { fun setChatListMessageLines(lines: Int) fun setShowChatListPhotos(enabled: Boolean) fun setShowReactions(enabled: Boolean) + fun setShowSponsoredMessagesForPremium(enabled: Boolean) fun setPrivateChatsNotifications(enabled: Boolean) fun setGroupsNotifications(enabled: Boolean) diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatOperationsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatOperationsRepository.kt index 7c7b3c3b..de439ac1 100644 --- a/domain/src/main/java/org/monogram/domain/repository/ChatOperationsRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/ChatOperationsRepository.kt @@ -6,20 +6,25 @@ interface ChatOperationsRepository { val isArchivePinned: StateFlow val isArchiveAlwaysVisible: StateFlow - fun toggleMuteChats(chatIds: Set, mute: Boolean) - fun toggleArchiveChats(chatIds: Set, archive: Boolean) - fun togglePinChats(chatIds: Set, pin: Boolean, folderId: Int) - fun toggleReadChats(chatIds: Set, markAsUnread: Boolean) + suspend fun toggleMuteChats(chatIds: Set, mute: Boolean) + suspend fun toggleArchiveChats(chatIds: Set, archive: Boolean) + suspend fun togglePinChats(chatIds: Set, pin: Boolean, folderId: Int) + suspend fun toggleReadChats(chatIds: Set, markAsUnread: Boolean) fun markChatsAsRead(chatIds: Set) fun markFolderAsRead(folderId: Int, chatIds: Set) - fun deleteChats(chatIds: Set) - fun leaveChats(chatIds: Set) - fun leaveChat(chatId: Long) + suspend fun deleteChats(chatIds: Set) + suspend fun leaveChats(chatIds: Set) + suspend fun leaveChat(chatId: Long) fun setArchivePinned(pinned: Boolean) - fun clearChatHistories(chatIds: Set, revoke: Boolean) - fun clearChatHistory(chatId: Long, revoke: Boolean) + suspend fun clearChatHistories(chatIds: Set, revoke: Boolean) + suspend fun clearChatHistory(chatId: Long, revoke: Boolean) suspend fun getChatLink(chatId: Long): String? - fun reportChats(chatIds: Set, reason: String, messageIds: List = emptyList()) - fun reportChat(chatId: Long, reason: String, messageIds: List = emptyList()) -} \ No newline at end of file + suspend fun reportChats( + chatIds: Set, + reason: String, + messageIds: List = emptyList() + ) + + suspend fun reportChat(chatId: Long, reason: String, messageIds: List = emptyList()) +} diff --git a/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt index 95dedd02..aee0552d 100644 --- a/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/ChatSettingsRepository.kt @@ -8,7 +8,10 @@ interface ChatSettingsRepository { suspend fun setChatDescription(chatId: Long, description: String) suspend fun setChatUsername(chatId: Long, username: String) suspend fun setChatPermissions(chatId: Long, permissions: ChatPermissionsModel) + suspend fun setChatHasProtectedContent(chatId: Long, hasProtectedContent: Boolean) + suspend fun setChatSignMessages(chatId: Long, signMessages: Boolean) + suspend fun setChatJoinToSendMessages(chatId: Long, joinToSendMessages: Boolean) + suspend fun setChatAvailableReactions(chatId: Long, availableReactions: List) suspend fun setChatSlowModeDelay(chatId: Long, slowModeDelay: Int) suspend fun toggleChatIsForum(chatId: Long, isForum: Boolean) - suspend fun toggleChatIsTranslatable(chatId: Long, isTranslatable: Boolean) } \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/repository/ContactEditRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ContactEditRepository.kt new file mode 100644 index 00000000..648fc8b8 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ContactEditRepository.kt @@ -0,0 +1,21 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.UserModel + +interface ContactEditRepository { + suspend fun getContact(userId: Long): UserModel? + suspend fun getNeedPhoneNumberPrivacyException(userId: Long): Boolean + suspend fun upsertContact( + user: UserModel, + sharePhoneNumber: Boolean + ): UserModel? + + suspend fun removeContact(userId: Long) + suspend fun setCloseFriend( + userId: Long, + isCloseFriend: Boolean + ): UserModel? + + val supportsPersonalPhotoEditing: Boolean + get() = false +} diff --git a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt index ee6e2dc2..7972bfb0 100644 --- a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt @@ -13,6 +13,7 @@ import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageUploadProgressEvent import org.monogram.domain.models.MessageViewerModel import org.monogram.domain.models.PollDraft +import org.monogram.domain.models.SponsoredMessagesFeedModel import org.monogram.domain.models.UserModel import org.monogram.domain.models.webapp.InstantViewModel @@ -131,7 +132,11 @@ interface MessageRepository : threadId: Long? = null ): OlderMessagesPage - suspend fun getCachedMessages(chatId: Long, limit: Int): List + suspend fun getCachedMessages( + chatId: Long, + limit: Int, + threadId: Long? = null + ): List suspend fun getMessagesNewer( chatId: Long, @@ -143,13 +148,15 @@ interface MessageRepository : suspend fun getCachedMessagesNewer( chatId: Long, fromMessageId: Long, - limit: Int + limit: Int, + threadId: Long? = null ): List suspend fun getCachedMessagesAround( chatId: Long, messageId: Long, - limit: Int + limit: Int, + threadId: Long? = null ): List suspend fun getMessagesAround(chatId: Long, messageId: Long, limit: Int, threadId: Long? = null): List @@ -262,6 +269,13 @@ interface MessageRepository : suspend fun getWebPageInstantView(url: String, forceFull: Boolean = false): InstantViewModel? suspend fun getFullRichMessage(chatId: Long, messageId: Long): MessageContent.RichMessage? suspend fun getDraftLinkPreview(request: DraftLinkPreviewRequest): DraftLinkPreview? + suspend fun getChannelSponsoredMessages(chatId: Long): SponsoredMessagesFeedModel? + suspend fun clickChannelSponsoredMessage( + chatId: Long, + messageId: Long, + isMediaClick: Boolean, + fromFullscreen: Boolean = false + ) suspend fun searchMessages( chatId: Long, diff --git a/domain/src/main/java/org/monogram/domain/repository/PinnedMessageVisibilityRepository.kt b/domain/src/main/java/org/monogram/domain/repository/PinnedMessageVisibilityRepository.kt new file mode 100644 index 00000000..43f95a4b --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/PinnedMessageVisibilityRepository.kt @@ -0,0 +1,10 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.Flow + +interface PinnedMessageVisibilityRepository { + fun observeHidden(chatId: Long): Flow + suspend fun isHidden(chatId: Long): Boolean + suspend fun hide(chatId: Long) + suspend fun show(chatId: Long) +} diff --git a/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt b/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt index 3b25e541..b8de6ece 100644 --- a/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt @@ -9,4 +9,5 @@ interface PremiumRepository { suspend fun getPremiumState(): PremiumStateModel? suspend fun getPremiumFeatures(source: PremiumSource): List suspend fun getPremiumLimit(limitType: PremiumLimitType): Int + suspend fun setSponsoredMessagesEnabled(enabled: Boolean) } diff --git a/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt b/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt index cb0a34d1..4528778c 100644 --- a/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt @@ -12,7 +12,7 @@ enum class UnifiedPushDebugStatus { data class PushDiagnostics( val pushProvider: PushProvider = PushProvider.FCM, - val backgroundServiceEnabled: Boolean = true, + val backgroundServiceEnabled: Boolean = false, val hideForegroundNotification: Boolean = false, val isPowerSavingMode: Boolean = false, val isWakeLockEnabled: Boolean = false, diff --git a/domain/src/main/java/org/monogram/domain/repository/SponsorRepository.kt b/domain/src/main/java/org/monogram/domain/repository/SponsorRepository.kt index 89fa0bbf..12c4b16a 100644 --- a/domain/src/main/java/org/monogram/domain/repository/SponsorRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/SponsorRepository.kt @@ -1,5 +1,17 @@ package org.monogram.domain.repository +import kotlinx.coroutines.flow.StateFlow + interface SponsorRepository { + val sponsorState: StateFlow + fun forceSponsorSync() } + +data class SponsorState( + val supporterIds: Set = emptySet(), + val supportersCount: Int = 0, + val isLoaded: Boolean = false, + val isSyncInProgress: Boolean = false, + val lastSyncAt: Long = 0L +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4305740b..9c94067f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,9 @@ [versions] # Plugins -agp = "9.2.1" +agp = "9.3.0-rc01" exifinterface = "1.4.2" kotlin = "2.3.21" -google-services = "4.4.4" +google-services = "4.5.0" ossLicensesPlugin = "0.12.0" ksp = "2.3.6" baselineprofile = "1.5.0-alpha06" @@ -14,10 +14,10 @@ javaToolchain = "21" androidx-activityCompose = "1.13.0" androidx-biometric = "1.4.0-alpha07" androidx-camera = "1.6.1" -androidx-compose-bom = "2026.05.01" -androidx-compose-runtime = "1.11.2" -androidx-compose-ui = "1.11.2" -androidx-material3 = "1.5.0-alpha21" +androidx-compose-bom = "2026.06.00" +androidx-compose-runtime = "1.11.3" +androidx-compose-ui = "1.11.3" +androidx-material3 = "1.5.0-alpha22" androidx-adaptive = "1.2.0" androidx-media3 = "1.10.1" androidx-securityCrypto = "1.1.0" @@ -27,7 +27,7 @@ androidx-benchmark-macro-junit4 = "1.4.1" androidx-test-ext-junit = "1.3.0" androidx-window = "1.5.1" androidx-core-splashscreen = "1.2.0" -androidx-lifecycle = "2.10.0" +androidx-lifecycle = "2.11.0" # KotlinX kotlinx-coroutines = "1.11.0" @@ -36,20 +36,20 @@ kotlinx-serialization = "1.11.0" # Architecture & DI decompose = "3.5.0" mvikotlin = "4.4.0" -koin = "4.2.1" +koin = "4.2.2" # UI & Media coil = "3.5.0" maplibre = "1.7.0" # Google & Firebase -firebase-bom = "34.14.1" +firebase-bom = "34.15.0" playServices-ossLicenses = "17.5.1" # Others zxing = "3.5.4" junit = "4.13.2" -libphonenumber = "9.0.32" +libphonenumber = "9.0.33" unifiedpush-connector = "3.3.3" json = "20260522" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c61a118f..70688ca4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ +#Fri Jun 26 12:53:50 MSK 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip -networkTimeout=10000 -validateDistributionUrl=true +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/ScreenSwipeBack.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/ScreenSwipeBack.kt new file mode 100644 index 00000000..d70f2aef --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/ScreenSwipeBack.kt @@ -0,0 +1,23 @@ +package org.monogram.presentation.core.ui + +import androidx.compose.runtime.Immutable + +@Immutable +data class ScreenSwipeBackState( + val isSupported: Boolean = false, + val isBlocked: Boolean = false, + val action: ScreenSwipeBackAction = ScreenSwipeBackAction.StackPop, + val preview: ScreenSwipeBackPreview = ScreenSwipeBackPreview.PreviousStackEntry, +) + +enum class ScreenSwipeBackAction { + StackPop, + LocalChatTopicClose, + LocalChatListArchiveReturn, +} + +enum class ScreenSwipeBackPreview { + PreviousStackEntry, + ChatForumList, + ChatListFolder, +} diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt index 0ace0ba7..a2bc3f85 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt @@ -270,7 +270,7 @@ class AppPreferences( private val _pinnedMessagesNotifications = MutableStateFlow(prefs.getBoolean(KEY_PINNED_MESSAGES, true)) override val pinnedMessagesNotifications: StateFlow = _pinnedMessagesNotifications - private val _backgroundServiceEnabled = MutableStateFlow(prefs.getBoolean(KEY_BACKGROUND_SERVICE_ENABLED, true)) + private val _backgroundServiceEnabled = MutableStateFlow(prefs.getBoolean(KEY_BACKGROUND_SERVICE_ENABLED, false)) override val backgroundServiceEnabled: StateFlow = _backgroundServiceEnabled private val _isPowerSavingMode = MutableStateFlow(prefs.getBoolean(KEY_POWER_SAVING_MODE, false)) @@ -334,6 +334,11 @@ class AppPreferences( private val _showReactions = MutableStateFlow(prefs.getBoolean(KEY_SHOW_REACTIONS, true)) override val showReactions: StateFlow = _showReactions + private val _showSponsoredMessagesForPremium = + MutableStateFlow(prefs.getBoolean(KEY_SHOW_SPONSORED_MESSAGES_FOR_PREMIUM, false)) + override val showSponsoredMessagesForPremium: StateFlow = + _showSponsoredMessagesForPremium + private val _showAllChatsFolder = MutableStateFlow(prefs.getBoolean(KEY_SHOW_ALL_CHATS_FOLDER, true)) val showAllChatsFolder: StateFlow = _showAllChatsFolder @@ -443,6 +448,14 @@ class AppPreferences( private val _isSupportViewed = MutableStateFlow(prefs.getBoolean(KEY_SUPPORT_VIEWED, false)) override val isSupportViewed: StateFlow = _isSupportViewed + private val _isProjectChannelPromoDismissed = + MutableStateFlow(prefs.getBoolean(KEY_PROJECT_CHANNEL_PROMO_DISMISSED, false)) + val isProjectChannelPromoDismissed: StateFlow = _isProjectChannelPromoDismissed + + private val _isProjectChannelSubscribed = + MutableStateFlow(prefs.getBoolean(KEY_PROJECT_CHANNEL_SUBSCRIBED, false)) + val isProjectChannelSubscribed: StateFlow = _isProjectChannelSubscribed + init { if (_adBlockKeywords.value.isEmpty()) { externalScope.launch { @@ -990,6 +1003,11 @@ class AppPreferences( _showReactions.value = enabled } + override fun setShowSponsoredMessagesForPremium(enabled: Boolean) { + prefs.edit().putBoolean(KEY_SHOW_SPONSORED_MESSAGES_FOR_PREMIUM, enabled).apply() + _showSponsoredMessagesForPremium.value = enabled + } + fun setShowAllChatsFolder(enabled: Boolean) { prefs.edit().putBoolean(KEY_SHOW_ALL_CHATS_FOLDER, enabled).apply() _showAllChatsFolder.value = enabled @@ -1177,7 +1195,7 @@ class AppPreferences( _inAppPreview.value = true _contactJoinedNotifications.value = true _pinnedMessagesNotifications.value = true - _backgroundServiceEnabled.value = true + _backgroundServiceEnabled.value = false _isPowerSavingMode.value = false _isWakeLockEnabled.value = false _hideForegroundNotification.value = false @@ -1195,6 +1213,7 @@ class AppPreferences( _isChatAnimationsEnabled.value = true _chatListMessageLines.value = 1 _showChatListPhotos.value = true + _showSponsoredMessagesForPremium.value = false _showAllChatsFolder.value = true _isTabletInterfaceEnabled.value = true _isAdBlockEnabled.value = false @@ -1212,6 +1231,8 @@ class AppPreferences( _proxyNetworkRules.value = readProxyNetworkRules() _userProxyBackups.value = emptySet() _isPermissionRequested.value = false + _isProjectChannelPromoDismissed.value = false + _isProjectChannelSubscribed.value = false } override fun clearSecurePreferences() { @@ -1225,6 +1246,16 @@ class AppPreferences( _isSupportViewed.value = viewed } + fun setProjectChannelPromoDismissed(dismissed: Boolean) { + prefs.edit().putBoolean(KEY_PROJECT_CHANNEL_PROMO_DISMISSED, dismissed).apply() + _isProjectChannelPromoDismissed.value = dismissed + } + + fun setProjectChannelSubscribed(subscribed: Boolean) { + prefs.edit().putBoolean(KEY_PROJECT_CHANNEL_SUBSCRIBED, subscribed).apply() + _isProjectChannelSubscribed.value = subscribed + } + private fun loadPushProvider(): PushProvider { val byName = prefs.getString(KEY_PUSH_PROVIDER_NAME, null) ?.let { stored -> PushProvider.entries.firstOrNull { it.name == stored } } @@ -1337,6 +1368,8 @@ class AppPreferences( private const val KEY_CHAT_LIST_MESSAGE_LINES = "chat_list_message_lines" private const val KEY_SHOW_CHAT_LIST_PHOTOS = "show_chat_list_photos" private const val KEY_SHOW_REACTIONS = "show_reactions" + private const val KEY_SHOW_SPONSORED_MESSAGES_FOR_PREMIUM = + "show_sponsored_messages_for_premium" private const val KEY_SHOW_ALL_CHATS_FOLDER = "show_all_chats_folder" private const val KEY_TABLET_INTERFACE_ENABLED = "tablet_interface_enabled" @@ -1373,5 +1406,7 @@ class AppPreferences( private const val KEY_PERMISSION_REQUESTED = "permission_requested" private const val KEY_SUPPORT_VIEWED = "support_viewed" + private const val KEY_PROJECT_CHANNEL_PROMO_DISMISSED = "project_channel_promo_dismissed" + private const val KEY_PROJECT_CHANNEL_SUBSCRIBED = "project_channel_subscribed" } } diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/UriTempFileUtils.kt b/presentation/src/main/java/org/monogram/presentation/core/util/UriTempFileUtils.kt new file mode 100644 index 00000000..05bb4722 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/core/util/UriTempFileUtils.kt @@ -0,0 +1,133 @@ +package org.monogram.presentation.core.util + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import java.io.File +import java.io.FileOutputStream + +data class CopiedUriFile( + val localPath: String, + val mimeType: String?, + val displayName: String? +) + +private val INVALID_FILE_CHARS = Regex("[\\\\/:*?\"<>|]") + +fun Context.copyUriToTempMediaFile(uri: Uri): CopiedUriFile? { + return copyUriToTempFile( + uri = uri, + directoryName = "shared_media", + fallbackFileName = { mimeType -> + val extension = resolveMediaExtension(mimeType) + "attach_${System.nanoTime()}.$extension" + } + ) +} + +fun Context.copyUriToTempDocumentFile(uri: Uri): CopiedUriFile? { + return copyUriToTempFile( + uri = uri, + directoryName = "shared_documents", + fallbackFileName = { mimeType -> + "document_${System.nanoTime()}.${resolveDocumentExtension(mimeType)}" + } + ) +} + +internal fun resolveMediaExtension(mimeType: String?): String { + val normalizedMimeType = mimeType?.substringBefore(';')?.trim()?.lowercase() + return when { + normalizedMimeType.isNullOrBlank() -> "jpg" + normalizedMimeType == "image/gif" -> "gif" + normalizedMimeType.startsWith("video/") -> resolveKnownSubtype(normalizedMimeType) ?: "mp4" + normalizedMimeType.startsWith("image/") -> resolveExtensionFromMimeType(normalizedMimeType) + ?: "jpg" + + else -> resolveExtensionFromMimeType(normalizedMimeType) ?: "jpg" + } +} + +internal fun resolveDocumentExtension(mimeType: String?): String { + if (mimeType.isNullOrBlank()) return "bin" + return resolveExtensionFromMimeType(mimeType) ?: "bin" +} + +private fun resolveExtensionFromMimeType(mimeType: String): String? { + return when (val normalizedMimeType = mimeType.substringBefore(';').trim().lowercase()) { + "image/jpeg" -> "jpg" + "image/png" -> "png" + "image/webp" -> "webp" + "image/heic" -> "heic" + "image/heif" -> "heif" + "image/avif" -> "avif" + "application/pdf" -> "pdf" + else -> resolveKnownSubtype(normalizedMimeType) + } +} + +private fun resolveKnownSubtype(mimeType: String): String? { + val normalizedMimeType = mimeType.substringBefore(';').trim().lowercase() + val subtype = normalizedMimeType.substringAfter('/', "") + if (subtype.isBlank()) return null + if (subtype == "unknown" || subtype == "octet-stream") return null + return when (subtype) { + "jpeg" -> "jpg" + "svg+xml" -> "svg" + else -> subtype.takeIf { candidate -> + candidate.all { it.isLetterOrDigit() || it == '+' || it == '-' || it == '.' } + }?.substringBefore('+') + } +} + +fun Context.copyUriToTempFile( + uri: Uri, + directoryName: String = "shared", + fallbackFileName: (String?) -> String = { "file_${System.nanoTime()}" } +): CopiedUriFile? { + return try { + if (uri.scheme == "file") { + val path = uri.path ?: return null + val name = File(path).name.takeIf { it.isNotBlank() } + return CopiedUriFile( + localPath = path, + mimeType = contentResolver.getType(uri), + displayName = name + ) + } + + val mimeType = contentResolver.getType(uri) + val displayName = queryDisplayName(uri)?.trim().takeUnless { it.isNullOrBlank() } + val safeName = displayName + ?.replace(INVALID_FILE_CHARS, "_") + ?: fallbackFileName(mimeType) + + val tempDir = File(cacheDir, directoryName).apply { mkdirs() } + val targetFile = File(tempDir, "${System.nanoTime()}_$safeName") + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(targetFile).use { output -> input.copyTo(output) } + } ?: return null + + CopiedUriFile( + localPath = targetFile.absolutePath, + mimeType = mimeType, + displayName = displayName + ) + } catch (_: Exception) { + null + } +} + +private fun Context.queryDisplayName(uri: Uri): String? { + return contentResolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, + null, + null + )?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0) cursor.getString(index) else null + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt index 2bc8bb0e..5b45ea91 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt @@ -24,6 +24,7 @@ import org.monogram.domain.repository.ChatOperationsRepository import org.monogram.domain.repository.ChatSearchRepository import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.domain.repository.ChatStatisticsRepository +import org.monogram.domain.repository.ContactEditRepository import org.monogram.domain.repository.EditorSnippetProvider import org.monogram.domain.repository.EmojiRepository import org.monogram.domain.repository.ExternalNavigator @@ -39,6 +40,7 @@ import org.monogram.domain.repository.MessageRepository import org.monogram.domain.repository.NetworkStatisticsRepository import org.monogram.domain.repository.NotificationSettingsRepository import org.monogram.domain.repository.PaymentRepository +import org.monogram.domain.repository.PinnedMessageVisibilityRepository import org.monogram.domain.repository.PremiumRepository import org.monogram.domain.repository.PrivacyRepository import org.monogram.domain.repository.ProfilePhotoRepository @@ -55,10 +57,10 @@ import org.monogram.domain.repository.UserProfileEditRepository import org.monogram.domain.repository.UserRepository import org.monogram.domain.repository.WallpaperRepository import org.monogram.domain.repository.WebAppRepository -import org.monogram.presentation.core.util.AppPreferences -import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.media.ExoPlayerCache import org.monogram.presentation.core.media.VideoPlayerPool +import org.monogram.presentation.core.util.AppPreferences +import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.settings.storage.CacheController interface AppContainer { @@ -89,10 +91,12 @@ interface RepositoriesContainer { val chatEventLogRepository: ChatEventLogRepository val messageAiRepository: MessageAiRepository val paymentRepository: PaymentRepository + val pinnedMessageVisibilityRepository: PinnedMessageVisibilityRepository val fileRepository: FileRepository val webAppRepository: WebAppRepository val userRepository: UserRepository val userProfileEditRepository: UserProfileEditRepository + val contactEditRepository: ContactEditRepository val profilePhotoRepository: ProfilePhotoRepository val chatInfoRepository: ChatInfoRepository val premiumRepository: PremiumRepository diff --git a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt index ed731cb6..e523210d 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt @@ -25,6 +25,7 @@ import org.monogram.domain.repository.ChatOperationsRepository import org.monogram.domain.repository.ChatSearchRepository import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.domain.repository.ChatStatisticsRepository +import org.monogram.domain.repository.ContactEditRepository import org.monogram.domain.repository.EditorSnippetProvider import org.monogram.domain.repository.EmojiRepository import org.monogram.domain.repository.ExternalNavigator @@ -40,6 +41,7 @@ import org.monogram.domain.repository.MessageRepository import org.monogram.domain.repository.NetworkStatisticsRepository import org.monogram.domain.repository.NotificationSettingsRepository import org.monogram.domain.repository.PaymentRepository +import org.monogram.domain.repository.PinnedMessageVisibilityRepository import org.monogram.domain.repository.PremiumRepository import org.monogram.domain.repository.PrivacyRepository import org.monogram.domain.repository.ProfilePhotoRepository @@ -56,10 +58,10 @@ import org.monogram.domain.repository.UserProfileEditRepository import org.monogram.domain.repository.UserRepository import org.monogram.domain.repository.WallpaperRepository import org.monogram.domain.repository.WebAppRepository -import org.monogram.presentation.core.util.AppPreferences -import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.media.ExoPlayerCache import org.monogram.presentation.core.media.VideoPlayerPool +import org.monogram.presentation.core.util.AppPreferences +import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.settings.storage.CacheController class KoinAppContainer(koin: Koin) : AppContainer { @@ -90,10 +92,12 @@ class KoinRepositoriesContainer(private val koin: Koin) : RepositoriesContainer override val chatEventLogRepository: ChatEventLogRepository by lazy { koin.get() } override val messageAiRepository: MessageAiRepository by lazy { koin.get() } override val paymentRepository: PaymentRepository by lazy { koin.get() } + override val pinnedMessageVisibilityRepository: PinnedMessageVisibilityRepository by lazy { koin.get() } override val fileRepository: FileRepository by lazy { koin.get() } override val webAppRepository: WebAppRepository by lazy { koin.get() } override val userRepository: UserRepository by lazy { koin.get() } override val userProfileEditRepository: UserProfileEditRepository by lazy { koin.get() } + override val contactEditRepository: ContactEditRepository by lazy { koin.get() } override val profilePhotoRepository: ProfilePhotoRepository by lazy { koin.get() } override val chatInfoRepository: ChatInfoRepository by lazy { koin.get() } override val premiumRepository: PremiumRepository by lazy { koin.get() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/common/ChatActionModels.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/common/ChatActionModels.kt new file mode 100644 index 00000000..0ed62301 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/common/ChatActionModels.kt @@ -0,0 +1,75 @@ +package org.monogram.presentation.features.chats.common + +enum class ChatActionScreenContext { + Chat, + Profile, + ListSelection +} + +enum class ChatActionType { + Leave, + Delete, + ClearHistory, + Report, + Join, + BlockUser, + UnblockUser, + Mute, + Archive, + ToggleRead, + Pin +} + +sealed interface ChatActionState { + data object Idle : ChatActionState + data class Pending(val action: ChatActionType) : ChatActionState + data class Success(val action: ChatActionType?) : ChatActionState + data class Failure(val action: ChatActionType, val message: String) : ChatActionState +} + +data class ChatActionPolicy( + val exitAction: ChatExitAction = ChatExitAction.None, + val canClearHistory: Boolean = false, + val canReport: Boolean = false, + val canJoin: Boolean = false, + val canBlockOrUnblock: Boolean = false, + val canMute: Boolean = true, + val canArchive: Boolean = true, + val canToggleRead: Boolean = true, + val canPin: Boolean = false, + val closeOnExitSuccess: Boolean = false +) + +fun resolveChatActionPolicy( + isMainChat: Boolean, + isGroup: Boolean, + isChannel: Boolean, + isMember: Boolean, + canDeleteChat: Boolean, + canReport: Boolean, + canJoin: Boolean, + canBlockOrUnblock: Boolean, + canPin: Boolean, + context: ChatActionScreenContext +): ChatActionPolicy { + val exitAction = resolveChatExitAction( + isMainChat = isMainChat, + isGroup = isGroup, + isChannel = isChannel, + isMember = isMember, + canDeleteChat = canDeleteChat + ) + val canClearHistory = isMainChat && ((!isGroup && !isChannel) || isMember) + return ChatActionPolicy( + exitAction = exitAction, + canClearHistory = canClearHistory, + canReport = isMainChat && canReport, + canJoin = isMainChat && canJoin, + canBlockOrUnblock = canBlockOrUnblock, + canMute = true, + canArchive = true, + canToggleRead = true, + canPin = canPin, + closeOnExitSuccess = context != ChatActionScreenContext.ListSelection && exitAction != ChatExitAction.None + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/common/ChatExitAction.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/common/ChatExitAction.kt new file mode 100644 index 00000000..b78d3ef0 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/common/ChatExitAction.kt @@ -0,0 +1,26 @@ +package org.monogram.presentation.features.chats.common + +enum class ChatExitAction { + None, + Leave, + Delete +} + +fun resolveChatExitAction( + isMainChat: Boolean, + isGroup: Boolean, + isChannel: Boolean, + isMember: Boolean, + canDeleteChat: Boolean +): ChatExitAction { + if (!isMainChat) return ChatExitAction.None + + return when { + isGroup || isChannel -> { + if (isMember) ChatExitAction.Leave else ChatExitAction.None + } + + canDeleteChat -> ChatExitAction.Delete + else -> ChatExitAction.None + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt index 1f66b215..dcc99b25 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt @@ -18,6 +18,7 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageViewerModel import org.monogram.domain.models.PollDraft +import org.monogram.domain.models.SponsoredMessagesFeedModel import org.monogram.domain.models.StickerSetModel import org.monogram.domain.models.TopicModel import org.monogram.domain.models.UserModel @@ -27,8 +28,14 @@ import org.monogram.domain.repository.ChecklistDraft import org.monogram.domain.repository.InlineBotResultsModel import org.monogram.domain.repository.MessageRepository import org.monogram.domain.repository.StickerRepository +import org.monogram.presentation.core.ui.ScreenSwipeBackState import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils +import org.monogram.presentation.features.chats.common.ChatActionState +import org.monogram.presentation.features.share.IncomingShareRequest +import org.monogram.presentation.features.share.PendingAttachment +import org.monogram.presentation.features.share.PendingAttachmentKind +import org.monogram.presentation.features.share.isDocument import java.io.File @Stable @@ -36,6 +43,7 @@ interface ChatComponent { val appPreferences: AppPreferences val stickerRepository: StickerRepository val state: StateFlow + val swipeBackState: StateFlow val repositoryMessage: MessageRepository val downloadUtils: IDownloadUtils @@ -116,12 +124,15 @@ interface ChatComponent { fun onRestoreDraftLinkPreview() fun onPinMessage(message: MessageModel) fun onUnpinMessage(message: MessageModel) + fun onHidePinnedMessage() + fun onShowPinnedMessage() fun onPinnedMessageClick(message: MessageModel? = null) fun onShowAllPinnedMessages() fun onDismissPinnedMessages() fun onScrollToMessageConsumed() fun onScrollCommandConsumed() fun onViewportSettled() + fun onMessageViewportChanged(visibleMessageIds: Set, nearbyMessageIds: Set) fun onScrollToBottom() fun onDownloadFile(fileId: Int) fun onDownloadHighRes(messageId: Long) @@ -193,6 +204,7 @@ interface ChatComponent { fun onSearchSenderChange(user: UserModel?) fun onSearchDateRangeChange(fromEpochSeconds: Int?, toEpochSeconds: Int?) fun onClearHistory() + fun onLeaveChat() fun onDeleteChat() fun onReport() fun onReportMessage(message: MessageModel) @@ -212,6 +224,7 @@ interface ChatComponent { fun onReplyMarkupButtonClick(messageId: Long, button: KeyboardButtonModel, botUserId: Long) fun onLinkClick(url: String) + fun onChannelSponsoredMessageClick(messageId: Long, url: String, isMediaClick: Boolean) fun onOpenInvoice(slug: String? = null, messageId: Long? = null) fun onDismissInvoice(status: String) @@ -230,6 +243,17 @@ interface ChatComponent { fun onLoadMoreInlineResults(offset: String) fun onSendInlineResult(resultId: String) fun onOpenAttachBot(botUserId: Long, fallbackName: String) + fun onStageAttachments(attachments: List) + fun onClearPendingAttachments() + fun onConsumeInitialShare(requestId: Long) + fun onRemovePendingAttachment(path: String) + fun onReplacePendingAttachment(oldPath: String, attachment: PendingAttachment) + fun onSendPendingAttachments( + attachments: List, + caption: String, + captionEntities: List = emptyList(), + sendOptions: MessageSendOptions = MessageSendOptions() + ) @Stable data class State( @@ -278,10 +302,14 @@ interface ChatComponent { val isDraftLinkPreviewLoading: Boolean = false, val draftLinkPreviewError: String? = null, val isDraftLinkPreviewDisabledForSend: Boolean = false, + val initialShare: IncomingShareRequest? = null, + val initialShareConsumed: Boolean = false, + val stagedAttachments: List = emptyList(), val pinnedMessage: MessageModel? = null, val allPinnedMessages: List = emptyList(), val showPinnedMessagesList: Boolean = false, val isLoadingPinnedMessages: Boolean = false, + val isPinnedMessageHidden: Boolean = false, val pinnedMessageCount: Int = 0, val pinnedMessageIndex: Int = 0, val scrollToMessageId: Long? = null, @@ -363,6 +391,7 @@ interface ChatComponent { val searchDateFromEpochSeconds: Int? = null, val searchDateToEpochSeconds: Int? = null, val showReportDialog: Boolean = false, + val actionState: ChatActionState = ChatActionState.Idle, val isBot: Boolean = false, val botCommands: List = emptyList(), val botMenuButton: BotMenuButtonModel = BotMenuButtonModel.Default, @@ -373,6 +402,7 @@ interface ChatComponent { val invoiceMessageId: Long? = null, val mentionSuggestions: List = emptyList(), val isMember: Boolean = true, + val canDeleteChat: Boolean = false, val restrictUserId: Long? = null, val inlineBotResults: InlineBotResultsModel? = null, val currentInlineBotId: Long? = null, @@ -384,5 +414,17 @@ interface ChatComponent { val scheduledMessages: List = emptyList(), val lastReadInboxMessageId: Long = 0L, val unreadSeparatorLastReadInboxMessageId: Long = 0L, - ) + val channelSponsoredMessages: SponsoredMessagesFeedModel? = null, + val showSponsoredMessagesForPremium: Boolean = false, + ) { + val pendingMediaPaths: List + get() = stagedAttachments.filterNot(PendingAttachment::isDocument).map { it.localPath } + + val pendingDocumentPaths: List + get() = stagedAttachments.filter(PendingAttachment::isDocument).map { it.localPath } + + val hasMixedPendingAttachments: Boolean + get() = stagedAttachments.any { it.kind == PendingAttachmentKind.DOCUMENT } && + stagedAttachments.any { it.kind != PendingAttachmentKind.DOCUMENT } + } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt index 4f7add49..45b21f16 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatContent.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowWidthSizeClass +import org.monogram.presentation.core.ui.ScreenSwipeBackState import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.chats.conversation.ui.LocalVoicePlaybackController import org.monogram.presentation.features.chats.conversation.ui.content.ChatContentBackground @@ -57,6 +58,7 @@ import org.monogram.presentation.features.chats.conversation.ui.content.extractT import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatChromeState import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatContentBodyUiState import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatContentPermissionState +import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatContentPreviewState import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatMessageListState import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatMessagePresentationState import org.monogram.presentation.features.chats.conversation.ui.content.rememberChatSearchUiState @@ -68,13 +70,20 @@ import org.monogram.presentation.features.chats.conversation.ui.message.PreviewI import org.monogram.presentation.features.chats.conversation.ui.message.PreviewVideoViewerRequest import org.monogram.presentation.features.chats.conversation.ui.message.rememberChatMessageRenderDependencies import org.monogram.presentation.features.chats.conversation.ui.rememberVoicePlaybackController +import org.monogram.presentation.features.share.PendingAttachment +import org.monogram.presentation.features.share.PendingAttachmentKind +import org.monogram.presentation.features.share.inferPendingAttachmentKind -internal fun shouldHideChatContentForViewportTransition( +internal fun updateChatContentVisibilityLatch( + previousVisible: Boolean, renderMode: ChatRenderMode, viewportPhase: ChatViewportPhase ): Boolean { - return renderMode != ChatRenderMode.SwipePreview && - viewportPhase != ChatViewportPhase.Settled + return when (renderMode) { + ChatRenderMode.Active -> previousVisible || viewportPhase == ChatViewportPhase.Settled + ChatRenderMode.SwipePreview, + ChatRenderMode.ForumTopicSwipePreview -> true + } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -82,12 +91,20 @@ internal fun shouldHideChatContentForViewportTransition( fun ChatContent( component: ChatComponent, renderMode: ChatRenderMode = ChatRenderMode.Active, - onSwipeBackBlockedChanged: (Boolean) -> Unit = {}, + onSwipeBackStateChanged: (ScreenSwipeBackState) -> Unit = {}, ) { val state by component.state.collectAsState() + val uiInstanceId = remember { ChatConversationLog.nextUiInstanceId() } + val componentInstanceId = (component as? DefaultChatComponent)?.componentInstanceId + val previewState = rememberChatContentPreviewState(state, renderMode) val conversationKey = - remember(state.chatId, state.currentTopicId, state.currentMessageThreadId) { - "${state.chatId}:${state.currentMessageThreadId ?: state.currentTopicId ?: 0L}" + remember( + state.chatId, + previewState.currentTopicId, + state.currentMessageThreadId, + renderMode + ) { + "${state.chatId}:${state.currentMessageThreadId ?: previewState.currentTopicId ?: 0L}:$renderMode" } val scrollState = rememberSaveable(conversationKey, saver = LazyListState.Saver) { LazyListState() @@ -104,7 +121,13 @@ fun ChatContent( isTabletInterfaceEnabled var isVisible by remember(conversationKey) { - mutableStateOf(!shouldHideChatContentForViewportTransition(renderMode, state.viewportPhase)) + mutableStateOf( + updateChatContentVisibilityLatch( + previousVisible = false, + renderMode = renderMode, + viewportPhase = state.viewportPhase + ) + ) } var showInitialLoading by remember(conversationKey) { mutableStateOf(false) } var isRecordingVideo by remember(conversationKey) { mutableStateOf(false) } @@ -118,16 +141,6 @@ fun ChatContent( var clickOffset by remember(conversationKey) { mutableStateOf(Offset.Zero) } var contentRect by remember { mutableStateOf(Rect.Zero) } - var pendingMediaPaths by rememberSaveable(conversationKey) { - mutableStateOf>( - emptyList() - ) - } - var pendingDocumentPaths by rememberSaveable(conversationKey) { - mutableStateOf>( - emptyList() - ) - } var editingPhotoPath by rememberSaveable(conversationKey) { mutableStateOf(null) } var editingVideoPath by rememberSaveable(conversationKey) { mutableStateOf(null) } var pendingBlockUserId by rememberSaveable(conversationKey) { mutableStateOf(null) } @@ -166,13 +179,14 @@ fun ChatContent( val isDragged by scrollState.interactionSource.collectIsDraggedAsState() val messagePresentationState = rememberChatMessagePresentationState( state = state, + previewState = previewState, selectedMessageId = selectedMessageId, transformedMessageTexts = transformedMessageTexts ) - val bodyUiState = rememberChatContentBodyUiState(state) + val bodyUiState = rememberChatContentBodyUiState(state, previewState) val searchUiState = rememberChatSearchUiState(state) val permissionState = rememberChatContentPermissionState(state) - val topBarUiState = rememberChatTopBarUiState(state) + val topBarUiState = rememberChatTopBarUiState(state, previewState) val chromeState = rememberChatChromeState( state = state, isRecordingVideo = isRecordingVideo, @@ -182,18 +196,12 @@ fun ChatContent( ) val messageListState = rememberChatMessageListState( state = state, + previewState = previewState, displayMessages = messagePresentationState.displayMessages, canSendAnything = permissionState.canSendAnything, showInitialLoading = showInitialLoading ) - val messageRenderDependencies by rememberChatMessageRenderDependencies( - messages = remember(messagePresentationState.displayMessages, state.rootMessage) { - buildList { - addAll(messagePresentationState.displayMessages) - state.rootMessage?.let(::add) - } - } - ) + val messageRenderDependencies by rememberChatMessageRenderDependencies() val voicePlaybackController = rememberVoicePlaybackController() val isAnyViewerOpen by remember( @@ -253,25 +261,73 @@ fun ChatContent( } } - LaunchedEffect(isAnyViewerOpen, onSwipeBackBlockedChanged) { - onSwipeBackBlockedChanged(isAnyViewerOpen) + LaunchedEffect(state, isAnyViewerOpen, onSwipeBackStateChanged) { + onSwipeBackStateChanged( + resolveChatSwipeBackState( + state = state, + hasTransientBlockingOverlay = isAnyViewerOpen + ) + ) } - DisposableEffect(onSwipeBackBlockedChanged) { - onDispose { onSwipeBackBlockedChanged(false) } + LaunchedEffect(state.initialShare?.requestId, conversationKey) { + val share = state.initialShare ?: return@LaunchedEffect + if (state.initialShareConsumed) return@LaunchedEffect + + if (share.attachments.isNotEmpty()) { + component.onStageAttachments(share.attachments) + } + if (share.text.isNotBlank() && state.draftText != share.text) { + component.onDraftChange(share.text) + } + component.onConsumeInitialShare(share.requestId) } - LaunchedEffect(renderMode, state.viewportPhase, conversationKey) { - if (renderMode == ChatRenderMode.SwipePreview) { - isVisible = true - } else if (shouldHideChatContentForViewportTransition(renderMode, state.viewportPhase)) { - isVisible = false + DisposableEffect(onSwipeBackStateChanged) { + onDispose { onSwipeBackStateChanged(ScreenSwipeBackState()) } + } + + DisposableEffect(conversationKey) { + ChatConversationLog.logState( + stream = ChatConversationLog.STREAM_VIEWPORT, + event = "ui_attach", + state = state, + componentInstanceId = componentInstanceId, + uiInstanceId = uiInstanceId, + extra = "conversationKey=$conversationKey renderMode=$renderMode" + ) + onDispose { + ChatConversationLog.logState( + stream = ChatConversationLog.STREAM_VIEWPORT, + event = "ui_dispose", + state = state, + componentInstanceId = componentInstanceId, + uiInstanceId = uiInstanceId, + extra = "conversationKey=$conversationKey renderMode=$renderMode" + ) } } + LaunchedEffect(renderMode, state.viewportPhase, conversationKey) { + val previousVisible = isVisible + isVisible = updateChatContentVisibilityLatch( + previousVisible = isVisible, + renderMode = renderMode, + viewportPhase = state.viewportPhase + ) + ChatConversationLog.logState( + stream = ChatConversationLog.STREAM_VIEWPORT, + event = "visibility_latch", + state = state, + componentInstanceId = componentInstanceId, + uiInstanceId = uiInstanceId, + extra = "conversationKey=$conversationKey renderMode=$renderMode previousVisible=$previousVisible nextVisible=$isVisible" + ) + } + val effectsEnabled = renderMode == ChatRenderMode.Active val shouldAnimateContentEntrance = effectsEnabled && state.isChatAnimationsEnabled - val shouldShowContent = renderMode == ChatRenderMode.SwipePreview || isVisible + val shouldShowContent = renderMode != ChatRenderMode.Active || isVisible val chromeAlpha = 1f val messagesAlpha by animateFloatAsState( targetValue = if (shouldShowContent) 1f else 0f, @@ -293,6 +349,8 @@ fun ChatContent( isComments = messagePresentationState.isComments, isForumList = messagePresentationState.isForumList, effectsEnabled = effectsEnabled, + componentInstanceId = componentInstanceId, + uiInstanceId = uiInstanceId, isDragged = isDragged, isRecordingVideo = isRecordingVideo, showInitialLoading = showInitialLoading, @@ -394,14 +452,42 @@ fun ChatContent( state = state, component = component, chromeState = chromeState, - pendingMediaPaths = pendingMediaPaths, - pendingDocumentPaths = pendingDocumentPaths, + pendingMediaPaths = state.pendingMediaPaths, + pendingDocumentPaths = state.pendingDocumentPaths, onDraftLinkPreviewAction = onLinkPreviewAction, - onPendingMediaPathsChanged = { pendingMediaPaths = it }, - onPendingDocumentPathsChanged = { pendingDocumentPaths = it }, + onPendingMediaPathsChanged = { mediaPaths -> + val documents = + state.stagedAttachments.filter { it.kind == PendingAttachmentKind.DOCUMENT } + val media = mediaPaths.map { path -> + val existing = + state.stagedAttachments.firstOrNull { it.localPath == path } + existing ?: PendingAttachment( + localPath = path, + kind = inferPendingAttachmentKind(path) + ) + } + component.onStageAttachments(media + documents) + }, + onPendingDocumentPathsChanged = { documentPaths -> + val media = + state.stagedAttachments.filter { it.kind != PendingAttachmentKind.DOCUMENT } + val documents = documentPaths.map { path -> + val existing = + state.stagedAttachments.firstOrNull { it.localPath == path } + existing ?: PendingAttachment( + localPath = path, + kind = PendingAttachmentKind.DOCUMENT + ) + } + component.onStageAttachments(media + documents) + }, onStartRecordingVideo = { isRecordingVideo = true }, onEditMediaPath = { path -> - if (path.endsWith(".mp4")) { + val existingKind = + state.stagedAttachments.firstOrNull { it.localPath == path }?.kind + if ((existingKind + ?: inferPendingAttachmentKind(path)) == PendingAttachmentKind.VIDEO + ) { editingVideoPath = path } else { editingPhotoPath = path @@ -420,6 +506,7 @@ fun ChatContent( state = bodyUiState, component = component, scrollState = scrollState, + isDragged = isDragged, messageListState = messageListState, groupedMessages = messagePresentationState.groupedMessages, searchUiState = searchUiState, @@ -515,11 +602,12 @@ fun ChatContent( onClosePhotoEditor = { editingPhotoPath = null }, onSavePhotoEditor = { newPath -> val path = editingPhotoPath ?: return@ChatContentOverlays - val newList = pendingMediaPaths.toMutableList() - val index = newList.indexOf(path) - if (index != -1) { - newList[index] = newPath - pendingMediaPaths = newList + val previous = state.stagedAttachments.firstOrNull { it.localPath == path } + if (previous != null) { + component.onReplacePendingAttachment( + oldPath = path, + attachment = previous.copy(localPath = newPath) + ) } editingPhotoPath = null }, @@ -527,11 +615,12 @@ fun ChatContent( onCloseVideoEditor = { editingVideoPath = null }, onSaveVideoEditor = { newPath -> val path = editingVideoPath ?: return@ChatContentOverlays - val newList = pendingMediaPaths.toMutableList() - val index = newList.indexOf(path) - if (index != -1) { - newList[index] = newPath - pendingMediaPaths = newList + val previous = state.stagedAttachments.firstOrNull { it.localPath == path } + if (previous != null) { + component.onReplacePendingAttachment( + oldPath = path, + attachment = previous.copy(localPath = newPath) + ) } editingVideoPath = null }, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatConversationLog.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatConversationLog.kt new file mode 100644 index 00000000..ed976bc9 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatConversationLog.kt @@ -0,0 +1,199 @@ +package org.monogram.presentation.features.chats.conversation + +import android.os.Trace +import android.util.Log +import org.monogram.core.perf.ChatOpenPerfDebug +import org.monogram.presentation.BuildConfig +import org.monogram.presentation.features.chats.conversation.logic.effectiveThreadId +import java.util.concurrent.atomic.AtomicInteger + +internal data class ConversationLoadSession( + val sessionId: String, + val source: String, + val target: String, + val startedAtMs: Long = android.os.SystemClock.elapsedRealtime() +) + +internal data class ChatInitialLoadKey( + val chatId: Long, + val effectiveThreadId: Long?, + val initialMessageId: Long?, + val savedViewportAnchorMessageId: Long?, + val firstUnreadMessageId: Long?, + val rootMessageId: Long? +) + +internal object ChatConversationLog { + internal const val TAG = ChatOpenPerfDebug.TAG + internal const val STREAM_VIEWPORT = "viewport" + internal const val STREAM_PERF = ChatOpenPerfDebug.STREAM_PERF + + private val componentCounter = AtomicInteger(1) + private val uiCounter = AtomicInteger(1) + + fun isEnabled(): Boolean = BuildConfig.DEBUG + + inline fun trace(section: String, block: () -> T): T { + if (!isEnabled()) return block() + Trace.beginSection(section) + return try { + block() + } finally { + Trace.endSection() + } + } + + suspend inline fun traceSuspend(section: String, crossinline block: suspend () -> T): T { + if (!isEnabled()) return block() + Trace.beginSection(section) + return try { + block() + } finally { + Trace.endSection() + } + } + + fun nextComponentInstanceId(chatId: Long): String { + return "cmp${componentCounter.getAndIncrement()}@$chatId" + } + + fun nextUiInstanceId(): String { + return "ui${uiCounter.getAndIncrement()}" + } + + fun logState( + stream: String, + event: String, + state: ChatComponent.State, + componentInstanceId: String? = null, + uiInstanceId: String? = null, + extra: String? = null + ) { + if (!isEnabled()) return + Log.d( + TAG, + buildString { + append("stream=").append(stream) + append(" event=").append(event) + append(" component=").append(componentInstanceId ?: "none") + append(" ui=").append(uiInstanceId ?: "none") + append(" chatId=").append(state.chatId) + append(" threadId=").append( + state.currentMessageThreadId ?: state.currentTopicId ?: 0L + ) + append(" viewport=").append(state.viewportPhase) + append(" pending=").append( + state.pendingScrollCommand?.javaClass?.simpleName ?: "none" + ) + append(" loading=").append(state.isLoading) + append(" loadingOlder=").append(state.isLoadingOlder) + append(" loadingNewer=").append(state.isLoadingNewer) + append(" messages=").append(state.messages.size) + append(" topics=").append(state.topics.size) + append(" atBottom=").append(state.isAtBottom) + append(" latestLoaded=").append(state.isLatestLoaded) + append(" oldestLoaded=").append(state.isOldestLoaded) + append(" scrollToMessageId=").append(state.scrollToMessageId ?: 0L) + append(" savedAnchor=").append(state.lastSavedViewport?.anchorMessageId ?: 0L) + append(" currentTopicId=").append(state.currentTopicId ?: 0L) + append(" rootMessageId=").append(state.rootMessage?.id ?: 0L) + if (!extra.isNullOrBlank()) { + append(" ").append(extra) + } + } + ) + } + + fun logViewportState( + event: String, + state: ChatComponent.State, + componentInstanceId: String? = null, + uiInstanceId: String? = null, + extra: String? = null + ) { + logState( + stream = STREAM_VIEWPORT, + event = event, + state = state, + componentInstanceId = componentInstanceId, + uiInstanceId = uiInstanceId, + extra = extra + ) + } + + fun log( + stream: String, + chatId: Long, + threadId: Long?, + event: String, + componentInstanceId: String? = null, + uiInstanceId: String? = null, + extra: String? = null + ) { + if (!isEnabled()) return + Log.d( + TAG, + buildString { + append("stream=").append(stream) + append(" event=").append(event) + append(" component=").append(componentInstanceId ?: "none") + append(" ui=").append(uiInstanceId ?: "none") + append(" chatId=").append(chatId) + append(" threadId=").append(threadId ?: 0L) + if (!extra.isNullOrBlank()) { + append(" ").append(extra) + } + } + ) + } + + fun logViewport( + chatId: Long, + threadId: Long?, + event: String, + componentInstanceId: String? = null, + uiInstanceId: String? = null, + extra: String? = null + ) { + log( + stream = STREAM_VIEWPORT, + chatId = chatId, + threadId = threadId, + event = event, + componentInstanceId = componentInstanceId, + uiInstanceId = uiInstanceId, + extra = extra + ) + } + + fun logPerf( + component: DefaultChatComponent, + phase: String, + source: String? = null, + target: String? = null, + anchorId: Long? = null, + messagesBefore: Int? = null, + messagesAfter: Int? = null, + durationMs: Long? = null + ) { + if (!isEnabled()) return + val state = component.state.value + val session = component.activeLoadSession + Log.d( + TAG, + ChatOpenPerfDebug.buildLogMessage( + chatId = component.chatId, + threadId = state.effectiveThreadId(), + event = phase, + componentInstanceId = component.componentInstanceId, + source = source ?: session?.source, + target = target ?: session?.target, + anchorId = anchorId ?: state.lastSavedViewport?.anchorMessageId, + messagesBefore = messagesBefore ?: state.messages.size, + messagesAfter = messagesAfter ?: state.messages.size, + unreadCount = state.unreadSeparatorCount, + durationMs = durationMs + ) + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt index 44569d02..a541c4b9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt @@ -18,7 +18,6 @@ import java.io.File interface ChatStore : Store { sealed class Intent { - data class UpdateState(val state: ChatComponent.State) : Intent() data class SendMessage( val text: String, val entities: List = emptyList(), @@ -98,6 +97,8 @@ interface ChatStore : Store() { + private inner class ExecutorImpl : + CoroutineExecutor() { override fun executeIntent(intent: Intent) { when (intent) { - is Intent.UpdateState -> dispatch(Message.UpdateState(intent.state)) is Intent.SendMessage -> component.handleSendMessage(intent.text, intent.entities, intent.sendOptions) is Intent.SendSticker -> component.handleSendSticker(intent.stickerPath) is Intent.SendPhoto -> component.handleSendPhoto( @@ -242,6 +245,10 @@ class ChatStoreFactory( is Intent.UnpinMessage -> component.handleUnpinMessage(intent.message) + is Intent.HidePinnedMessage -> component.handleHidePinnedMessage() + + is Intent.ShowPinnedMessage -> component.handleShowPinnedMessage() + is Intent.PinnedMessageClick -> component.handlePinnedMessageClick(intent.message) is Intent.ShowAllPinnedMessages -> { component._state.update { @@ -384,6 +391,7 @@ class ChatStoreFactory( intent.toEpochSeconds ) is Intent.ClearHistory -> component.handleClearHistory() + is Intent.LeaveChat -> component.handleLeaveChat() is Intent.DeleteChat -> component.handleDeleteChat() @@ -463,14 +471,7 @@ class ChatStoreFactory( .toList() } - private object ReducerImpl : Reducer { - override fun ChatComponent.State.reduce(msg: Message): ChatComponent.State = - when (msg) { - is Message.UpdateState -> msg.state - } - } - - sealed class Message { - data class UpdateState(val state: ChatComponent.State) : Message() + private object ReducerImpl : Reducer { + override fun ChatComponent.State.reduce(msg: Nothing): ChatComponent.State = this } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatSwipeBack.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatSwipeBack.kt new file mode 100644 index 00000000..e4534278 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatSwipeBack.kt @@ -0,0 +1,41 @@ +package org.monogram.presentation.features.chats.conversation + +import org.monogram.presentation.core.ui.ScreenSwipeBackAction +import org.monogram.presentation.core.ui.ScreenSwipeBackPreview +import org.monogram.presentation.core.ui.ScreenSwipeBackState + +internal fun resolveChatSwipeBackState( + state: ChatComponent.State, + hasTransientBlockingOverlay: Boolean = false, +): ScreenSwipeBackState { + val isBlocked = isChatSwipeBackBlocked(state, hasTransientBlockingOverlay) + return if (state.currentTopicId != null) { + ScreenSwipeBackState( + isSupported = true, + isBlocked = isBlocked, + action = ScreenSwipeBackAction.LocalChatTopicClose, + preview = ScreenSwipeBackPreview.ChatForumList, + ) + } else { + ScreenSwipeBackState( + isSupported = true, + isBlocked = isBlocked, + action = ScreenSwipeBackAction.StackPop, + preview = ScreenSwipeBackPreview.PreviousStackEntry, + ) + } +} + +internal fun isChatSwipeBackBlocked( + state: ChatComponent.State, + hasTransientBlockingOverlay: Boolean = false, +): Boolean { + return hasTransientBlockingOverlay || + state.fullScreenImages != null || + state.fullScreenVideoPath != null || + state.fullScreenVideoMessageId != null || + state.youtubeUrl != null || + state.instantViewUrl != null || + state.miniAppUrl != null || + state.webViewUrl != null +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatViewportPhase.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatViewportPhase.kt index 82122fff..d71e2e90 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatViewportPhase.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatViewportPhase.kt @@ -8,5 +8,6 @@ enum class ChatViewportPhase { enum class ChatRenderMode { Active, - SwipePreview + SwipePreview, + ForumTopicSwipePreview, } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt index a9bdc7a6..51db38b5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt @@ -6,7 +6,6 @@ import com.arkivanov.essenty.lifecycle.doOnResume import com.arkivanov.essenty.lifecycle.doOnStart import com.arkivanov.essenty.lifecycle.doOnStop import com.arkivanov.mvikotlin.extensions.coroutines.labels -import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -14,15 +13,18 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.monogram.core.DispatcherProvider +import org.monogram.core.perf.ChatOpenPerfBridge import org.monogram.domain.managers.DistrManager import org.monogram.domain.models.BotMenuButtonModel import org.monogram.domain.models.ChatPermissionsModel @@ -32,6 +34,7 @@ import org.monogram.domain.models.ForwardOriginType import org.monogram.domain.models.GifModel import org.monogram.domain.models.InlineKeyboardButtonModel import org.monogram.domain.models.KeyboardButtonModel +import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions @@ -53,13 +56,18 @@ import org.monogram.domain.repository.InlineBotRepository import org.monogram.domain.repository.MessageDisplayer import org.monogram.domain.repository.MessageRepository import org.monogram.domain.repository.PaymentRepository +import org.monogram.domain.repository.PinnedMessageVisibilityRepository import org.monogram.domain.repository.PrivacyRepository import org.monogram.domain.repository.StickerRepository import org.monogram.domain.repository.UserRepository import org.monogram.domain.repository.WallpaperRepository +import org.monogram.presentation.core.ui.ScreenSwipeBackState import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.componentScope +import org.monogram.presentation.features.chats.conversation.logic.buildChatInitialLoadKey +import org.monogram.presentation.features.chats.conversation.logic.effectiveThreadId +import org.monogram.presentation.features.chats.conversation.logic.handleSendPendingAttachments import org.monogram.presentation.features.chats.conversation.logic.loadChatInfo import org.monogram.presentation.features.chats.conversation.logic.loadDraft import org.monogram.presentation.features.chats.conversation.logic.loadMessages @@ -67,11 +75,16 @@ import org.monogram.presentation.features.chats.conversation.logic.loadPinnedMes import org.monogram.presentation.features.chats.conversation.logic.loadScheduledMessages import org.monogram.presentation.features.chats.conversation.logic.loadWallpapers import org.monogram.presentation.features.chats.conversation.logic.observePreferences +import org.monogram.presentation.features.chats.conversation.logic.observeSponsoredMessagePolicy import org.monogram.presentation.features.chats.conversation.logic.observeUserUpdates import org.monogram.presentation.features.chats.conversation.logic.refreshDraftLinkPreviewOnPhotoDownloadIfNeeded +import org.monogram.presentation.features.chats.conversation.logic.refreshSponsoredMessageAfterMediaDownload import org.monogram.presentation.features.chats.conversation.logic.setupMessageCollectors import org.monogram.presentation.features.chats.conversation.logic.setupPinnedMessageCollector +import org.monogram.presentation.features.chats.conversation.logic.shouldStartInitialLoad import org.monogram.presentation.features.chats.conversation.logic.withUnreadSessionFromChat +import org.monogram.presentation.features.share.IncomingShareRequest +import org.monogram.presentation.features.share.PendingAttachment import org.monogram.presentation.root.AppComponentContext import org.monogram.presentation.settings.storage.CacheController import java.io.File @@ -80,6 +93,179 @@ import kotlin.math.abs private const val VIEWPORT_PERSIST_DEBOUNCE_MS = 750L private const val VIEWPORT_OFFSET_DELTA_THRESHOLD_PX = 8 +private const val RICH_PREFETCH_PARALLELISM = 3 + +internal fun shouldRepairUnexpectedChatReset( + previousState: ChatComponent.State?, + nextState: ChatComponent.State +): Boolean { + return previousState != null && + previousState.messages.isNotEmpty() && + previousState.currentTopicId == null && + previousState.currentMessageThreadId == null && + previousState.rootMessage == null && + nextState.viewportPhase == ChatViewportPhase.Initializing && + nextState.isLoading && + nextState.messages.isEmpty() && + nextState.currentTopicId == null && + nextState.currentMessageThreadId == null && + nextState.rootMessage == null && + nextState.isAtBottom +} + +internal fun repairUnexpectedChatReset( + previousState: ChatComponent.State, + resetState: ChatComponent.State +): ChatComponent.State { + return previousState.copy( + isLoading = resetState.isLoading, + isOldestLoaded = resetState.isOldestLoaded, + isLatestLoaded = resetState.isLatestLoaded, + pendingScrollCommand = resetState.pendingScrollCommand, + lastSavedViewport = resetState.lastSavedViewport + ) +} + +internal data class RichMessageKey(val chatId: Long, val messageId: Long) + +internal fun mergeRichContent( + current: MessageContent.RichMessage, + incoming: MessageContent.RichMessage +): MessageContent.RichMessage { + if (current.isFull && !incoming.isFull && incoming.blocks.isEmpty()) return current + if (incoming.isFull) return incoming + if (incoming.blocks.isNotEmpty()) return incoming + return current +} + +internal fun mergeRichMessageIntoModel( + current: MessageModel, + incoming: MessageContent.RichMessage +): MessageModel { + val currentContent = current.content as? MessageContent.RichMessage ?: return current + val merged = mergeRichContent(currentContent, incoming) + return if (merged == currentContent) current else current.copy(content = merged) +} + +internal fun DefaultChatComponent.applyResolvedRichMessage( + messageId: Long, + richMessage: MessageContent.RichMessage +) { + scope.launch { + messageMutex.withLock { + val targetMessageId = remappedMessageIds[messageId] ?: messageId + _state.update { currentState -> + val currentMessages = currentState.messages.toMutableList() + val index = currentMessages.indexOfFirst { it.id == targetMessageId } + if (index == -1) return@update currentState + val currentMessage = currentMessages[index] + val updatedMessage = mergeRichMessageIntoModel(currentMessage, richMessage) + if (updatedMessage == currentMessage) { + currentState + } else { + currentMessages[index] = updatedMessage + currentState.copy(messages = currentMessages) + } + } + } + } +} + +internal data class RichViewportPrefetchDecision( + val fetchNow: List, + val visibleCandidates: Set, + val nearbyCandidates: Set +) + +internal fun decideRichViewportPrefetch( + visibleMessages: List, + nearbyMessages: List, + resolvedMessageIds: Set, + inFlightMessageIds: Set, + maxNearbyFetches: Int = RICH_PREFETCH_PARALLELISM +): RichViewportPrefetchDecision { + fun MessageModel.partialRichKeyOrNull(): RichMessageKey? { + val content = content as? MessageContent.RichMessage ?: return null + if (content.isFull) return null + return RichMessageKey(chatId = chatId, messageId = id) + } + + val visibleCandidates = + visibleMessages.mapNotNullTo(linkedSetOf(), MessageModel::partialRichKeyOrNull) + val nearbyCandidates = + nearbyMessages.mapNotNullTo(linkedSetOf(), MessageModel::partialRichKeyOrNull) + val fetchVisible = + visibleCandidates.filterNot { it in resolvedMessageIds || it in inFlightMessageIds } + val fetchNearby = nearbyCandidates + .filterNot { it in visibleCandidates || it in resolvedMessageIds || it in inFlightMessageIds } + .take(maxNearbyFetches.coerceAtLeast(0)) + return RichViewportPrefetchDecision( + fetchNow = fetchVisible + fetchNearby, + visibleCandidates = visibleCandidates, + nearbyCandidates = nearbyCandidates + ) +} + +internal class RichMessageCoordinator( + private val component: DefaultChatComponent +) { + private val inFlightMessageIds = ConcurrentHashMap.newKeySet() + private val resolvedMessageIds = ConcurrentHashMap.newKeySet() + + fun onViewportChanged(visibleMessageIds: Set, nearbyMessageIds: Set) { + if (component._state.value.viewportPhase != ChatViewportPhase.Settled) return + val messages = component._state.value.messages + if (messages.isEmpty()) return + val visibleMessages = messages.filter { it.id in visibleMessageIds } + val nearbyMessages = messages.filter { it.id in nearbyMessageIds } + val decision = decideRichViewportPrefetch( + visibleMessages = visibleMessages, + nearbyMessages = nearbyMessages, + resolvedMessageIds = resolvedMessageIds, + inFlightMessageIds = inFlightMessageIds + ) + decision.fetchNow.take(RICH_PREFETCH_PARALLELISM).forEach(::request) + } + + fun onMessageEdited(message: MessageModel) { + val content = message.content as? MessageContent.RichMessage ?: return + val key = RichMessageKey(chatId = message.chatId, messageId = message.id) + if (content.isFull) { + resolvedMessageIds += key + component.scope.launch { + component.applyResolvedRichMessage(message.id, content) + } + return + } + resolvedMessageIds.remove(key) + if (component._state.value.viewportPhase == ChatViewportPhase.Settled) { + request(key) + } + } + + private fun request(key: RichMessageKey) { + if (!inFlightMessageIds.add(key)) return + component.scope.launch(component.dispatcherProvider.io) { + ChatConversationLog.logViewport( + chatId = key.chatId, + threadId = component._state.value.currentTopicId, + event = "rich_fetch_requested", + componentInstanceId = component.componentInstanceId, + extra = "messageId=${key.messageId}" + ) + runCatching { + component.repositoryMessage.getFullRichMessage(key.chatId, key.messageId) + }.onSuccess { richMessage -> + if (richMessage != null) { + resolvedMessageIds += key + component.applyResolvedRichMessage(key.messageId, richMessage) + } + }.also { + inFlightMessageIds.remove(key) + } + } + } +} class DefaultChatComponent( context: AppComponentContext, @@ -90,9 +276,14 @@ class DefaultChatComponent( private val onProfileClick: () -> Unit, private val onForward: (Long, List) -> Unit, private val onLink: (String) -> Unit, - private val initialMessageId: Long? = null + private val initialMessageId: Long? = null, + private val initialTopicId: Long? = null, + private val initialShare: IncomingShareRequest? = null, + private val onInitialShareConsumed: (Long) -> Unit = {} ) : ChatComponent, AppComponentContext by context { + internal val componentInstanceId: String = ChatConversationLog.nextComponentInstanceId(chatId) + internal val wallpaperRepository: WallpaperRepository = container.repositories.wallpaperRepository override val downloadUtils: IDownloadUtils = container.utils.downloadUtils() internal val userRepository: UserRepository = container.repositories.userRepository @@ -107,6 +298,8 @@ class DefaultChatComponent( internal val chatOperationsRepository: ChatOperationsRepository = container.repositories.chatOperationsRepository internal val forumTopicsRepository: ForumTopicsRepository = container.repositories.forumTopicsRepository override val repositoryMessage: MessageRepository = container.repositories.messageRepository + internal val pinnedMessageVisibilityRepository: PinnedMessageVisibilityRepository = + container.repositories.pinnedMessageVisibilityRepository internal val inlineBotRepository: InlineBotRepository = container.repositories.inlineBotRepository internal val paymentRepository: PaymentRepository = container.repositories.paymentRepository override val appPreferences: AppPreferences = container.preferences.appPreferences @@ -124,7 +317,6 @@ class DefaultChatComponent( var draftSaveJob: Job? = null var draftLinkPreviewJob: Job? = null var draftLinkPreviewDebounceJob: Job? = null - private var autoLoadJob: Job? = null private var mentionJob: Job? = null private var viewportPersistenceJob: Job? = null private var pendingViewportToPersist: ChatViewportCacheEntry? = null @@ -138,6 +330,12 @@ class DefaultChatComponent( internal val pendingSenderRefreshes = ConcurrentHashMap.newKeySet() internal val senderRefreshRequestedAtMs = ConcurrentHashMap() internal var chatInfoObserversStarted: Boolean = false + internal var sponsoredMessageLoadingJob: Job? = null + internal var unreadBackfillJob: Job? = null + internal var lastStartedLoadKey: ChatInitialLoadKey? = null + internal var hasStartedInitialLoadForContext: Boolean = false + internal var activeLoadSession: ConversationLoadSession? = null + internal val richMessageCoordinator = RichMessageCoordinator(this) internal var lastLoadedOlderId: Long = 0L internal var lastLoadedNewerId: Long = 0L @@ -171,6 +369,8 @@ class DefaultChatComponent( fixLinkPreviews = appPreferences.fixLinkPreviews.value, isWhitelistedInAdBlock = appPreferences.adBlockWhitelistedChannels.value.contains(chatId), scrollToMessageId = initialMessageId, + currentTopicId = initialTopicId, + initialShare = initialShare, lastScrollPosition = cacheProvider.getChatScrollPosition(chatId), lastSavedViewport = cacheProvider.getChatViewport(chatId, null), isInstalledFromGooglePlay = distrManager.isInstalledFromGooglePlay(), @@ -184,12 +384,20 @@ class DefaultChatComponent( component = this ).create() - override val state: StateFlow = store.stateFlow + override val state: StateFlow = _state + private val _swipeBackState = MutableStateFlow(resolveChatSwipeBackState(_state.value)) + override val swipeBackState: StateFlow = _swipeBackState private var availableWallpapers: List = emptyList() internal var allMembers: List = emptyList() init { + ChatConversationLog.logViewport( + chatId = chatId, + threadId = initialTopicId, + event = "component_init", + componentInstanceId = componentInstanceId + ) setupLifecycle() setupCollectors() initialLoad() @@ -197,23 +405,43 @@ class DefaultChatComponent( private fun setupLifecycle() { lifecycle.doOnStart { - startAutoLoad() + ChatConversationLog.logViewportState( + event = "lifecycle_start", + state = _state.value, + componentInstanceId = componentInstanceId + ) + requestInitialLoad(source = "lifecycle_start") } lifecycle.doOnStop { - autoLoadJob?.cancel() + ChatConversationLog.logViewportState( + event = "lifecycle_stop", + state = _state.value, + componentInstanceId = componentInstanceId + ) + unreadBackfillJob?.cancel() flushViewportPersistence() } lifecycle.doOnResume { + ChatConversationLog.logViewportState( + event = "lifecycle_resume", + state = _state.value, + componentInstanceId = componentInstanceId + ) loadChatInfo() - handleResume(initialMessageId) + handleResume() } scope.launch { try { awaitCancellation() } finally { + ChatConversationLog.logViewportState( + event = "component_dispose", + state = _state.value, + componentInstanceId = componentInstanceId + ) repositoryMessage.closeChat(chatId) } } @@ -235,6 +463,7 @@ class DefaultChatComponent( setupPinnedMessageCollector() observeUserUpdates() observeCurrentUser() + observeSponsoredMessagePolicy() cacheProvider.attachBots .onEach { bots -> _state.update { @@ -246,30 +475,100 @@ class DefaultChatComponent( .launchIn(scope) appPreferences.adBlockWhitelistedChannels + .drop(1) .onEach { channels -> _state.update { it.copy(isWhitelistedInAdBlock = channels.contains(chatId)) } } .launchIn(scope) + pinnedMessageVisibilityRepository.observeHidden(chatId) + .onEach { hidden -> + _state.update { it.copy(isPinnedMessageHidden = hidden) } + } + .launchIn(scope) + loadWallpapers { wallpapers -> availableWallpapers = wallpapers observePreferences(availableWallpapers) } _state.onEach { - store.accept(ChatStore.Intent.UpdateState(it)) + _swipeBackState.value = resolveChatSwipeBackState(it) }.launchIn(scope) + var repairingUnexpectedReset = false + var previousTracedState: ChatComponent.State? = null + _state + .distinctUntilChanged { old, new -> + old.viewportPhase == new.viewportPhase && + old.pendingScrollCommand == new.pendingScrollCommand && + old.isLoading == new.isLoading && + old.isLoadingOlder == new.isLoadingOlder && + old.isLoadingNewer == new.isLoadingNewer && + old.messages.size == new.messages.size && + old.topics.size == new.topics.size && + old.isAtBottom == new.isAtBottom && + old.isLatestLoaded == new.isLatestLoaded && + old.isOldestLoaded == new.isOldestLoaded && + old.currentTopicId == new.currentTopicId && + old.currentMessageThreadId == new.currentMessageThreadId + } + .onEach { state -> + val previousState = previousTracedState + if (!repairingUnexpectedReset && shouldRepairUnexpectedChatReset( + previousState, + state + ) + ) { + val nonNullPreviousState = requireNotNull(previousState) + ChatConversationLog.logViewportState( + event = "unexpected_reset_repair", + state = state, + componentInstanceId = componentInstanceId, + extra = "restoringMessages=${nonNullPreviousState.messages.size} restoringViewport=${nonNullPreviousState.viewportPhase}" + ) + repairingUnexpectedReset = true + _state.update { repairUnexpectedChatReset(nonNullPreviousState, state) } + repairingUnexpectedReset = false + return@onEach + } + ChatConversationLog.logViewportState( + event = "state", + state = state, + componentInstanceId = componentInstanceId + ) + previousTracedState = state + } + .launchIn(scope) + repositoryMessage.fileDownloadFlow .filterIsInstance() .onEach { event -> refreshDraftLinkPreviewOnPhotoDownloadIfNeeded(event.fileId) } .launchIn(scope) + + repositoryMessage.messageDownloadFlow + .filterIsInstance() + .onEach { event -> + if (event.chatId != chatId) return@onEach + refreshSponsoredMessageAfterMediaDownload( + messageId = event.messageId, + fileId = event.fileId, + path = event.path + ) + } + .launchIn(scope) } private fun initialLoad() { scope.launch { + ChatConversationLog.logViewport( + chatId = chatId, + threadId = _state.value.currentMessageThreadId ?: _state.value.currentTopicId, + event = "initial_load_start", + componentInstanceId = componentInstanceId + ) chatListRepository.getChatById(chatId)?.let { chat -> if (chat.unreadCount > 0) { _state.update { @@ -281,6 +580,12 @@ class DefaultChatComponent( } } repositoryMessage.openChat(chatId) + ChatConversationLog.logViewport( + chatId = chatId, + threadId = _state.value.currentMessageThreadId ?: _state.value.currentTopicId, + event = "open_chat_called", + componentInstanceId = componentInstanceId + ) withContext(Dispatchers.Main) { loadChatInfo() loadDraft() @@ -291,27 +596,7 @@ class DefaultChatComponent( } } - private fun startAutoLoad() { - autoLoadJob?.cancel() - autoLoadJob = scope.launch { - while (isActive) { - val currentState = _state.value - if ( - initialMessageId == null && - currentState.messages.isEmpty() && - !currentState.isLoading && - !currentState.isLoadingOlder && - !currentState.isLoadingNewer - ) { - Log.d("DefaultChatComponent", "Auto-loading messages...") - loadMessages() - } - delay(5000) - } - } - } - - private fun handleResume(initialMessageId: Long?) { + private fun handleResume() { val currentState = _state.value if (currentState.isLoading || currentState.isLoadingOlder || currentState.isLoadingNewer) return @@ -319,13 +604,48 @@ class DefaultChatComponent( if (initialMessageId != null) { scrollToMessage(initialMessageId) } else if (currentState.messages.isEmpty()) { - loadMessages() + requestInitialLoad(source = "lifecycle_resume") } } else if (currentState.messages.isEmpty() && currentState.currentTopicId == null) { - loadMessages() + requestInitialLoad(source = "lifecycle_resume") } } + internal fun requestInitialLoad(source: String) { + val currentState = _state.value + if (currentState.isLoading || currentState.isLoadingOlder || currentState.isLoadingNewer) return + + val savedViewport = cacheProvider.getChatViewport(chatId, currentState.effectiveThreadId()) + val loadKey = buildChatInitialLoadKey( + chatId = chatId, + effectiveThreadId = currentState.effectiveThreadId(), + initialMessageId = initialMessageId, + savedViewport = savedViewport, + firstUnreadMessageId = currentState.unreadSeparatorLastReadInboxMessageId.takeIf { + currentState.unreadSeparatorCount > 0 + }, + rootMessageId = currentState.rootMessage?.id + ) + if (!shouldStartInitialLoad(lastStartedLoadKey, loadKey, hasStartedInitialLoadForContext)) { + return + } + lastStartedLoadKey = loadKey + hasStartedInitialLoadForContext = true + ChatConversationLog.logViewportState( + event = "request_initial_load", + state = currentState, + componentInstanceId = componentInstanceId, + extra = "source=$source savedViewportAnchor=${savedViewport?.anchorMessageId ?: 0L}" + ) + ChatConversationLog.logPerf( + component = this, + phase = "initial_load_trigger", + source = source, + anchorId = savedViewport?.anchorMessageId + ) + loadMessages(loadSource = source) + } + private fun loadMembers() { scope.launch { val currentState = _state.value @@ -489,6 +809,10 @@ class DefaultChatComponent( override fun onUnpinMessage(message: MessageModel) = store.accept(ChatStore.Intent.UnpinMessage(message)) + override fun onHidePinnedMessage() = store.accept(ChatStore.Intent.HidePinnedMessage) + + override fun onShowPinnedMessage() = store.accept(ChatStore.Intent.ShowPinnedMessage) + override fun onPinnedMessageClick(message: MessageModel?) = store.accept(ChatStore.Intent.PinnedMessageClick(message)) @@ -501,6 +825,14 @@ class DefaultChatComponent( override fun onScrollCommandConsumed() = store.accept(ChatStore.Intent.ScrollCommandConsumed) override fun onViewportSettled() { + val before = _state.value.viewportPhase + val currentThreadId = _state.value.effectiveThreadId() + val sessionId = activeLoadSession?.sessionId + ChatConversationLog.logViewportState( + event = "on_viewport_settled_before", + state = _state.value, + componentInstanceId = componentInstanceId + ) _state.update { if (it.viewportPhase == ChatViewportPhase.Settled) { it @@ -508,6 +840,27 @@ class DefaultChatComponent( it.copy(viewportPhase = ChatViewportPhase.Settled) } } + ChatConversationLog.logViewportState( + event = "on_viewport_settled_after", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "before=$before sessionId=${sessionId ?: "none"}" + ) + if (before != ChatViewportPhase.Settled) { + ChatConversationLog.logPerf(component = this, phase = "viewport_settled") + ChatOpenPerfBridge.clearSession(chatId, currentThreadId, sessionId) + activeLoadSession = null + } + } + + override fun onMessageViewportChanged( + visibleMessageIds: Set, + nearbyMessageIds: Set + ) { + richMessageCoordinator.onViewportChanged( + visibleMessageIds = visibleMessageIds, + nearbyMessageIds = nearbyMessageIds + ) } override fun onScrollToBottom() = store.accept(ChatStore.Intent.ScrollToBottom) @@ -673,6 +1026,7 @@ class DefaultChatComponent( store.accept(ChatStore.Intent.SearchDateRangeChange(fromEpochSeconds, toEpochSeconds)) override fun onClearHistory() = store.accept(ChatStore.Intent.ClearHistory) + override fun onLeaveChat() = store.accept(ChatStore.Intent.LeaveChat) override fun onDeleteChat() = store.accept(ChatStore.Intent.DeleteChat) override fun onReport() = store.accept(ChatStore.Intent.Report) @@ -701,6 +1055,24 @@ class DefaultChatComponent( override fun onLinkClick(url: String) = store.accept(ChatStore.Intent.LinkClick(url)) + override fun onChannelSponsoredMessageClick( + messageId: Long, + url: String, + isMediaClick: Boolean + ) { + scope.launch { + runCatching { + repositoryMessage.clickChannelSponsoredMessage( + chatId = chatId, + messageId = messageId, + isMediaClick = isMediaClick, + fromFullscreen = false + ) + } + onLink(url) + } + } + override fun onOpenInvoice(slug: String?, messageId: Long?) = store.accept(ChatStore.Intent.OpenInvoice(slug, messageId)) @@ -741,8 +1113,72 @@ class DefaultChatComponent( } } + override fun onStageAttachments(attachments: List) { + _state.update { it.copy(stagedAttachments = attachments) } + } + + override fun onClearPendingAttachments() { + cleanupTempAttachments(_state.value.stagedAttachments) + _state.update { it.copy(stagedAttachments = emptyList()) } + } + + override fun onConsumeInitialShare(requestId: Long) { + val current = _state.value.initialShare + if (current?.requestId != requestId) return + _state.update { + it.copy( + initialShareConsumed = true, + initialShare = null + ) + } + onInitialShareConsumed(requestId) + } + + override fun onRemovePendingAttachment(path: String) { + val current = _state.value.stagedAttachments + val removed = current.firstOrNull { it.localPath == path } ?: return + cleanupTempAttachments(listOf(removed)) + _state.update { + it.copy(stagedAttachments = current.filterNot { attachment -> attachment.localPath == path }) + } + } + + override fun onReplacePendingAttachment(oldPath: String, attachment: PendingAttachment) { + val current = _state.value.stagedAttachments.toMutableList() + val index = current.indexOfFirst { it.localPath == oldPath } + if (index == -1) return + val previous = current[index] + current[index] = attachment + cleanupTempAttachments(listOf(previous), excludePaths = setOf(attachment.localPath)) + _state.update { it.copy(stagedAttachments = current) } + } + + override fun onSendPendingAttachments( + attachments: List, + caption: String, + captionEntities: List, + sendOptions: MessageSendOptions + ) { + handleSendPendingAttachments( + attachments = attachments, + caption = caption, + captionEntities = captionEntities, + sendOptions = sendOptions + ) + } + override fun onClosePoll(messageId: Long) = store.accept(ChatStore.Intent.ClosePoll(messageId)) + internal fun cleanupTempAttachments( + attachments: List, + excludePaths: Set = emptySet() + ) { + attachments.forEach { attachment -> + if (!attachment.deleteAfterUse || attachment.localPath in excludePaths) return@forEach + runCatching { File(attachment.localPath).deleteRecursively() } + } + } + private fun scheduleViewportPersistence(threadId: Long?, viewport: ChatViewportCacheEntry) { val previous = when { pendingViewportToPersist != null && pendingViewportThreadId == threadId -> pendingViewportToPersist diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorScreen.kt index 076a446b..5b33a4ae 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorScreen.kt @@ -14,19 +14,66 @@ import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Brush +import androidx.compose.material.icons.rounded.CleaningServices +import androidx.compose.material.icons.rounded.Crop +import androidx.compose.material.icons.rounded.TextFields +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ContainedLoadingIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale @@ -43,8 +90,24 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.monogram.presentation.R -import org.monogram.presentation.features.chats.conversation.editor.photo.components.* -import org.monogram.presentation.features.chats.conversation.editor.photo.crop.* +import org.monogram.presentation.features.chats.conversation.editor.photo.components.DrawControls +import org.monogram.presentation.features.chats.conversation.editor.photo.components.EditorTopBar +import org.monogram.presentation.features.chats.conversation.editor.photo.components.FilterControls +import org.monogram.presentation.features.chats.conversation.editor.photo.components.TextEntryDialog +import org.monogram.presentation.features.chats.conversation.editor.photo.components.TransformControls +import org.monogram.presentation.features.chats.conversation.editor.photo.components.normalizeRotationDegrees +import org.monogram.presentation.features.chats.conversation.editor.photo.components.rotateClockwiseAnimationTarget +import org.monogram.presentation.features.chats.conversation.editor.photo.crop.CropOverlay +import org.monogram.presentation.features.chats.conversation.editor.photo.crop.CropScrim +import org.monogram.presentation.features.chats.conversation.editor.photo.crop.calculateScalarTransformedBounds +import org.monogram.presentation.features.chats.conversation.editor.photo.crop.calculateTargetFillRect +import org.monogram.presentation.features.chats.conversation.editor.photo.crop.clampOffsetToCoverCrop +import org.monogram.presentation.features.chats.conversation.editor.photo.crop.constrainCropRectToImage +import org.monogram.presentation.features.chats.conversation.editor.photo.crop.fitContentInBounds +import org.monogram.presentation.features.chats.conversation.editor.photo.crop.minimumScaleToCoverCrop +import org.monogram.presentation.features.chats.conversation.editor.photo.crop.offsetForRotationAroundAnchor +import org.monogram.presentation.features.chats.conversation.editor.photo.crop.offsetForZoomAroundAnchor +import org.monogram.presentation.features.chats.conversation.editor.photo.crop.rememberCropEditorState import java.io.File enum class EditorTool(val labelRes: Int, val icon: ImageVector) { @@ -307,13 +370,16 @@ fun PhotoEditorScreen( val hasChanges by remember { derivedStateOf { - paths.isNotEmpty() || - textElements.isNotEmpty() || - currentFilter != null || - (cropState.cropRect != Rect.Zero && cropState.cropRect != cropState.defaultCropRect) || - normalizeRotationDegrees(imageRotation) != 0f || - imageScale != 1f || - imageOffset != Offset.Zero + hasMeaningfulPhotoEdits( + paths = paths, + textElements = textElements, + filter = currentFilter, + cropRect = cropState.cropRect, + defaultCropRect = cropState.defaultCropRect, + imageRotation = normalizeRotationDegrees(imageRotation), + imageScale = imageScale, + imageOffset = imageOffset + ) } } @@ -336,6 +402,10 @@ fun PhotoEditorScreen( onClose = handleBack, onSave = { if (!isSaving) { + if (!hasChanges) { + onSave(imagePath) + return@EditorTopBar + } scope.launch { isSaving = true val result = saveImage( @@ -529,7 +599,12 @@ fun PhotoEditorScreen( val y1 = change.previousPosition.y val x2 = change.position.x val y2 = change.position.y - cur.path.quadraticTo(x1, y1, (x1 + x2) / 2, (y1 + y2) / 2) + cur.path.quadraticTo( + x1, + y1, + (x1 + x2) / 2, + (y1 + y2) / 2 + ) paths.add(paths.removeAt(index)) } ) @@ -610,7 +685,11 @@ fun PhotoEditorScreen( Box( modifier = Modifier .matchParentSize() - .border(1.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) + .border( + 1.dp, + Color.White.copy(alpha = 0.5f), + RoundedCornerShape(4.dp) + ) ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorUtils.kt index 9d2ba9f6..56b8d226 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/editor/photo/PhotoEditorUtils.kt @@ -1,25 +1,32 @@ package org.monogram.presentation.features.chats.conversation.editor.photo import android.content.Context -import android.graphics.* +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RectF +import android.graphics.Typeface import androidx.annotation.StringRes import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.asAndroidPath +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.IntSize +import androidx.core.graphics.createBitmap +import androidx.core.graphics.withTranslation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.monogram.presentation.R import java.io.File import java.io.FileOutputStream -import java.util.* +import java.util.UUID +import kotlin.math.abs import android.graphics.Canvas as AndroidCanvas import android.graphics.Paint as AndroidPaint -import androidx.core.graphics.createBitmap -import androidx.core.graphics.withTranslation data class DrawnPath( val path: Path, @@ -43,6 +50,9 @@ data class ImageFilter( val colorMatrix: ColorMatrix ) +private const val PhotoEditorFloatTolerance = 0.001f +private const val EditedPhotoExportQuality = 95 + fun getPresetFilters(): List { return listOf( ImageFilter(R.string.photo_editor_filter_original, ColorMatrix()), @@ -110,6 +120,34 @@ fun getPresetFilters(): List { ) } +fun hasMeaningfulPhotoEdits( + paths: List, + textElements: List, + filter: ImageFilter?, + cropRect: Rect, + defaultCropRect: Rect, + imageRotation: Float, + imageScale: Float, + imageOffset: Offset +): Boolean { + return paths.isNotEmpty() || + textElements.isNotEmpty() || + filter != null || + (cropRect != Rect.Zero && cropRect != defaultCropRect) || + !imageRotation.isApproximatelyZero() || + !imageScale.isApproximately(1f) || + !imageOffset.isApproximatelyZero() +} + +private fun Float.isApproximately(other: Float): Boolean = + abs(this - other) <= PhotoEditorFloatTolerance + +private fun Float.isApproximatelyZero(): Boolean = isApproximately(0f) + +private fun Offset.isApproximatelyZero(): Boolean { + return x.isApproximatelyZero() && y.isApproximatelyZero() +} + suspend fun saveImage( context: Context, originalPath: String, @@ -239,7 +277,7 @@ suspend fun saveImage( val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.jpg") FileOutputStream(file).use { out -> - resultBitmap.compress(Bitmap.CompressFormat.JPEG, 95, out) + resultBitmap.compress(Bitmap.CompressFormat.JPEG, EditedPhotoExportQuality, out) } bitmap.recycle() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicy.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicy.kt new file mode 100644 index 00000000..e46be4ab --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicy.kt @@ -0,0 +1,31 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.monogram.domain.models.ChatViewportCacheEntry +import org.monogram.presentation.features.chats.conversation.ChatInitialLoadKey + +internal fun buildChatInitialLoadKey( + chatId: Long, + effectiveThreadId: Long?, + initialMessageId: Long?, + savedViewport: ChatViewportCacheEntry?, + firstUnreadMessageId: Long?, + rootMessageId: Long? +): ChatInitialLoadKey { + return ChatInitialLoadKey( + chatId = chatId, + effectiveThreadId = effectiveThreadId, + initialMessageId = initialMessageId, + savedViewportAnchorMessageId = savedViewport?.anchorMessageId, + firstUnreadMessageId = firstUnreadMessageId, + rootMessageId = rootMessageId + ) +} + +internal fun shouldStartInitialLoad( + currentKey: ChatInitialLoadKey?, + nextKey: ChatInitialLoadKey, + hasStartedForCurrentContext: Boolean +): Boolean { + if (!hasStartedForCurrentContext) return true + return currentKey != nextKey +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportRestorePolicy.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportRestorePolicy.kt index 07a77872..46357e93 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportRestorePolicy.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatViewportRestorePolicy.kt @@ -16,6 +16,12 @@ internal sealed interface InitialChatScrollTarget { data class Comments(val command: ChatScrollCommand) : InitialChatScrollTarget } +internal fun InitialChatScrollTarget.perfTargetName(): String = when (this) { + is InitialChatScrollTarget.AroundMessage -> "around" + is InitialChatScrollTarget.Bottom -> "bottom" + is InitialChatScrollTarget.Comments -> "comments" +} + internal fun resolveInitialChatScrollTarget( explicitMessageId: Long?, savedViewport: ChatViewportCacheEntry?, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/SponsoredMessages.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/SponsoredMessages.kt new file mode 100644 index 00000000..8606d48b --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/SponsoredMessages.kt @@ -0,0 +1,268 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.SponsoredMessageModel +import org.monogram.domain.models.SponsoredMessagesFeedModel +import org.monogram.presentation.features.chats.conversation.DefaultChatComponent + +internal data class ChannelSponsoredRequestContext( + val isChannel: Boolean, + val isGroup: Boolean, + val isBot: Boolean, + val currentTopicId: Long?, + val rootMessageId: Long?, + val viewAsTopics: Boolean, + val isPremium: Boolean, + val showSponsoredMessagesForPremium: Boolean +) + +internal fun shouldRequestChannelSponsoredMessage(context: ChannelSponsoredRequestContext): Boolean { + if (!context.isChannel) return false + if (context.isGroup) return false + if (context.isBot) return false + if (context.currentTopicId != null) return false + if (context.rootMessageId != null) return false + if (context.viewAsTopics) return false + return !context.isPremium || context.showSponsoredMessagesForPremium +} + +internal fun DefaultChatComponent.observeSponsoredMessagePolicy() { + _state + .map { state -> + ChannelSponsoredRequestContext( + isChannel = state.isChannel, + isGroup = state.isGroup, + isBot = state.isBot, + currentTopicId = state.currentTopicId, + rootMessageId = state.rootMessage?.id, + viewAsTopics = state.viewAsTopics, + isPremium = state.currentUser?.isPremium == true, + showSponsoredMessagesForPremium = state.showSponsoredMessagesForPremium + ) + } + .distinctUntilChanged() + .onEach(::refreshSponsoredMessageIfNeeded) + .launchIn(scope) +} + +internal fun DefaultChatComponent.refreshSponsoredMessageIfNeeded( + context: ChannelSponsoredRequestContext = ChannelSponsoredRequestContext( + isChannel = _state.value.isChannel, + isGroup = _state.value.isGroup, + isBot = _state.value.isBot, + currentTopicId = _state.value.currentTopicId, + rootMessageId = _state.value.rootMessage?.id, + viewAsTopics = _state.value.viewAsTopics, + isPremium = _state.value.currentUser?.isPremium == true, + showSponsoredMessagesForPremium = _state.value.showSponsoredMessagesForPremium + ) +) { + sponsoredMessageLoadingJob?.cancel() + if (!shouldRequestChannelSponsoredMessage(context)) { + _state.update { it.copy(channelSponsoredMessages = null) } + return + } + + sponsoredMessageLoadingJob = scope.launch { + val sponsoredMessages = + runCatching { repositoryMessage.getChannelSponsoredMessages(chatId) } + .getOrNull() + _state.update { current -> + val mergedSponsoredMessages = sponsoredMessages?.mergeWithExistingMediaPaths( + existing = current.channelSponsoredMessages + ) + if ( + shouldRequestChannelSponsoredMessage( + ChannelSponsoredRequestContext( + isChannel = current.isChannel, + isGroup = current.isGroup, + isBot = current.isBot, + currentTopicId = current.currentTopicId, + rootMessageId = current.rootMessage?.id, + viewAsTopics = current.viewAsTopics, + isPremium = current.currentUser?.isPremium == true, + showSponsoredMessagesForPremium = current.showSponsoredMessagesForPremium + ) + ) + ) { + current.copy(channelSponsoredMessages = mergedSponsoredMessages) + } else { + current.copy(channelSponsoredMessages = null) + } + } + } +} + +internal fun DefaultChatComponent.refreshSponsoredMessageAfterMediaDownload( + messageId: Long, + fileId: Int, + path: String +) { + val current = _state.value + val currentMessages = current.channelSponsoredMessages?.messages.orEmpty() + val affected = currentMessages.firstOrNull { sponsored -> + when (val content = sponsored.content) { + is MessageContent.Photo -> content.fileId == fileId + is MessageContent.Video -> content.fileId == fileId + is MessageContent.Gif -> content.fileId == fileId + else -> false + } + } ?: return + + _state.update { currentState -> + val feed = currentState.channelSponsoredMessages ?: return@update currentState + val patchedFeed = feed.patchSponsoredMediaPath(fileId = fileId, path = path) + if (patchedFeed === feed) { + currentState + } else { + currentState.copy(channelSponsoredMessages = patchedFeed) + } + } + sponsoredMessageLoadingJob?.cancel() + sponsoredMessageLoadingJob = scope.launch { + delay(300) + refreshSponsoredMessageIfNeeded() + } +} + +private fun SponsoredMessagesFeedModel.patchSponsoredMediaPath( + fileId: Int, + path: String +): SponsoredMessagesFeedModel { + var changed = false + val updatedMessages = messages.map { message -> + val updatedMessage = message.patchSponsoredMediaPath(fileId = fileId, path = path) + if (updatedMessage !== message) { + changed = true + } + updatedMessage + } + return if (changed) { + copy(messages = updatedMessages) + } else { + this + } +} + +private fun SponsoredMessagesFeedModel.mergeWithExistingMediaPaths( + existing: SponsoredMessagesFeedModel? +): SponsoredMessagesFeedModel { + val existingMessages = existing?.messages.orEmpty() + if (existingMessages.isEmpty()) return this + + var changed = false + val mergedMessages = messages.map { incoming -> + val current = existingMessages.firstOrNull { it.messageId == incoming.messageId } + ?: return@map incoming + val merged = incoming.preserveExistingMediaPath(current) + if (merged !== incoming) { + changed = true + } + merged + } + return if (changed) { + copy(messages = mergedMessages) + } else { + this + } +} + +private fun SponsoredMessageModel.patchSponsoredMediaPath( + fileId: Int, + path: String +): SponsoredMessageModel { + val updatedContent = when (val currentContent = content) { + is MessageContent.Photo -> { + if (currentContent.fileId != fileId || currentContent.path == path) { + currentContent + } else { + currentContent.copy( + path = path, + isDownloading = false, + downloadProgress = 1f, + downloadError = false + ) + } + } + + is MessageContent.Video -> { + if (currentContent.fileId != fileId || currentContent.path == path) { + currentContent + } else { + currentContent.copy( + path = path, + isDownloading = false, + downloadProgress = 1f, + downloadError = false + ) + } + } + + is MessageContent.Gif -> { + if (currentContent.fileId != fileId || currentContent.path == path) { + currentContent + } else { + currentContent.copy( + path = path, + isDownloading = false, + downloadProgress = 1f, + downloadError = false + ) + } + } + + else -> content + } + return if (updatedContent === content) { + this + } else { + copy(content = updatedContent) + } +} + +private fun SponsoredMessageModel.preserveExistingMediaPath( + existing: SponsoredMessageModel +): SponsoredMessageModel { + val incomingContent = content + val existingContent = existing.content + val mergedContent = when { + incomingContent is MessageContent.Photo && existingContent is MessageContent.Photo -> { + if (incomingContent.path.isNullOrBlank() && !existingContent.path.isNullOrBlank() && incomingContent.fileId == existingContent.fileId) { + incomingContent.copy(path = existingContent.path) + } else { + incomingContent + } + } + + incomingContent is MessageContent.Video && existingContent is MessageContent.Video -> { + if (incomingContent.path.isNullOrBlank() && !existingContent.path.isNullOrBlank() && incomingContent.fileId == existingContent.fileId) { + incomingContent.copy(path = existingContent.path) + } else { + incomingContent + } + } + + incomingContent is MessageContent.Gif && existingContent is MessageContent.Gif -> { + if (incomingContent.path.isNullOrBlank() && !existingContent.path.isNullOrBlank() && incomingContent.fileId == existingContent.fileId) { + incomingContent.copy(path = existingContent.path) + } else { + incomingContent + } + } + + else -> incomingContent + } + + return if (mergedContent === incomingContent) { + this + } else { + copy(content = mergedContent) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/chat/ChatInfo.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/chat/ChatInfo.kt index 62bbd3dc..13239f82 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/chat/ChatInfo.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/chat/ChatInfo.kt @@ -14,6 +14,9 @@ import org.monogram.domain.models.ChatType import org.monogram.domain.models.UserStatusType import org.monogram.domain.models.UserTypeEnum import org.monogram.domain.repository.ChatMemberStatus +import org.monogram.presentation.features.chats.common.ChatActionState +import org.monogram.presentation.features.chats.common.ChatActionType +import org.monogram.presentation.features.chats.conversation.ChatConversationLog import org.monogram.presentation.features.chats.conversation.DefaultChatComponent internal fun DefaultChatComponent.loadChatInfo() { @@ -75,6 +78,12 @@ internal fun DefaultChatComponent.loadChatInfo() { loadTopics() } } else if (wasTopics) { + ChatConversationLog.logState( + stream = ChatConversationLog.STREAM_VIEWPORT, + event = "chat_info_topics_disabled_reload_messages", + state = _state.value, + componentInstanceId = componentInstanceId + ) loadMessages() } } @@ -179,6 +188,8 @@ private fun DefaultChatComponent.updateBaseChatState(chat: ChatModel) { typingAction = chat.typingAction, viewAsTopics = chat.viewAsTopics, isMuted = chat.isMuted, + canDeleteChat = chat.canBeDeletedOnlyForSelf || chat.canBeDeletedForAllUsers, + channelSponsoredMessages = if (chat.viewAsTopics) null else currentState.channelSponsoredMessages ).withUnreadSessionFromChat( chatUnreadCount = chat.unreadCount, chatLastReadInboxMessageId = chat.lastReadInboxMessageId @@ -203,7 +214,9 @@ private fun DefaultChatComponent.updateEffectiveChatState(chat: ChatModel) { } internal fun DefaultChatComponent.handleToggleMute() { - chatOperationsRepository.toggleMuteChats(setOf(chatId), !_state.value.isMuted) + runChatAction(ChatActionType.Mute) { + chatOperationsRepository.toggleMuteChats(setOf(chatId), !_state.value.isMuted) + } } internal fun DefaultChatComponent.handleAddToAdBlockWhitelist() { @@ -211,7 +224,7 @@ internal fun DefaultChatComponent.handleAddToAdBlockWhitelist() { if (chatId in current) return appPreferences.setAdBlockWhitelistedChannels(current + chatId) - loadMessages(force = true) + _state.update { it.copy(isWhitelistedInAdBlock = true) } } internal fun DefaultChatComponent.handleRemoveFromAdBlockWhitelist() { @@ -219,26 +232,31 @@ internal fun DefaultChatComponent.handleRemoveFromAdBlockWhitelist() { if (chatId !in current) return appPreferences.setAdBlockWhitelistedChannels(current - chatId) - loadMessages(force = true) + _state.update { it.copy(isWhitelistedInAdBlock = false) } } internal fun DefaultChatComponent.handleClearHistory() { - chatOperationsRepository.clearChatHistory(chatId, true) + runChatAction(ChatActionType.ClearHistory) { + chatOperationsRepository.clearChatHistory(chatId, true) + } +} + +internal fun DefaultChatComponent.handleLeaveChat() { + runChatAction(ChatActionType.Leave, closeOnSuccess = true) { + chatOperationsRepository.leaveChat(chatId) + } } internal fun DefaultChatComponent.handleDeleteChat() { - chatOperationsRepository.deleteChats(setOf(chatId)) - onBackClicked() + runChatAction(ChatActionType.Delete, closeOnSuccess = true) { + chatOperationsRepository.deleteChats(setOf(chatId)) + } } internal fun DefaultChatComponent.handleJoinChat() { - scope.launch { + runChatAction(ChatActionType.Join) { val effectiveChatId = _state.value.effectiveThreadChatId(chatId) - chatInfoRepository.setChatMemberStatus( - effectiveChatId, - userRepository.getMe().id, - ChatMemberStatus.Member - ) + repositoryMessage.joinChat(effectiveChatId) chatListRepository.getChatById(effectiveChatId)?.let(::updateEffectiveChatState) refreshEffectiveChatDetails(effectiveChatId) refreshCurrentUserRestrictionState() @@ -246,7 +264,7 @@ internal fun DefaultChatComponent.handleJoinChat() { } internal fun DefaultChatComponent.handleBlockUser(userId: Long) { - scope.launch { + runChatAction(ChatActionType.BlockUser) { if (_state.value.isGroup || _state.value.isChannel) { chatInfoRepository.setChatMemberStatus(chatId, userId, ChatMemberStatus.Banned()) } else { @@ -256,11 +274,35 @@ internal fun DefaultChatComponent.handleBlockUser(userId: Long) { } internal fun DefaultChatComponent.handleUnblockUser(userId: Long) { - scope.launch { + runChatAction(ChatActionType.UnblockUser) { privacyRepository.unblockUser(userId) } } +internal fun DefaultChatComponent.runChatAction( + action: ChatActionType, + closeOnSuccess: Boolean = false, + block: suspend () -> Unit +) { + if (_state.value.actionState is ChatActionState.Pending) return + scope.launch { + _state.update { it.copy(actionState = ChatActionState.Pending(action)) } + runCatching { block() } + .onSuccess { + _state.update { it.copy(actionState = ChatActionState.Success(action)) } + if (closeOnSuccess) { + onBackClicked() + } + } + .onFailure { error -> + val message = error.message ?: "Action failed" + _state.update { it.copy(actionState = ChatActionState.Failure(action, message)) } + toastMessageDisplayer.show(message) + } + _state.update { it.copy(actionState = ChatActionState.Idle) } + } +} + internal fun DefaultChatComponent.handleConfirmRestrict( permissions: org.monogram.domain.models.ChatPermissionsModel, untilDate: Int diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt index 3edf995c..40928240 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt @@ -5,9 +5,9 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import android.media.MediaMetadataRetriever -import androidx.exifinterface.media.ExifInterface import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.text.AnnotatedString +import androidx.exifinterface.media.ExifInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -18,13 +18,32 @@ import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.PollDraft +import org.monogram.presentation.features.chats.common.ChatActionType import org.monogram.presentation.features.chats.conversation.DefaultChatComponent import org.monogram.presentation.features.chats.conversation.editor.video.VideoQuality import org.monogram.presentation.features.chats.conversation.editor.video.VideoTrimRange import org.monogram.presentation.features.chats.conversation.editor.video.processVideo +import org.monogram.presentation.features.share.PendingAttachment +import org.monogram.presentation.features.share.PendingAttachmentKind import java.io.File import java.io.FileOutputStream import java.io.IOException +import kotlin.math.max +import kotlin.math.roundToInt + +private const val MaxCompressedPhotoLongSide = 3840 + +internal data class PhotoCompressionProfile( + val targetWidth: Int, + val targetHeight: Int, + val quality: Int +) + +internal sealed interface PendingAttachmentSendPlan { + data class Single(val attachment: PendingAttachment) : PendingAttachmentSendPlan + data class Album(val attachments: List) : PendingAttachmentSendPlan + data class Individual(val attachments: List) : PendingAttachmentSendPlan +} private fun DefaultChatComponent.shouldAutoScrollAfterSend(isAtBottom: Boolean): Boolean { return _state.value.rootMessage == null && !isAtBottom @@ -80,6 +99,8 @@ internal fun DefaultChatComponent.handleSendPhoto( sendOptions: MessageSendOptions = MessageSendOptions() ) { scope.launch { + val matchingAttachment = + _state.value.stagedAttachments.firstOrNull { it.localPath == photoPath } val shouldCompress = appPreferences.compressPhotos.value val finalPath = if (shouldCompress) { @@ -113,6 +134,12 @@ internal fun DefaultChatComponent.handleSendPhoto( if (sendOptions.scheduleDate != null) { loadScheduledMessages() } + _state.update { state -> + state.copy(stagedAttachments = state.stagedAttachments.filterNot { it.localPath == photoPath }) + } + if (sendOptions.scheduleDate == null) { + cleanupTempAttachments(listOfNotNull(matchingAttachment)) + } } } @@ -123,6 +150,8 @@ internal fun DefaultChatComponent.handleSendVideo( sendOptions: MessageSendOptions = MessageSendOptions() ) { scope.launch { + val matchingAttachment = + _state.value.stagedAttachments.firstOrNull { it.localPath == videoPath } val shouldCompress = appPreferences.compressVideos.value val finalPath = if (shouldCompress) { @@ -162,6 +191,12 @@ internal fun DefaultChatComponent.handleSendVideo( if (sendOptions.scheduleDate != null) { loadScheduledMessages() } + _state.update { state -> + state.copy(stagedAttachments = state.stagedAttachments.filterNot { it.localPath == videoPath }) + } + if (sendOptions.scheduleDate == null) { + cleanupTempAttachments(listOfNotNull(matchingAttachment)) + } } } @@ -212,6 +247,7 @@ internal fun DefaultChatComponent.handleSendDocument( sendOptions: MessageSendOptions = MessageSendOptions() ) { scope.launch { + val matchingAttachment = _state.value.stagedAttachments.firstOrNull { it.localPath == path } val currentState = _state.value val replyId = currentState.replyMessage?.id val threadId = currentState.effectiveThreadId() @@ -235,6 +271,12 @@ internal fun DefaultChatComponent.handleSendDocument( if (sendOptions.scheduleDate != null) { loadScheduledMessages() } + _state.update { state -> + state.copy(stagedAttachments = state.stagedAttachments.filterNot { it.localPath == path }) + } + if (sendOptions.scheduleDate == null) { + cleanupTempAttachments(listOfNotNull(matchingAttachment)) + } } } @@ -274,6 +316,7 @@ internal fun DefaultChatComponent.handleSendGifFile( sendOptions: MessageSendOptions = MessageSendOptions() ) { scope.launch { + val matchingAttachment = _state.value.stagedAttachments.firstOrNull { it.localPath == path } val currentState = _state.value val replyId = currentState.replyMessage?.id val threadId = currentState.effectiveThreadId() @@ -297,6 +340,12 @@ internal fun DefaultChatComponent.handleSendGifFile( if (sendOptions.scheduleDate != null) { loadScheduledMessages() } + _state.update { state -> + state.copy(stagedAttachments = state.stagedAttachments.filterNot { it.localPath == path }) + } + if (sendOptions.scheduleDate == null) { + cleanupTempAttachments(listOfNotNull(matchingAttachment)) + } } } @@ -307,6 +356,7 @@ internal fun DefaultChatComponent.handleSendAlbum( sendOptions: MessageSendOptions = MessageSendOptions() ) { scope.launch { + val matchingAttachments = _state.value.stagedAttachments.filter { it.localPath in paths } val compressPhotos = appPreferences.compressPhotos.value val compressVideos = appPreferences.compressVideos.value @@ -361,6 +411,117 @@ internal fun DefaultChatComponent.handleSendAlbum( if (sendOptions.scheduleDate != null) { loadScheduledMessages() } + _state.update { state -> + state.copy(stagedAttachments = state.stagedAttachments.filterNot { it.localPath in paths }) + } + if (sendOptions.scheduleDate == null) { + cleanupTempAttachments(matchingAttachments) + } + } +} + +internal fun DefaultChatComponent.handleSendPendingAttachments( + attachments: List, + caption: String, + captionEntities: List, + sendOptions: MessageSendOptions = MessageSendOptions() +) { + when (val plan = resolvePendingAttachmentSendPlan(attachments) ?: return) { + is PendingAttachmentSendPlan.Album -> handleSendAlbum( + paths = plan.attachments.map { it.localPath }, + caption = caption, + captionEntities = captionEntities, + sendOptions = sendOptions + ) + + is PendingAttachmentSendPlan.Single -> { + when (plan.attachment.kind) { + PendingAttachmentKind.PHOTO -> handleSendPhoto( + photoPath = plan.attachment.localPath, + caption = caption, + captionEntities = captionEntities, + sendOptions = sendOptions + ) + + PendingAttachmentKind.VIDEO -> handleSendVideo( + videoPath = plan.attachment.localPath, + caption = caption, + captionEntities = captionEntities, + sendOptions = sendOptions + ) + + PendingAttachmentKind.GIF -> handleSendGifFile( + path = plan.attachment.localPath, + caption = caption, + captionEntities = captionEntities, + sendOptions = sendOptions + ) + + PendingAttachmentKind.DOCUMENT -> handleSendDocument( + path = plan.attachment.localPath, + caption = caption, + captionEntities = captionEntities, + sendOptions = sendOptions + ) + } + } + + is PendingAttachmentSendPlan.Individual -> { + plan.attachments.forEachIndexed { index, attachment -> + val itemCaption = if (index == 0) caption else "" + val itemEntities = if (index == 0) captionEntities else emptyList() + when (attachment.kind) { + PendingAttachmentKind.PHOTO -> handleSendPhoto( + photoPath = attachment.localPath, + caption = itemCaption, + captionEntities = itemEntities, + sendOptions = sendOptions + ) + + PendingAttachmentKind.VIDEO -> handleSendVideo( + videoPath = attachment.localPath, + caption = itemCaption, + captionEntities = itemEntities, + sendOptions = sendOptions + ) + + PendingAttachmentKind.GIF -> handleSendGifFile( + path = attachment.localPath, + caption = itemCaption, + captionEntities = itemEntities, + sendOptions = sendOptions + ) + + PendingAttachmentKind.DOCUMENT -> handleSendDocument( + path = attachment.localPath, + caption = itemCaption, + captionEntities = itemEntities, + sendOptions = sendOptions + ) + } + } + } + } +} + +internal fun resolvePendingAttachmentSendPlan( + attachments: List +): PendingAttachmentSendPlan? { + if (attachments.isEmpty()) return null + if (attachments.size == 1) return PendingAttachmentSendPlan.Single(attachments.first()) + + val media = attachments.filter { it.kind != PendingAttachmentKind.DOCUMENT } + val hasDocuments = attachments.any { it.kind == PendingAttachmentKind.DOCUMENT } + val hasGifs = attachments.any { it.kind == PendingAttachmentKind.GIF } + val canSendAsAlbum = !hasDocuments && + !hasGifs && + media.size > 1 && + media.all { it.kind.isAlbumMedia } + + return if (canSendAsAlbum) { + PendingAttachmentSendPlan.Album(attachments) + } else { + PendingAttachmentSendPlan.Individual(attachments) } } @@ -371,13 +532,25 @@ private fun DefaultChatComponent.compressPhotoForUpload(photoPath: String): Stri if (normalizedBitmap !== sourceBitmap) { sourceBitmap.recycle() } + val compressionProfile = resolvePhotoCompressionProfile( + width = normalizedBitmap.width, + height = normalizedBitmap.height + ) + val bitmapForUpload = normalizedBitmap.scaleForCompression(compressionProfile) val compressedFile = File( cacheController.getCacheDir(), "compressed_photo_${System.currentTimeMillis()}.jpg" ) FileOutputStream(compressedFile).use { out -> - normalizedBitmap.compress(Bitmap.CompressFormat.JPEG, 95, out) + bitmapForUpload.compress( + Bitmap.CompressFormat.JPEG, + compressionProfile.quality, + out + ) + } + if (bitmapForUpload !== normalizedBitmap && !bitmapForUpload.isRecycled) { + bitmapForUpload.recycle() } if (!normalizedBitmap.isRecycled) { normalizedBitmap.recycle() @@ -388,6 +561,37 @@ private fun DefaultChatComponent.compressPhotoForUpload(photoPath: String): Stri } } +internal fun resolvePhotoCompressionProfile(width: Int, height: Int): PhotoCompressionProfile { + val safeWidth = width.coerceAtLeast(1) + val safeHeight = height.coerceAtLeast(1) + val longestSide = max(safeWidth, safeHeight) + val resizeScale = if (longestSide > MaxCompressedPhotoLongSide) { + MaxCompressedPhotoLongSide / longestSide.toFloat() + } else { + 1f + } + val targetWidth = (safeWidth * resizeScale).roundToInt().coerceAtLeast(1) + val targetHeight = (safeHeight * resizeScale).roundToInt().coerceAtLeast(1) + val targetPixels = targetWidth.toLong() * targetHeight.toLong() + val quality = when { + targetPixels <= 2_000_000L -> 92 + targetPixels <= 4_000_000L -> 90 + targetPixels <= 8_000_000L -> 88 + else -> 85 + } + + return PhotoCompressionProfile( + targetWidth = targetWidth, + targetHeight = targetHeight, + quality = quality + ) +} + +private fun Bitmap.scaleForCompression(profile: PhotoCompressionProfile): Bitmap { + if (width == profile.targetWidth && height == profile.targetHeight) return this + return Bitmap.createScaledBitmap(this, profile.targetWidth, profile.targetHeight, true) +} + private fun Bitmap.applyExifOrientation(path: String): Bitmap { val orientation = try { ExifInterface(path).getAttributeInt( @@ -504,7 +708,10 @@ internal fun DefaultChatComponent.handleReportMessage(message: MessageModel) { } internal fun DefaultChatComponent.handleReportReasonSelected(reason: String) { - chatOperationsRepository.reportChat(chatId, reason) + runChatAction(ChatActionType.Report) { + chatOperationsRepository.reportChat(chatId, reason) + _state.update { it.copy(showReportDialog = false) } + } } internal fun DefaultChatComponent.handleCopyLink(localClipboard: Clipboard) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt index 54605ca8..09cf2230 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt @@ -392,7 +392,9 @@ internal fun DefaultChatComponent.handleUnpinMessage(message: MessageModel) { } internal fun DefaultChatComponent.handleClearMessages() { - chatOperationsRepository.clearChatHistory(chatId, false) + scope.launch { + chatOperationsRepository.clearChatHistory(chatId, false) + } } internal fun DefaultChatComponent.handleSendScheduledNow(message: MessageModel) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt index 18ad4d75..fa8e3cde 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt @@ -7,9 +7,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.monogram.core.perf.ChatOpenPerfBridge import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageDownloadEvent import org.monogram.domain.models.MessageModel @@ -19,12 +21,15 @@ import org.monogram.domain.models.UserModel import org.monogram.domain.repository.ReadUpdate import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression import org.monogram.presentation.features.chats.conversation.ChatComponent +import org.monogram.presentation.features.chats.conversation.ChatConversationLog import org.monogram.presentation.features.chats.conversation.ChatScrollCommand import org.monogram.presentation.features.chats.conversation.ChatViewportPhase +import org.monogram.presentation.features.chats.conversation.ConversationLoadSession import org.monogram.presentation.features.chats.conversation.DefaultChatComponent import org.monogram.presentation.features.chats.conversation.ScrollAlign import java.io.File import kotlin.math.abs +import kotlin.time.Duration.Companion.milliseconds private const val PAGE_SIZE = 50 @@ -283,13 +288,23 @@ private fun MessageModel.hasUnresolvableCachedMedia(): Boolean { } } -internal fun DefaultChatComponent.loadMessages(force: Boolean = false) { +internal fun DefaultChatComponent.loadMessages( + force: Boolean = false, + loadSource: String = "manual" +) { val state = _state.value if (state.isLoading) return if (!force && state.messages.size >= PAGE_SIZE && state.currentTopicId == null) return cancelAllLoadingJobs() messageLoadingJob = scope.launch { + val openStartedAt = System.currentTimeMillis() + ChatConversationLog.logViewportState( + event = "load_messages_reset_before", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "source=$loadSource force=$force" + ) _state.update { it.copy( isLoading = true, @@ -299,6 +314,12 @@ internal fun DefaultChatComponent.loadMessages(force: Boolean = false) { viewportPhase = ChatViewportPhase.Initializing ) } + ChatConversationLog.logViewportState( + event = "load_messages_started", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "source=$loadSource force=$force" + ) try { val currentState = _state.value @@ -307,24 +328,31 @@ internal fun DefaultChatComponent.loadMessages(force: Boolean = false) { val isComments = currentState.rootMessage != null val savedViewport = cacheProvider.getChatViewport(chatId, threadId) _state.update { it.copy(lastSavedViewport = savedViewport) } + val sessionSnapshot = ChatOpenPerfBridge.startSession( + chatId = chatId, + threadId = threadId, + source = loadSource, + target = "resolving" + ) + activeLoadSession = ConversationLoadSession( + sessionId = sessionSnapshot.sessionId, + source = loadSource, + target = "resolving" + ) val unreadSeparatorCount = currentState.unreadSeparatorCount val unreadSeparatorLastReadInboxMessageId = currentState.unreadSeparatorLastReadInboxMessageId - val firstUnreadId = - unreadSeparatorLastReadInboxMessageId.takeIf { unreadSeparatorCount > 0 } - ?.let { lastRead -> - if (unreadSeparatorCount > 0) { - repositoryMessage.getMessagesNewer(targetChatId, lastRead, 1, threadId) - .firstOrNull()?.id + val firstUnreadId = unreadSeparatorLastReadInboxMessageId + .takeIf { unreadSeparatorCount > 0 } + ?.let { lastRead -> + repositoryMessage.getMessagesNewer(targetChatId, lastRead, 1, threadId) + .firstOrNull() + ?.id ?: lastRead.takeIf { it > 0L } - } else { - null } - } - when ( - val target = resolveInitialChatScrollTarget( + val target = resolveInitialChatScrollTarget( explicitMessageId = currentState.scrollToMessageId, savedViewport = savedViewport, firstUnreadMessageId = firstUnreadId, @@ -332,7 +360,27 @@ internal fun DefaultChatComponent.loadMessages(force: Boolean = false) { backfillUnreadThreshold = PAGE_SIZE, isComments = isComments ) - ) { + ChatConversationLog.logViewportState( + event = "load_messages_target_resolved", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "source=$loadSource target=${target.perfTargetName()} targetChatId=$targetChatId savedAnchor=${savedViewport?.anchorMessageId ?: 0L} firstUnreadId=${firstUnreadId ?: 0L}" + ) + ChatOpenPerfBridge.updateTarget(chatId, threadId, target.perfTargetName()) + activeLoadSession = ConversationLoadSession( + sessionId = sessionSnapshot.sessionId, + source = loadSource, + target = target.perfTargetName() + ) + ChatConversationLog.logPerf( + component = this@loadMessages, + phase = "chat_open_total_start", + source = loadSource, + target = target.perfTargetName(), + anchorId = savedViewport?.anchorMessageId + ) + + when (target) { is InitialChatScrollTarget.AroundMessage -> { loadAroundMessage( chatId = targetChatId, @@ -371,9 +419,26 @@ internal fun DefaultChatComponent.loadMessages(force: Boolean = false) { } catch (e: CancellationException) { throw e } catch (e: Exception) { + ChatConversationLog.logViewportState( + event = "load_messages_failed", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "source=$loadSource error=${e.javaClass.simpleName}" + ) Log.e("DefaultChatComponent", "Failed to load messages", e) } finally { + ChatConversationLog.logPerf( + component = this@loadMessages, + phase = "chat_open_total_end", + durationMs = System.currentTimeMillis() - openStartedAt + ) _state.update { it.copy(isLoading = false) } + ChatConversationLog.logViewportState( + event = "load_messages_finished", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "source=$loadSource" + ) } } } @@ -411,6 +476,12 @@ internal suspend fun DefaultChatComponent.loadComments( viewportPhase = ChatViewportPhase.Restoring ) } + ChatConversationLog.logViewportState( + event = "load_comments_restore", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "messages=${messages.size} reachedOldest=$reachedOldest command=${scrollCommand.javaClass.simpleName}" + ) } } @@ -423,7 +494,7 @@ private suspend fun DefaultChatComponent.loadBottomMessages( lastLoadedNewerId = 0L var hasCachedPreview = false - val cachedMessages = repositoryMessage.getCachedMessages(targetChatId, PAGE_SIZE) + val cachedMessages = repositoryMessage.getCachedMessages(targetChatId, PAGE_SIZE, threadId) if (cachedMessages.isNotEmpty()) { hasCachedPreview = true _state.update { @@ -437,6 +508,12 @@ private suspend fun DefaultChatComponent.loadBottomMessages( } updateMessages(cachedMessages, replace = true) refreshCachedSenderProfiles(cachedMessages) + ChatConversationLog.logViewportState( + event = "load_bottom_cached_preview", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "targetChatId=$targetChatId cachedMessages=${cachedMessages.size}" + ) } val olderPage = repositoryMessage.getMessagesOlder(targetChatId, 0, PAGE_SIZE, threadId) @@ -466,6 +543,12 @@ private suspend fun DefaultChatComponent.loadBottomMessages( val shouldReplaceCachedPreview = !hasCachedPreview || messages.isNotEmpty() updateMessages(messages, replace = shouldReplaceCachedPreview) refreshCachedSenderProfiles(messages) + ChatConversationLog.logViewportState( + event = "load_bottom_remote_applied", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "targetChatId=$targetChatId remoteMessages=${messages.size} replace=${shouldReplaceCachedPreview} reachedOldest=${olderPage.reachedOldest}" + ) } else { _state.update { it.copy( @@ -476,6 +559,12 @@ private suspend fun DefaultChatComponent.loadBottomMessages( highlightRequest = null ) } + ChatConversationLog.logViewportState( + event = "load_bottom_remote_same_as_cache", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "targetChatId=$targetChatId remoteMessages=${messages.size}" + ) } if (scrollCommand != null) { _state.update { @@ -484,10 +573,12 @@ private suspend fun DefaultChatComponent.loadBottomMessages( viewportPhase = ChatViewportPhase.Restoring ) } - } - if (!isRemoteSameAsCachedPreview && !isOldestLoaded) { - delay(100) - loadMoreMessages() + ChatConversationLog.logViewportState( + event = "load_bottom_restore", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "command=${scrollCommand.javaClass.simpleName}" + ) } } @@ -507,7 +598,8 @@ private suspend fun DefaultChatComponent.loadAroundMessage( lastLoadedOlderId = 0L lastLoadedNewerId = 0L var hasTargetPreview = false - val cachedMessages = repositoryMessage.getCachedMessagesAround(chatId, messageId, PAGE_SIZE) + val cachedMessages = + repositoryMessage.getCachedMessagesAround(chatId, messageId, PAGE_SIZE, threadId) if (cachedMessages.any { it.id == messageId }) { hasTargetPreview = true _state.update { @@ -521,6 +613,12 @@ private suspend fun DefaultChatComponent.loadAroundMessage( } updateMessages(cachedMessages, replace = true) refreshCachedSenderProfiles(cachedMessages) + ChatConversationLog.logViewportState( + event = "load_around_cached_preview", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "targetMessageId=$messageId cachedMessages=${cachedMessages.size}" + ) if (scrollCommand != null) { _state.update { it.copy( @@ -528,6 +626,12 @@ private suspend fun DefaultChatComponent.loadAroundMessage( viewportPhase = ChatViewportPhase.Restoring ) } + ChatConversationLog.logViewportState( + event = "load_around_cached_restore", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "command=${scrollCommand.javaClass.simpleName} targetMessageId=$messageId" + ) } } @@ -544,6 +648,12 @@ private suspend fun DefaultChatComponent.loadAroundMessage( } updateMessages(messages, replace = !hasTargetPreview) refreshCachedSenderProfiles(messages) + ChatConversationLog.logViewportState( + event = "load_around_remote_applied", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "targetMessageId=$messageId remoteMessages=${messages.size} replace=${!hasTargetPreview}" + ) if (!hasTargetPreview && scrollCommand != null) { _state.update { it.copy( @@ -551,23 +661,39 @@ private suspend fun DefaultChatComponent.loadAroundMessage( viewportPhase = ChatViewportPhase.Restoring ) } + ChatConversationLog.logViewportState( + event = "load_around_remote_restore", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "command=${scrollCommand.javaClass.simpleName} targetMessageId=$messageId" + ) } - delay(100) - loadMoreMessages() if (backfillNewerAfterInitialLoad) { - backfillNewerMessages(maxPages = 3) - } else { - loadNewerMessages() + ChatConversationLog.logViewportState( + event = "load_around_schedule_backfill", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "targetMessageId=$messageId maxPages=3" + ) + scheduleUnreadBackfill(maxPages = 3) } } else if (hasTargetPreview) { - delay(100) - loadMoreMessages() if (backfillNewerAfterInitialLoad) { - backfillNewerMessages(maxPages = 3) - } else { - loadNewerMessages() + ChatConversationLog.logViewportState( + event = "load_around_schedule_backfill_from_cache_only", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "targetMessageId=$messageId maxPages=3" + ) + scheduleUnreadBackfill(maxPages = 3) } } else { + ChatConversationLog.logViewportState( + event = "load_around_fallback", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "targetMessageId=$messageId hasTargetPreview=$hasTargetPreview rootMessage=${_state.value.rootMessage?.id ?: 0L}" + ) if (_state.value.rootMessage != null && threadId != null) { loadComments( targetChatId = chatId, @@ -592,6 +718,41 @@ private suspend fun DefaultChatComponent.backfillNewerMessages(maxPages: Int) { } } +internal fun DefaultChatComponent.scheduleUnreadBackfill(maxPages: Int) { + unreadBackfillJob?.cancel() + ChatConversationLog.logViewportState( + event = "schedule_unread_backfill", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "maxPages=$maxPages" + ) + unreadBackfillJob = scope.launch { + while (true) { + val state = _state.value + if (state.viewportPhase == ChatViewportPhase.Settled) break + if (!isActive || state.isAtBottom || state.isLoading || state.isLoadingOlder || state.isLoadingNewer) { + ChatConversationLog.logViewportState( + event = "schedule_unread_backfill_abort", + state = state, + componentInstanceId = componentInstanceId, + extra = "maxPages=$maxPages" + ) + return@launch + } + delay(50.milliseconds) + } + ChatConversationLog.logViewportState( + event = "schedule_unread_backfill_run", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "maxPages=$maxPages unreadSeparatorCount=${_state.value.unreadSeparatorCount}" + ) + if (_state.value.unreadSeparatorCount > PAGE_SIZE) { + backfillNewerMessages(maxPages = maxPages) + } + } +} + internal fun DefaultChatComponent.requestMessageHighlight(messageId: Long) { _state.update { currentState -> val nextToken = currentState.highlightRequestToken + 1L @@ -802,6 +963,12 @@ private suspend fun DefaultChatComponent.loadNewerMessagesPage(): Boolean { internal fun DefaultChatComponent.scrollToMessageInternal(messageId: Long) { val currentState = _state.value + ChatConversationLog.logViewportState( + event = "scroll_to_message_requested", + state = currentState, + componentInstanceId = componentInstanceId, + extra = "messageId=$messageId alreadyLoaded=${currentState.messages.any { it.id == messageId }}" + ) if (currentState.messages.any { it.id == messageId }) { queueJumpToLoadedMessage( messageId = messageId, @@ -814,6 +981,12 @@ internal fun DefaultChatComponent.scrollToMessageInternal(messageId: Long) { cancelAllLoadingJobs() messageLoadingJob = scope.launch { + ChatConversationLog.logViewportState( + event = "scroll_to_message_reset_before", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "messageId=$messageId" + ) _state.update { it.copy( isLoading = true, @@ -824,6 +997,12 @@ internal fun DefaultChatComponent.scrollToMessageInternal(messageId: Long) { highlightRequest = null ) } + ChatConversationLog.logViewportState( + event = "scroll_to_message_reload_start", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "messageId=$messageId" + ) try { loadAroundMessage( messageId = messageId, @@ -840,9 +1019,21 @@ internal fun DefaultChatComponent.scrollToMessageInternal(messageId: Long) { } catch (e: CancellationException) { throw e } catch (e: Exception) { + ChatConversationLog.logViewportState( + event = "scroll_to_message_reload_failed", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "messageId=$messageId error=${e.javaClass.simpleName}" + ) Log.e("DefaultChatComponent", "Failed to scroll to message", e) } finally { _state.update { it.copy(isLoading = false) } + ChatConversationLog.logViewportState( + event = "scroll_to_message_reload_finish", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "messageId=$messageId" + ) } } } @@ -852,14 +1043,30 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { val threadId = currentState.effectiveThreadId() val isComments = currentState.rootMessage != null val targetChatId = currentState.effectiveThreadChatId(chatId) + ChatConversationLog.logViewportState( + event = "scroll_to_bottom_requested", + state = currentState, + componentInstanceId = componentInstanceId, + extra = "isComments=$isComments" + ) if (currentState.messages.isNotEmpty() && currentState.isLatestLoaded) { _state.update { it.enqueueRuntimeScrollToBottom(animated = true) } + ChatConversationLog.logViewportState( + event = "scroll_to_bottom_runtime_enqueued", + state = _state.value, + componentInstanceId = componentInstanceId + ) return } if (currentState.messages.isNotEmpty() && !currentState.isLatestLoaded) { if (loadNewerJob?.isActive == true || currentState.isLoadingNewer) return loadNewerJob = scope.launch { _state.update { it.copy(isLoadingNewer = true) } + ChatConversationLog.logViewportState( + event = "scroll_to_bottom_load_newer_start", + state = _state.value, + componentInstanceId = componentInstanceId + ) try { var reachedLatest = false repeat(20) { @@ -871,8 +1078,19 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { } if (reachedLatest || _state.value.isLatestLoaded) { _state.update { it.enqueueRuntimeScrollToBottom(animated = true) } + ChatConversationLog.logViewportState( + event = "scroll_to_bottom_load_newer_enqueued", + state = _state.value, + componentInstanceId = componentInstanceId + ) } } catch (e: Exception) { + ChatConversationLog.logViewportState( + event = "scroll_to_bottom_load_newer_failed", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "error=${e.javaClass.simpleName}" + ) Log.e( "DefaultChatComponent", "Failed to load newer messages before scroll to bottom", @@ -882,6 +1100,11 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { } finally { inFlightNewerAnchorId = 0L _state.update { it.copy(isLoadingNewer = false) } + ChatConversationLog.logViewportState( + event = "scroll_to_bottom_load_newer_finish", + state = _state.value, + componentInstanceId = componentInstanceId + ) } } return @@ -889,6 +1112,12 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { if (currentState.isLoading) return cancelAllLoadingJobs() messageLoadingJob = scope.launch { + ChatConversationLog.logViewportState( + event = "scroll_to_bottom_reset_before", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "isComments=$isComments" + ) _state.update { it.copy( isLoading = true, @@ -898,6 +1127,12 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { viewportPhase = ChatViewportPhase.Initializing ) } + ChatConversationLog.logViewportState( + event = "scroll_to_bottom_reload_start", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "isComments=$isComments" + ) try { if (isComments && threadId != null) { loadComments( @@ -915,9 +1150,20 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { } catch (e: CancellationException) { throw e } catch (e: Exception) { + ChatConversationLog.logViewportState( + event = "scroll_to_bottom_reload_failed", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "error=${e.javaClass.simpleName}" + ) Log.e("DefaultChatComponent", "Failed to scroll to bottom", e) } finally { _state.update { it.copy(isLoading = false) } + ChatConversationLog.logViewportState( + event = "scroll_to_bottom_reload_finish", + state = _state.value, + componentInstanceId = componentInstanceId + ) } } } @@ -1420,6 +1666,7 @@ internal fun DefaultChatComponent.setupMessageCollectors() { else -> mediaSafeMessage } } + handleEditedRichMessage(message) } } .launchIn(scope) @@ -1586,6 +1833,10 @@ private inline fun DefaultChatComponent.updateMessageContent( } } +internal fun DefaultChatComponent.handleEditedRichMessage(message: MessageModel) { + richMessageCoordinator.onMessageEdited(message) +} + internal fun DefaultChatComponent.loadDraft() { scope.launch { _state.value.currentTopicId @@ -1601,7 +1852,19 @@ internal fun DefaultChatComponent.loadDraft() { internal fun DefaultChatComponent.handleTopicClick(topicId: Int) { val id = if (topicId == 0) null else topicId.toLong() + ChatConversationLog.logViewportState( + event = "topic_click", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "topicId=$topicId resolvedTopicId=${id ?: 0L}" + ) resetSearchState(isSearchActive = false) + ChatConversationLog.logViewportState( + event = "topic_click_reset_before", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "topicId=$topicId resolvedTopicId=${id ?: 0L}" + ) _state.update { it.copy( currentTopicId = id, @@ -1616,6 +1879,12 @@ internal fun DefaultChatComponent.handleTopicClick(topicId: Int) { viewportPhase = ChatViewportPhase.Initializing ) } + ChatConversationLog.logViewportState( + event = "topic_click_state_reset", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "topicId=$topicId" + ) if (topicId != 0) { scope.launch { forumTopicsRepository.markForumTopicAsRead(chatId, topicId) @@ -1626,9 +1895,21 @@ internal fun DefaultChatComponent.handleTopicClick(topicId: Int) { internal fun DefaultChatComponent.handleCommentsClick(messageId: Long) { scope.launch { + ChatConversationLog.logViewportState( + event = "comments_click", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "messageId=$messageId" + ) val message = _state.value.messages.find { it.id == messageId } val threadContext = repositoryMessage.getMessageThreadContext(chatId, messageId) resetSearchState(isSearchActive = false) + ChatConversationLog.logViewportState( + event = "comments_click_reset_before", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "messageId=$messageId threadChatId=${threadContext?.chatId ?: 0L} threadId=${threadContext?.threadId ?: messageId}" + ) _state.update { it.copy( currentTopicId = messageId, @@ -1643,6 +1924,12 @@ internal fun DefaultChatComponent.handleCommentsClick(messageId: Long) { viewportPhase = ChatViewportPhase.Initializing ) } + ChatConversationLog.logViewportState( + event = "comments_click_state_reset", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "messageId=$messageId threadChatId=${threadContext?.chatId ?: 0L} threadId=${threadContext?.threadId ?: messageId}" + ) loadComments( targetChatId = threadContext?.chatId ?: chatId, threadId = threadContext?.threadId ?: messageId, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/pinned/PinnedMessages.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/pinned/PinnedMessages.kt index 534251d7..051e4706 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/pinned/PinnedMessages.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/pinned/PinnedMessages.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.monogram.domain.models.MessageModel +import org.monogram.presentation.features.chats.conversation.ChatComponent import org.monogram.presentation.features.chats.conversation.DefaultChatComponent @@ -14,6 +15,7 @@ internal fun DefaultChatComponent.loadPinnedMessage() { val threadId = activeThreadId() val targetChatId = activeThreadChatId() try { + val previousState = _state.value val pinned = repositoryMessage.getPinnedMessage(targetChatId, threadId) val count = repositoryMessage.getPinnedMessageCount(targetChatId, threadId) Log.d( @@ -21,10 +23,9 @@ internal fun DefaultChatComponent.loadPinnedMessage() { "loadPinnedMessage: chatId=$chatId, targetChatId=$targetChatId, threadId=$threadId, pinnedId=${pinned?.id}, count=$count" ) _state.update { - it.copy( + previousState.applyPinnedMessageRefresh( pinnedMessage = pinned, - pinnedMessageCount = count, - pinnedMessageIndex = 0 + pinnedMessageCount = count ) } } catch (e: Exception) { @@ -33,6 +34,20 @@ internal fun DefaultChatComponent.loadPinnedMessage() { } } +internal fun DefaultChatComponent.handleHidePinnedMessage() { + scope.launch { + pinnedMessageVisibilityRepository.hide(chatId) + _state.update { it.copy(isPinnedMessageHidden = true) } + } +} + +internal fun DefaultChatComponent.handleShowPinnedMessage() { + scope.launch { + pinnedMessageVisibilityRepository.show(chatId) + _state.update { it.copy(isPinnedMessageHidden = false) } + } +} + internal fun DefaultChatComponent.loadAllPinnedMessages() { scope.launch { val threadId = activeThreadId() @@ -128,3 +143,21 @@ internal fun DefaultChatComponent.handlePinnedMessageClick(message: MessageModel private fun DefaultChatComponent.jumpToMessage(message: MessageModel) { scrollToMessage(message.id) } + +internal fun ChatComponent.State.hidePinnedMessage(): ChatComponent.State = + copy(isPinnedMessageHidden = true) + +internal fun ChatComponent.State.showPinnedMessage(): ChatComponent.State = + copy(isPinnedMessageHidden = false) + +internal fun ChatComponent.State.applyPinnedMessageRefresh( + pinnedMessage: MessageModel?, + pinnedMessageCount: Int +): ChatComponent.State { + return copy( + pinnedMessage = pinnedMessage, + pinnedMessageCount = pinnedMessageCount, + pinnedMessageIndex = 0, + isPinnedMessageHidden = isPinnedMessageHidden + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/preferences/Preferences.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/preferences/Preferences.kt index 881c7908..37d8441c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/preferences/Preferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/preferences/Preferences.kt @@ -2,6 +2,8 @@ package org.monogram.presentation.features.chats.conversation.logic import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -144,6 +146,10 @@ internal fun DefaultChatComponent.observePreferences(availableWallpapers: List + _state.update { it.copy(showSponsoredMessagesForPremium = value) } + }.launchIn(scope) + combine(appPreferences.isChatAnimationsEnabled, appPreferences.isPowerSavingMode) { enabled, powerSaving -> if (powerSaving) false else enabled }.onEach { value -> @@ -152,9 +158,12 @@ internal fun DefaultChatComponent.observePreferences(availableWallpapers: List enabled to keywords - }.onEach { - if (_state.value.isChannel) { - loadMessages() + } + .distinctUntilChanged() + .drop(1) + .onEach { + if (_state.value.isChannel && _state.value.messages.isNotEmpty()) { + loadMessages(force = true, loadSource = "preferences_reload") } }.launchIn(scope) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AlbumMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AlbumMessageBubbleContainer.kt index 5a9156ae..37281850 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AlbumMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AlbumMessageBubbleContainer.kt @@ -53,6 +53,7 @@ internal fun AlbumMessageBubbleContainer( onPhotoClick: (MessageModel) -> Unit, onDownloadPhoto: (Int) -> Unit = {}, onVideoClick: (MessageModel) -> Unit = {}, + onDownloadVideo: (Int) -> Unit = {}, onDocumentClick: (MessageModel) -> Unit = {}, onAudioClick: (MessageModel) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, @@ -230,6 +231,7 @@ internal fun AlbumMessageBubbleContainer( onPhotoClick = onPhotoClick, onDownloadPhoto = onDownloadPhoto, onVideoClick = onVideoClick, + onDownloadVideo = onDownloadVideo, onDocumentClick = onDocumentClick, onAudioClick = onAudioClick, onCancelDownload = onCancelDownload, @@ -283,6 +285,7 @@ internal fun AlbumMessageBubbleContainer( onPhotoClick = onPhotoClick, onDownloadPhoto = onDownloadPhoto, onVideoClick = onVideoClick, + onDownloadVideo = onDownloadVideo, onDocumentClick = onDocumentClick, onAudioClick = onAudioClick, onCancelDownload = onCancelDownload, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatInputBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatInputBar.kt index 6d1f486b..706897d4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatInputBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatInputBar.kt @@ -71,8 +71,8 @@ import org.monogram.presentation.features.chats.conversation.ui.inputbar.SlowMod import org.monogram.presentation.features.chats.conversation.ui.inputbar.applyMentionSuggestion import org.monogram.presentation.features.chats.conversation.ui.inputbar.buildEditingMessageTextValue import org.monogram.presentation.features.chats.conversation.ui.inputbar.buildScheduledDateEpochSeconds +import org.monogram.presentation.features.chats.conversation.ui.inputbar.copyUriToPendingAttachment import org.monogram.presentation.features.chats.conversation.ui.inputbar.copyUriToTempDocumentPath -import org.monogram.presentation.features.chats.conversation.ui.inputbar.copyUriToTempPath import org.monogram.presentation.features.chats.conversation.ui.inputbar.declaredPermissions import org.monogram.presentation.features.chats.conversation.ui.inputbar.extractEntities import org.monogram.presentation.features.chats.conversation.ui.inputbar.hasAllPermissions @@ -82,6 +82,8 @@ import org.monogram.presentation.features.chats.conversation.ui.inputbar.remembe import org.monogram.presentation.features.chats.conversation.ui.message.getEmojiFontFamily import org.monogram.presentation.features.gallery.GalleryScreen import org.monogram.presentation.features.gallery.components.PollComposerSheet +import org.monogram.presentation.features.share.PendingAttachment +import org.monogram.presentation.features.share.PendingAttachmentKind import java.util.Calendar import kotlin.math.ceil @@ -90,6 +92,20 @@ private enum class AttachmentPickerMode { MediaOnly } +private fun List.mergeAttachments(newAttachments: List): List { + if (newAttachments.isEmpty()) return this + val merged = toMutableList() + newAttachments.forEach { incoming -> + val existingIndex = merged.indexOfFirst { it.localPath == incoming.localPath } + if (existingIndex >= 0) { + merged[existingIndex] = incoming + } else { + merged += incoming + } + } + return merged +} + @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ChatInputBar( @@ -331,6 +347,7 @@ internal fun ChatInputBar( microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } ) + val currentPendingAttachments = state.pendingAttachments val maxMessageLength by remember( state.pendingMediaPaths, state.pendingDocumentPaths, @@ -348,14 +365,17 @@ internal fun ChatInputBar( derivedStateOf { currentMessageLength > maxMessageLength } } val canSendPendingAttachments by remember( - state.pendingMediaPaths, - state.pendingDocumentPaths, + currentPendingAttachments, canUseMediaPicker, canUseDocumentPicker ) { derivedStateOf { - (state.pendingMediaPaths.isNotEmpty() && canUseMediaPicker) || - (state.pendingDocumentPaths.isNotEmpty() && canUseDocumentPicker) + currentPendingAttachments.any { + when (it.kind) { + PendingAttachmentKind.DOCUMENT -> canUseDocumentPicker + else -> canUseMediaPicker + } + } } } @@ -371,8 +391,7 @@ internal fun ChatInputBar( ) val canSendNow = when { - state.pendingMediaPaths.isNotEmpty() && canUseMediaPicker -> true - state.pendingDocumentPaths.isNotEmpty() && canUseDocumentPicker -> true + currentPendingAttachments.isNotEmpty() && canSendPendingAttachments -> true state.editingMessage != null -> false canWriteText && !isTextEmpty -> true else -> false @@ -382,19 +401,9 @@ internal fun ChatInputBar( return@sendWithOptions } - if (state.pendingMediaPaths.isNotEmpty() && canUseMediaPicker) { - actions.onSendMedia( - state.pendingMediaPaths, - textValue.text, - captionEntities, - effectiveSendOptions - ) - textValue = TextFieldValue("") - knownCustomEmojis.clear() - sentInstantMessage = !isScheduling - } else if (state.pendingDocumentPaths.isNotEmpty() && canUseDocumentPicker) { - actions.onSendDocuments( - state.pendingDocumentPaths, + if (currentPendingAttachments.isNotEmpty() && canSendPendingAttachments) { + actions.onSendAttachments( + currentPendingAttachments, textValue.text, captionEntities, effectiveSendOptions @@ -564,7 +573,9 @@ internal fun ChatInputBar( } else if (state.pendingMediaPaths.isNotEmpty()) { actions.onCancelMedia() } else if (state.pendingDocumentPaths.isNotEmpty()) { - actions.onDocumentOrderChange(emptyList()) + actions.onPendingAttachmentsChange( + currentPendingAttachments.filterNot { it.kind == PendingAttachmentKind.DOCUMENT } + ) } else if (showGallery) { showGallery = false } else if (showPollComposer) { @@ -646,10 +657,21 @@ internal fun ChatInputBar( val documentsPickerLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenMultipleDocuments() ) { uris -> - val localPaths = uris.mapNotNull { uri -> context.copyUriToTempDocumentPath(uri) } - if (localPaths.isNotEmpty()) { - actions.onDocumentOrderChange((state.pendingDocumentPaths + localPaths).distinct()) - actions.onMediaOrderChange(emptyList()) + val attachments = uris.mapNotNull { uri -> + context.copyUriToTempDocumentPath(uri)?.let { path -> + PendingAttachment( + localPath = path, + kind = PendingAttachmentKind.DOCUMENT, + deleteAfterUse = true + ) + } + } + if (attachments.isNotEmpty()) { + actions.onPendingAttachmentsChange( + currentPendingAttachments.mergeAttachments( + attachments + ) + ) } attachmentPickerMode = AttachmentPickerMode.Default } @@ -681,10 +703,11 @@ internal fun ChatInputBar( if (showCamera) { CameraScreen( onImageCaptured = { uri -> - val path = context.copyUriToTempPath(uri) - if (path != null) { - actions.onMediaOrderChange((state.pendingMediaPaths + path).distinct()) - actions.onDocumentOrderChange(emptyList()) + val attachment = context.copyUriToPendingAttachment(uri) + if (attachment != null) { + actions.onPendingAttachmentsChange( + currentPendingAttachments.mergeAttachments(listOf(attachment)) + ) } showCamera = false attachmentPickerMode = AttachmentPickerMode.Default @@ -717,6 +740,7 @@ internal fun ChatInputBar( draftLinkPreviewError = state.draftLinkPreviewError, isDraftLinkPreviewDisabledForSend = state.isDraftLinkPreviewDisabledForSend, attachments = ComposerAttachmentState( + pendingAttachments = state.pendingAttachments, pendingMediaPaths = state.pendingMediaPaths, pendingDocumentPaths = state.pendingDocumentPaths, scheduledMessagesCount = state.scheduledMessages.size @@ -767,7 +791,11 @@ internal fun ChatInputBar( onDismissDraftLinkPreview = actions.onDismissDraftLinkPreview, onRestoreDraftLinkPreview = actions.onRestoreDraftLinkPreview, onCancelMedia = actions.onCancelMedia, - onCancelDocuments = { actions.onDocumentOrderChange(emptyList()) }, + onCancelDocuments = { + actions.onPendingAttachmentsChange( + currentPendingAttachments.filterNot { it.kind == PendingAttachmentKind.DOCUMENT } + ) + }, onAddMedia = { if (!canUseMediaPicker) return@ChatInputBarComposerSection openStickerMenuAfterKeyboardClosed = false @@ -782,18 +810,18 @@ internal fun ChatInputBar( if (!canUseDocumentPicker) return@ChatInputBarComposerSection documentsPickerLauncher.launch(arrayOf("*/*")) }, - onMediaOrderChange = actions.onMediaOrderChange, - onDocumentOrderChange = actions.onDocumentOrderChange, + onPendingAttachmentsChange = actions.onPendingAttachmentsChange, onMediaClick = actions.onMediaClick, onDraftLinkPreviewAction = actions.onDraftLinkPreviewAction, onPasteImages = { uris -> if (!canUseMediaPicker || state.editingMessage != null) return@ChatInputBarComposerSection - val localPaths = uris.mapNotNull { uri -> - context.copyUriToTempPath(uri) - } - if (localPaths.isNotEmpty()) { - actions.onMediaOrderChange((state.pendingMediaPaths + localPaths).distinct()) - actions.onDocumentOrderChange(emptyList()) + val attachments = uris.mapNotNull(context::copyUriToPendingAttachment) + if (attachments.isNotEmpty()) { + actions.onPendingAttachmentsChange( + currentPendingAttachments.mergeAttachments( + attachments + ) + ) } }, onMentionClick = { user -> @@ -1052,12 +1080,13 @@ internal fun ChatInputBar( ) { GalleryScreen( onMediaSelected = { uris -> - val localPaths = uris.mapNotNull { uri -> - context.copyUriToTempPath(uri) - } - if (localPaths.isNotEmpty()) { - actions.onMediaOrderChange((state.pendingMediaPaths + localPaths).distinct()) - actions.onDocumentOrderChange(emptyList()) + val attachments = uris.mapNotNull(context::copyUriToPendingAttachment) + if (attachments.isNotEmpty()) { + actions.onPendingAttachmentsChange( + currentPendingAttachments.mergeAttachments( + attachments + ) + ) } showGallery = false attachmentPickerMode = AttachmentPickerMode.Default diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatTopBar.kt index feceeefe..92bb05a4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ChatTopBar.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.ExitToApp import androidx.compose.material.icons.automirrored.rounded.PlaylistAddCheck import androidx.compose.material.icons.automirrored.rounded.VolumeOff import androidx.compose.material.icons.automirrored.rounded.VolumeUp @@ -31,6 +32,7 @@ import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.Groups import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.PushPin import androidx.compose.material.icons.rounded.Report import androidx.compose.material.icons.rounded.Verified import androidx.compose.material3.ExperimentalMaterial3Api @@ -92,10 +94,12 @@ fun ChatTopBar( onSearchToggle: () -> Unit = {}, onSearchQueryChange: (String) -> Unit = {}, onClearHistory: (() -> Unit)? = null, + onLeaveChat: (() -> Unit)? = null, onDeleteChat: (() -> Unit)? = null, onReport: (() -> Unit)? = null, onCopyLink: (() -> Unit)? = null, onManageMembers: (() -> Unit)? = null, + onShowPinnedMessage: (() -> Unit)? = null, showBack: Boolean = true, personalAvatarPath: String? = null, isTablet: Boolean = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint( @@ -104,6 +108,7 @@ fun ChatTopBar( ) { var showMenu by rememberSaveable { mutableStateOf(false) } var showClearHistorySheet by rememberSaveable { mutableStateOf(false) } + var showLeaveChatSheet by rememberSaveable { mutableStateOf(false) } var showDeleteChatSheet by rememberSaveable { mutableStateOf(false) } val windowInsets = WindowInsets.statusBars @@ -382,6 +387,16 @@ fun ChatTopBar( } ) } + if (onShowPinnedMessage != null) { + MenuOptionRow( + icon = Icons.Rounded.PushPin, + title = stringResource(R.string.menu_show_pin), + onClick = { + showMenu = false + onShowPinnedMessage() + } + ) + } if (onClearHistory != null) { MenuOptionRow( icon = Icons.Rounded.CleaningServices, @@ -392,6 +407,17 @@ fun ChatTopBar( } ) } + if (onLeaveChat != null) { + MenuOptionRow( + icon = Icons.AutoMirrored.Rounded.ExitToApp, + title = stringResource(R.string.menu_leave), + destructive = true, + onClick = { + showMenu = false + showLeaveChatSheet = true + } + ) + } if (onDeleteChat != null) { MenuOptionRow( icon = Icons.Rounded.Delete, @@ -429,6 +455,20 @@ fun ChatTopBar( ) } + if (showLeaveChatSheet && onLeaveChat != null) { + ConfirmationSheet( + icon = Icons.AutoMirrored.Rounded.ExitToApp, + title = stringResource(R.string.leave_chat_title), + description = stringResource(R.string.leave_chat_confirmation), + confirmText = stringResource(R.string.action_leave), + onConfirm = { + onLeaveChat() + showLeaveChatSheet = false + }, + onDismiss = { showLeaveChatSheet = false } + ) + } + if (showDeleteChatSheet && onDeleteChat != null) { ConfirmationSheet( icon = Icons.Rounded.Delete, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt index a3be8f79..781e23b3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt @@ -78,6 +78,7 @@ fun CompactMediaMosaic( onPhotoClick: (MessageModel) -> Unit, onDownloadPhoto: (Int) -> Unit = {}, onVideoClick: (MessageModel) -> Unit, + onDownloadVideo: (Int) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, onLongClick: (MessageModel, Offset) -> Unit, showTimestampOverlay: Boolean, @@ -131,6 +132,7 @@ fun CompactMediaMosaic( video = content, autoplayVideos = autoplayVideos, onVideoClick = onVideoClick, + onDownloadVideo = onDownloadVideo, onCancelDownload = onCancelDownload, onLongClick = onLongClick, contentScale = contentScale, @@ -447,6 +449,7 @@ fun VideoItem( video: MessageContent.Video, autoplayVideos: Boolean, onVideoClick: (MessageModel) -> Unit, + onDownloadVideo: (Int) -> Unit, onCancelDownload: (Int) -> Unit, onLongClick: (MessageModel, Offset) -> Unit, autoDownloadMobile: Boolean, @@ -486,7 +489,7 @@ fun VideoItem( downloadUtils.isRoaming() -> autoDownloadRoaming else -> autoDownloadMobile } - if (shouldDownload) onVideoClick(msg) + if (shouldDownload) onDownloadVideo(video.fileId) } } @@ -631,7 +634,9 @@ fun VideoItem( } else { isAutoDownloadSuppressed = false AutoDownloadSuppression.clear(video.fileId) - onVideoClick(msg) + if (video.supportsStreaming) onVideoClick(msg) else onDownloadVideo( + video.fileId + ) } }, onLongPress = { offset -> onLongClick(msg, itemPosition + offset) } @@ -661,7 +666,9 @@ fun VideoItem( onIdleClick = { isAutoDownloadSuppressed = false AutoDownloadSuppression.clear(video.fileId) - onVideoClick(msg) + if (video.supportsStreaming) onVideoClick(msg) else onDownloadVideo( + video.fileId + ) } ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageBubbleContainer.kt index 22b5621f..f188ba8c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageBubbleContainer.kt @@ -58,6 +58,7 @@ import org.monogram.presentation.features.chats.conversation.ui.message.VenueMes import org.monogram.presentation.features.chats.conversation.ui.message.VideoMessageBubble import org.monogram.presentation.features.chats.conversation.ui.message.VideoNoteBubble import org.monogram.presentation.features.chats.conversation.ui.message.VoiceMessageBubble +import org.monogram.presentation.features.chats.conversation.ui.message.prefersExpandedBubbleWidth @Composable internal fun MessageBubbleContainer( @@ -70,6 +71,7 @@ internal fun MessageBubbleContainer( onPhotoClick: (MessageModel) -> Unit, onDownloadPhoto: (Int) -> Unit = {}, onVideoClick: (MessageModel) -> Unit = {}, + onDownloadVideo: (Int) -> Unit = {}, onDocumentClick: (MessageModel) -> Unit = {}, onAudioClick: (MessageModel) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, @@ -78,6 +80,7 @@ internal fun MessageBubbleContainer( onGoToReply: (MessageModel) -> Unit = {}, onReactionClick: (Long, String) -> Unit = { _, _ -> }, onStickerClick: (Long) -> Unit = {}, + onDownloadSticker: (Int) -> Unit = {}, onPollOptionClick: (Long, Int) -> Unit = { _, _ -> }, onRetractVote: (Long) -> Unit = {}, onShowVoters: (Long, Int) -> Unit = { _, _ -> }, @@ -157,6 +160,9 @@ internal fun MessageBubbleContainer( ) } } + val contentPrefersExpandedWidth = remember(msg.content) { + msg.content.prefersExpandedBubbleWidth() + } MessageBubbleGestureLayer( modifier = Modifier.padding(top = topSpacing), @@ -218,7 +224,13 @@ internal fun MessageBubbleContainer( ) { MessageBubbleContentHost( modifier = Modifier - .width(IntrinsicSize.Max) + .then( + if (contentPrefersExpandedWidth) { + Modifier.fillMaxWidth() + } else { + Modifier.width(IntrinsicSize.Max) + } + ) .widthIn(max = maxWidth), msg = msg, newerMsg = newerMsg, @@ -229,6 +241,7 @@ internal fun MessageBubbleContainer( onPhotoClick = onPhotoClick, onDownloadPhoto = onDownloadPhoto, onVideoClick = onVideoClick, + onDownloadVideo = onDownloadVideo, onDocumentClick = onDocumentClick, onAudioClick = onAudioClick, onCancelDownload = onCancelDownload, @@ -238,6 +251,7 @@ internal fun MessageBubbleContainer( onGoToReply = onGoToReply, onReactionClick = onReactionClick, onStickerClick = onStickerClick, + onDownloadSticker = onDownloadSticker, onPollOptionClick = onPollOptionClick, onRetractVote = onRetractVote, onShowVoters = onShowVoters, @@ -349,6 +363,7 @@ private fun MessageBubbleContentHost( onPhotoClick: (MessageModel) -> Unit, onDownloadPhoto: (Int) -> Unit, onVideoClick: (MessageModel) -> Unit, + onDownloadVideo: (Int) -> Unit, onDocumentClick: (MessageModel) -> Unit, onAudioClick: (MessageModel) -> Unit, onCancelDownload: (Int) -> Unit, @@ -358,6 +373,7 @@ private fun MessageBubbleContentHost( onGoToReply: (MessageModel) -> Unit, onReactionClick: (Long, String) -> Unit, onStickerClick: (Long) -> Unit, + onDownloadSticker: (Int) -> Unit, onPollOptionClick: (Long, Int) -> Unit, onRetractVote: (Long) -> Unit, onShowVoters: (Long, Int) -> Unit, @@ -387,6 +403,7 @@ private fun MessageBubbleContentHost( onPhotoClick = onPhotoClick, onDownloadPhoto = onDownloadPhoto, onVideoClick = onVideoClick, + onDownloadVideo = onDownloadVideo, onDocumentClick = onDocumentClick, onAudioClick = onAudioClick, onCancelDownload = onCancelDownload, @@ -396,6 +413,7 @@ private fun MessageBubbleContentHost( onGoToReply = onGoToReply, onReactionClick = onReactionClick, onStickerClick = onStickerClick, + onDownloadSticker = onDownloadSticker, onPollOptionClick = onPollOptionClick, onRetractVote = onRetractVote, onShowVoters = onShowVoters, @@ -458,6 +476,7 @@ private fun MessageContentSelector( onPhotoClick: (MessageModel) -> Unit, onDownloadPhoto: (Int) -> Unit, onVideoClick: (MessageModel) -> Unit, + onDownloadVideo: (Int) -> Unit, onDocumentClick: (MessageModel) -> Unit, onAudioClick: (MessageModel) -> Unit, onCancelDownload: (Int) -> Unit, @@ -467,6 +486,7 @@ private fun MessageContentSelector( onGoToReply: (MessageModel) -> Unit, onReactionClick: (Long, String) -> Unit, onStickerClick: (Long) -> Unit, + onDownloadSticker: (Int) -> Unit, onPollOptionClick: (Long, Int) -> Unit, onRetractVote: (Long) -> Unit, onShowVoters: (Long, Int) -> Unit, @@ -483,9 +503,16 @@ private fun MessageContentSelector( val finalMsg = remember(msg, appearance.showReactions) { if (appearance.showReactions) msg else msg.copy(reactions = emptyList()) } + val contentPrefersExpandedWidth = remember(finalMsg.content) { + finalMsg.content.prefersExpandedBubbleWidth() + } Column( - modifier = Modifier.width(IntrinsicSize.Max), + modifier = if (contentPrefersExpandedWidth) { + Modifier.fillMaxWidth() + } else { + Modifier.width(IntrinsicSize.Max) + }, horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start ) { when (val content = finalMsg.content) { @@ -541,6 +568,7 @@ private fun MessageContentSelector( onReplyClick = onGoToReply, onReactionClick = { onReactionClick(msg.id, it) }, onStickerClick = { onStickerClick(it) }, + onDownloadSticker = onDownloadSticker, onLongClick = onBubbleCenterLongClick ) } @@ -585,6 +613,7 @@ private fun MessageContentSelector( autoDownloadRoaming = appearance.autoDownloadRoaming, autoplayVideos = appearance.autoplayVideos, onVideoClick = onVideoClick, + onDownloadVideo = onDownloadVideo, onCancelDownload = onCancelDownload, onLongClick = onBubbleLongClick, onReplyClick = onGoToReply, @@ -655,6 +684,7 @@ private fun MessageContentSelector( onReactionClick = { onReactionClick(msg.id, it) }, toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, + modifier = Modifier.fillMaxWidth(), downloadUtils = downloadUtils, isAnyViewerOpen = isAnyViewerOpen ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt index 6f90f61a..5cd11053 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt @@ -77,6 +77,7 @@ fun ChannelAlbumMessageBubble( bubbleRadius: Float = 16f, onDownloadPhoto: (Int) -> Unit = {}, onVideoClick: (MessageModel) -> Unit, + onDownloadVideo: (Int) -> Unit = {}, onDocumentClick: (MessageModel) -> Unit = {}, onAudioClick: (MessageModel) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, @@ -231,6 +232,7 @@ fun ChannelAlbumMessageBubble( onPhotoClick = onPhotoClick, onDownloadPhoto = onDownloadPhoto, onVideoClick = onVideoClick, + onDownloadVideo = onDownloadVideo, onCancelDownload = onCancelDownload, onLongClick = { tappedMessage, offset -> if (onMessageLongPress != null) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageBubbleContainer.kt index bdf99bd8..e4beee47 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageBubbleContainer.kt @@ -72,6 +72,7 @@ internal fun ChannelMessageBubbleContainer( onPhotoClick: (MessageModel) -> Unit, onDownloadPhoto: (Int) -> Unit = {}, onVideoClick: (MessageModel) -> Unit = {}, + onDownloadVideo: (Int) -> Unit = {}, onDocumentClick: (MessageModel) -> Unit = {}, onAudioClick: (MessageModel) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, @@ -81,6 +82,7 @@ internal fun ChannelMessageBubbleContainer( onReactionClick: (Long, String) -> Unit = { _, _ -> }, onReplyMarkupButtonClick: (Long, InlineKeyboardButtonModel) -> Unit = { _, _ -> }, onStickerClick: (Long) -> Unit = {}, + onDownloadSticker: (Int) -> Unit = {}, onPollOptionClick: (Long, Int) -> Unit = { _, _ -> }, onRetractVote: (Long) -> Unit = {}, onShowVoters: (Long, Int) -> Unit = { _, _ -> }, @@ -312,6 +314,7 @@ internal fun ChannelMessageBubbleContainer( autoDownloadRoaming = appearance.autoDownloadRoaming, autoplayVideos = appearance.autoplayVideos, onVideoClick = onVideoClick, + onDownloadVideo = onDownloadVideo, onCancelDownload = onCancelDownload, onLongClick = { offset -> (onMessageLongPressState ?: onReplyClickState)( @@ -492,6 +495,7 @@ internal fun ChannelMessageBubbleContainer( onReplyClick = onGoToReply, onReactionClick = { onReactionClick(msg.id, it) }, onStickerClick = { onStickerClick(content.setId) }, + onDownloadSticker = onDownloadSticker, onLongClick = { (onMessageLongPressState ?: onReplyClickState)( layoutTracker.bubblePosition, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt index 4562341e..9aca60df 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt @@ -56,11 +56,13 @@ import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.R +import org.monogram.presentation.core.media.VideoStickerPlayer +import org.monogram.presentation.core.media.VideoType import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression -import org.monogram.presentation.core.media.VideoStickerPlayer -import org.monogram.presentation.core.media.VideoType +import org.monogram.presentation.features.chats.conversation.ui.content.VideoTapAction +import org.monogram.presentation.features.chats.conversation.ui.content.resolveVideoTapAction import org.monogram.presentation.features.chats.conversation.ui.message.BigEmojiContent import org.monogram.presentation.features.chats.conversation.ui.message.ForwardContent import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingAction @@ -86,6 +88,7 @@ fun ChannelVideoMessageBubble( isSameSenderBelow: Boolean = false, isSameSenderAbove: Boolean = false, onVideoClick: (MessageModel) -> Unit, + onDownloadVideo: (Int) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, onLongClick: (Offset) -> Unit, onReplyClick: (MessageModel) -> Unit = {}, @@ -154,7 +157,7 @@ fun ChannelVideoMessageBubble( downloadUtils.isRoaming() -> autoDownloadRoaming else -> autoDownloadMobile } - if (shouldDownload) onVideoClick(msg) + if (shouldDownload) onDownloadVideo(content.fileId) } } @@ -358,7 +361,9 @@ fun ChannelVideoMessageBubble( onIdleClick = { isAutoDownloadSuppressed = false AutoDownloadSuppression.clear(content.fileId) - onVideoClick(msg) + if (content.supportsStreaming) onVideoClick(msg) else onDownloadVideo( + content.fileId + ) } ) } @@ -382,7 +387,16 @@ fun ChannelVideoMessageBubble( } else { isAutoDownloadSuppressed = false AutoDownloadSuppression.clear(content.fileId) - onVideoClick(msg) + when (resolveVideoTapAction( + content.path, + content.supportsStreaming + )) { + VideoTapAction.Download -> onDownloadVideo( + content.fileId + ) + + VideoTapAction.Open -> onVideoClick(msg) + } } }, onLongPress = { offset -> onLongClick(videoPosition + offset) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChannelSponsoredMessageRow.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChannelSponsoredMessageRow.kt new file mode 100644 index 00000000..dc4a002b --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChannelSponsoredMessageRow.kt @@ -0,0 +1,395 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Campaign +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.SponsoredMessageModel +import org.monogram.presentation.R +import org.monogram.presentation.core.ui.shimmerBackground +import org.monogram.presentation.core.util.namespacedCacheKey +import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingBackground + +@Composable +internal fun ChannelSponsoredMessageRow( + sponsoredMessage: SponsoredMessageModel, + onCardClick: () -> Unit, + onMediaClick: () -> Unit +) { + val context = LocalContext.current + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val maxWidth = if (isLandscape) { + (screenWidth * 0.7f).coerceAtMost(600.dp) + } else { + (screenWidth * 0.94f).coerceAtMost(500.dp) + } + val content = sponsoredMessage.content + val media = rememberSponsoredMedia(content) + val titleColor = MaterialTheme.colorScheme.onSurface + val secondaryTextColor = MaterialTheme.colorScheme.onSurfaceVariant + val bubbleColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.96f) + val buttonColor = MaterialTheme.colorScheme.secondaryContainer + val label = stringResource( + if (sponsoredMessage.isRecommended) { + R.string.chat_recommended_label + } else { + R.string.chat_sponsored_label + } + ) + val contentText = sponsoredContentText(content) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.Bottom + ) { + Box( + modifier = Modifier + .padding(horizontal = 8.dp) + .widthIn(max = maxWidth) + .fillMaxWidth() + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f), + shape = RoundedCornerShape(20.dp) + ), + color = bubbleColor, + shape = RoundedCornerShape(20.dp), + tonalElevation = 0.dp, + onClick = onCardClick + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (sponsoredMessage.sponsor.photoPath != null) { + AsyncImage( + model = sponsoredMessage.sponsor.photoPath, + contentDescription = null, + modifier = Modifier + .size(28.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + CircleShape + ), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier + .size(28.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Campaign, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + sponsoredMessage.title?.takeIf { it.isNotBlank() }?.let { title -> + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = titleColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + sponsoredMessage.sponsor.info?.takeIf { it.isNotBlank() } + ?.let { info -> + Text( + text = info, + style = MaterialTheme.typography.bodySmall, + color = secondaryTextColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + media?.let { + SponsoredMedia( + messageId = sponsoredMessage.messageId, + media = it, + context = context, + onMediaClick = onMediaClick + ) + } + + contentText?.let { text -> + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = titleColor + ) + } + + sponsoredMessage.additionalInfo?.takeIf { it.isNotBlank() }?.let { info -> + Text( + text = info, + style = MaterialTheme.typography.bodySmall, + color = secondaryTextColor, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + + sponsoredMessage.buttonText?.takeIf { it.isNotBlank() }?.let { buttonText -> + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f) + ) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onCardClick, + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonColor, + contentColor = titleColor + ), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp) + ) { + Text( + text = buttonText, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + } + } +} + +@Composable +private fun SponsoredMedia( + messageId: Long, + media: SponsoredMediaUiModel, + context: android.content.Context, + onMediaClick: () -> Unit +) { + val shape = RoundedCornerShape(16.dp) + val model = media.fullPath ?: media.previewPath + val cacheKey = when { + !media.fullPath.isNullOrBlank() -> namespacedCacheKey( + "sponsored_media_full:${media.fileId}", + media.fullPath + ) + + !media.previewPath.isNullOrBlank() -> namespacedCacheKey( + "sponsored_media_preview:${media.fileId}", + media.previewPath + ) + + else -> null + } + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val mediaRatio = if (media.width > 0 && media.height > 0) { + (media.width.toFloat() / media.height.toFloat()).coerceIn(0.5f, 2f) + } else { + 1f + } + val mediaHeight = (maxWidth / mediaRatio).coerceIn(160.dp, 320.dp) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(mediaHeight) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onMediaClick), + contentAlignment = Alignment.Center + ) { + when { + !media.fullPath.isNullOrBlank() -> { + val request = ImageRequest.Builder(context) + .data(model) + .apply { + cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(false) + .build() + AsyncImage( + model = request, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Crop + ) + } + + !media.previewPath.isNullOrBlank() -> { + val request = ImageRequest.Builder(context) + .data(model) + .apply { + cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(false) + .build() + AsyncImage( + model = request, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.Crop + ) + } + + media.minithumbnail != null -> { + MediaLoadingBackground( + previewData = media.minithumbnail, + contentScale = ContentScale.Crop + ) + } + + else -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(mediaHeight) + .shimmerBackground(shape = shape) + ) + } + } + } + } +} + +internal fun sponsoredContentText(content: MessageContent): String? { + return when (content) { + is MessageContent.Text -> content.text.takeIf { it.isNotBlank() } + is MessageContent.Photo -> content.caption.takeIf { it.isNotBlank() } + is MessageContent.Video -> content.caption.takeIf { it.isNotBlank() } + is MessageContent.Gif -> content.caption.takeIf { it.isNotBlank() } + else -> null + } +} + +private fun rememberSponsoredMedia(content: MessageContent): SponsoredMediaUiModel? { + return when (content) { + is MessageContent.Photo -> SponsoredMediaUiModel( + fileId = content.fileId, + width = content.width, + height = content.height, + fullPath = content.path, + previewPath = content.thumbnailPath, + minithumbnail = content.minithumbnail + ) + + is MessageContent.Video -> SponsoredMediaUiModel( + fileId = content.fileId, + width = content.width, + height = content.height, + fullPath = content.path, + previewPath = content.thumbnailPath, + minithumbnail = content.minithumbnail + ) + + is MessageContent.Gif -> SponsoredMediaUiModel( + fileId = content.fileId, + width = content.width, + height = content.height, + fullPath = content.path, + previewPath = null, + minithumbnail = content.minithumbnail + ) + + else -> null + } +} + +private data class SponsoredMediaUiModel( + val fileId: Int, + val width: Int, + val height: Int, + val fullPath: String?, + val previewPath: String?, + val minithumbnail: ByteArray? +) { + val minithumbnailHash: Int? = minithumbnail?.contentHashCode() + val logSource: String = when { + !fullPath.isNullOrBlank() -> "full" + !previewPath.isNullOrBlank() -> "thumb" + minithumbnail != null -> "mini" + else -> "skeleton" + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt index 91c5dac9..0a2de506 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt @@ -66,13 +66,15 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.UserModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.ExpressiveDefaults +import org.monogram.presentation.core.util.copyUriToTempMediaFile import org.monogram.presentation.features.chats.conversation.ChatComponent import org.monogram.presentation.features.chats.conversation.ui.AdvancedCircularRecorderScreen import org.monogram.presentation.features.chats.conversation.ui.ChatInputBar import org.monogram.presentation.features.chats.conversation.ui.MessageListShimmer import org.monogram.presentation.features.chats.conversation.ui.message.LinkPreviewAction +import org.monogram.presentation.features.share.PendingAttachment +import org.monogram.presentation.features.share.inferPendingAttachmentKind import java.io.File -import java.io.FileOutputStream @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -94,27 +96,20 @@ internal fun ChatContentBottomBar( val pickMedia = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris -> - val albumPaths = mutableListOf() - uris.forEach { uri -> - val mimeType = context.contentResolver.getType(uri) - val extension = when { - mimeType == "image/gif" -> "gif" - mimeType?.startsWith("video/") == true -> "mp4" - else -> "jpg" - } - val file = File(context.cacheDir, "temp_media_${System.nanoTime()}.$extension") - context.contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(file).use { output -> input.copyTo(output) } - } - if (extension == "gif") { - component.onSendGifFile(file.absolutePath) - } else { - albumPaths.add(file.absolutePath) - } + val attachments = uris.mapNotNull { uri -> + val copied = context.copyUriToTempMediaFile(uri) ?: return@mapNotNull null + val path = copied.localPath + PendingAttachment( + localPath = path, + kind = inferPendingAttachmentKind( + localPath = path, + mimeType = copied.mimeType + ), + deleteAfterUse = true + ) } - if (albumPaths.isNotEmpty()) { - onPendingMediaPathsChanged((pendingMediaPaths + albumPaths).distinct()) - onPendingDocumentPathsChanged(emptyList()) + if (attachments.isNotEmpty()) { + component.onStageAttachments((state.stagedAttachments + attachments).distinctBy { it.localPath }) } } @@ -208,6 +203,7 @@ internal fun ChatContentBody( state: ChatContentBodyUiState, component: ChatComponent, scrollState: LazyListState, + isDragged: Boolean, messageListState: ChatMessageListUiState, groupedMessages: List, searchUiState: ChatContentSearchUiState, @@ -436,6 +432,7 @@ internal fun ChatContentBody( state = messageListState, component = component, scrollState = scrollState, + isDragged = isDragged, groupedMessages = groupedMessages, onPhotoDownload = onPhotoDownload, onPhotoClick = onPhotoClick, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt index 194a8be9..cf940767 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.remember import org.monogram.domain.models.MessageModel import org.monogram.domain.models.UserModel import org.monogram.presentation.features.chats.conversation.ChatComponent +import org.monogram.presentation.features.chats.conversation.ChatRenderMode import org.monogram.presentation.features.chats.conversation.ChatViewportPhase @Immutable @@ -59,6 +60,38 @@ internal data class ChatContentBodyUiState( val isChannel: Boolean ) +@Immutable +internal data class ChatContentPreviewState( + val currentTopicId: Long?, + val rootMessage: MessageModel?, + val messageSource: List, +) + +@Composable +internal fun rememberChatContentPreviewState( + state: ChatComponent.State, + renderMode: ChatRenderMode, +): ChatContentPreviewState { + val previewCurrentTopicId = + if (renderMode == ChatRenderMode.ForumTopicSwipePreview) null else state.currentTopicId + val previewRootMessage = + if (renderMode == ChatRenderMode.ForumTopicSwipePreview) null else state.rootMessage + val previewMessages = + if (renderMode == ChatRenderMode.ForumTopicSwipePreview) emptyList() else state.messages + + return remember( + previewCurrentTopicId, + previewRootMessage, + previewMessages, + ) { + ChatContentPreviewState( + currentTopicId = previewCurrentTopicId, + rootMessage = previewRootMessage, + messageSource = previewMessages, + ) + } +} + internal fun shouldSuppressMessageEntryAnimations( showInitialLoading: Boolean, viewportPhase: ChatViewportPhase @@ -137,13 +170,18 @@ internal fun rememberChatContentPermissionState( @Composable internal fun rememberChatMessagePresentationState( state: ChatComponent.State, + previewState: ChatContentPreviewState, selectedMessageId: Long?, transformedMessageTexts: Map ): ChatContentMessagePresentationState { - val rootMessageId = state.rootMessage?.id - val displayMessages by remember(state.messages, rootMessageId, transformedMessageTexts) { + val rootMessageId = previewState.rootMessage?.id + val displayMessages by remember( + previewState.messageSource, + rootMessageId, + transformedMessageTexts + ) { derivedStateOf { - state.messages.map { message -> + previewState.messageSource.map { message -> val transformedText = transformedMessageTexts[message.id] val transformedMessage = if (transformedText != null) { message.withUpdatedTextContent(transformedText) @@ -183,7 +221,7 @@ internal fun rememberChatMessagePresentationState( derivedStateOf { selectedMessageId?.let { id -> displayMessages.firstOrNull { it.id == id } } } } val isComments = rootMessageId != null - val isForumList = state.viewAsTopics && state.currentTopicId == null + val isForumList = state.viewAsTopics && previewState.currentTopicId == null return remember( displayMessages, @@ -207,20 +245,22 @@ internal fun rememberChatMessagePresentationState( @Composable internal fun rememberChatMessageListState( state: ChatComponent.State, + previewState: ChatContentPreviewState, displayMessages: List, canSendAnything: Boolean, showInitialLoading: Boolean ): ChatMessageListUiState { return remember( state.chatId, - state.currentTopicId, + previewState.currentTopicId, displayMessages, state.selectedMessageIds, state.unreadSeparatorCount, state.unreadSeparatorLastReadInboxMessageId, + state.channelSponsoredMessages, state.viewAsTopics, state.topics, - state.rootMessage, + previewState.rootMessage, state.isLoading, state.isLoadingOlder, state.isLoadingNewer, @@ -251,14 +291,15 @@ internal fun rememberChatMessageListState( ) { ChatMessageListUiState( chatId = state.chatId, - currentTopicId = state.currentTopicId, + currentTopicId = previewState.currentTopicId, messages = displayMessages, selectedMessageIds = state.selectedMessageIds, unreadSeparatorCount = state.unreadSeparatorCount, unreadSeparatorLastReadInboxMessageId = state.unreadSeparatorLastReadInboxMessageId, + channelSponsoredMessages = state.channelSponsoredMessages, viewAsTopics = state.viewAsTopics, topics = state.topics, - rootMessage = state.rootMessage, + rootMessage = previewState.rootMessage, isLoading = state.isLoading, isLoadingOlder = state.isLoadingOlder, isLoadingNewer = state.isLoadingNewer, @@ -295,12 +336,13 @@ internal fun rememberChatMessageListState( @Composable internal fun rememberChatContentBodyUiState( - state: ChatComponent.State + state: ChatComponent.State, + previewState: ChatContentPreviewState, ): ChatContentBodyUiState { return remember( state.viewAsTopics, - state.currentTopicId, - state.rootMessage, + previewState.currentTopicId, + previewState.rootMessage, state.isSearchActive, state.searchQuery, state.searchResults, @@ -315,7 +357,7 @@ internal fun rememberChatContentBodyUiState( state.isChannel ) { ChatContentBodyUiState( - showTopOverlayPadding = (state.viewAsTopics && state.currentTopicId == null) || state.rootMessage != null, + showTopOverlayPadding = (state.viewAsTopics && previewState.currentTopicId == null) || previewState.rootMessage != null, isSearchActive = state.isSearchActive, searchQuery = state.searchQuery, searchResults = state.searchResults, @@ -334,11 +376,12 @@ internal fun rememberChatContentBodyUiState( @Composable internal fun rememberChatTopBarUiState( - state: ChatComponent.State + state: ChatComponent.State, + previewState: ChatContentPreviewState, ): ChatContentTopBarUiState { return remember( - state.currentTopicId, - state.rootMessage, + previewState.currentTopicId, + previewState.rootMessage, state.isGroup, state.isChannel, state.isAdmin, @@ -361,12 +404,15 @@ internal fun rememberChatTopBarUiState( state.isMuted, state.isSearchActive, state.searchQuery, + state.isMember, + state.canDeleteChat, state.pinnedMessage, - state.pinnedMessageCount + state.pinnedMessageCount, + state.isPinnedMessageHidden ) { ChatContentTopBarUiState( - currentTopicId = state.currentTopicId, - rootMessage = state.rootMessage, + currentTopicId = previewState.currentTopicId, + rootMessage = previewState.rootMessage, isGroup = state.isGroup, isChannel = state.isChannel, isAdmin = state.isAdmin, @@ -389,8 +435,11 @@ internal fun rememberChatTopBarUiState( isMuted = state.isMuted, isSearchActive = state.isSearchActive, searchQuery = state.searchQuery, + isMember = state.isMember, + canDeleteChat = state.canDeleteChat, pinnedMessage = if (state.isSearchActive) null else state.pinnedMessage, - pinnedMessageCount = if (state.isSearchActive) 0 else state.pinnedMessageCount + pinnedMessageCount = if (state.isSearchActive) 0 else state.pinnedMessageCount, + isPinnedMessageHidden = state.isPinnedMessageHidden ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEdgeLoadPolicy.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEdgeLoadPolicy.kt new file mode 100644 index 00000000..9365aa33 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEdgeLoadPolicy.kt @@ -0,0 +1,41 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +internal data class EdgeLoadDecision( + val shouldLoadOlder: Boolean, + val shouldLoadNewer: Boolean, + val nextOlderAnchorId: Long?, + val nextNewerAnchorId: Long? +) + +internal fun decideEdgeLoad( + isComments: Boolean, + isOldestLoaded: Boolean, + isLatestLoaded: Boolean, + isAtBottom: Boolean, + nearOlderEdge: Boolean, + nearNewerEdge: Boolean, + olderAnchorId: Long?, + newerAnchorId: Long?, + lastOlderAnchorId: Long?, + lastNewerAnchorId: Long? +): EdgeLoadDecision { + val shouldLoadOlder = nearOlderEdge && + !isOldestLoaded && + olderAnchorId != null && + olderAnchorId > 0L && + olderAnchorId != lastOlderAnchorId + + val shouldLoadNewer = nearNewerEdge && + !isLatestLoaded && + (!isAtBottom || isComments) && + newerAnchorId != null && + newerAnchorId > 0L && + newerAnchorId != lastNewerAnchorId + + return EdgeLoadDecision( + shouldLoadOlder = shouldLoadOlder, + shouldLoadNewer = shouldLoadNewer, + nextOlderAnchorId = olderAnchorId?.takeIf { shouldLoadOlder }, + nextNewerAnchorId = newerAnchorId?.takeIf { shouldLoadNewer } + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt index 4b4d8f0a..9fd1ca6f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt @@ -4,7 +4,11 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameNanos import kotlinx.coroutines.delay @@ -14,12 +18,60 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import org.monogram.presentation.features.chats.conversation.ChatComponent +import org.monogram.presentation.features.chats.conversation.ChatConversationLog import org.monogram.presentation.features.chats.conversation.ChatScrollCommand import org.monogram.presentation.features.chats.conversation.ChatViewportPhase import org.monogram.presentation.features.chats.conversation.DefaultChatComponent import org.monogram.presentation.features.chats.conversation.logic.requestMessageHighlight import kotlin.math.abs +internal fun shouldAutoFollowLatestAfterContentChange( + previousLastGroupedMessageId: Long?, + currentLastGroupedMessageId: Long?, + followLatestArmed: Boolean, + viewportPhase: ChatViewportPhase, + pendingScrollCommand: ChatScrollCommand?, + isLoading: Boolean, + isLoadingOlder: Boolean, + isLoadingNewer: Boolean, + isScrollInProgress: Boolean, + showInitialLoading: Boolean, + isLatestLoaded: Boolean +): Boolean { + if (!followLatestArmed) return false + if (viewportPhase != ChatViewportPhase.Settled) return false + if (previousLastGroupedMessageId == null || currentLastGroupedMessageId == null) return false + if (previousLastGroupedMessageId == currentLastGroupedMessageId) return false + if (pendingScrollCommand != null) return false + if (isLoading || isLoadingOlder || isLoadingNewer || isScrollInProgress || showInitialLoading) return false + if (!isLatestLoaded) return false + return true +} + +internal fun updateFollowLatestArmed( + previousArmed: Boolean, + isNearBottom: Boolean, + hasUserScrolledAwayFromBottom: Boolean, + isDragged: Boolean +): Boolean { + if (isNearBottom) return true + if (isDragged || hasUserScrolledAwayFromBottom) return false + return previousArmed +} + +internal fun shouldDisarmFollowLatest( + pendingScrollCommand: ChatScrollCommand? +): Boolean { + return when (pendingScrollCommand) { + is ChatScrollCommand.JumpToMessage, + is ChatScrollCommand.ScrollToStart -> true + + is ChatScrollCommand.RestoreViewport -> !pendingScrollCommand.atBottom + null, + is ChatScrollCommand.ScrollToBottom -> false + } +} + internal fun shouldRetainBottomAlignmentAfterContentChange( viewportPhase: ChatViewportPhase, pendingScrollCommand: ChatScrollCommand?, @@ -40,6 +92,19 @@ internal fun shouldRetainBottomAlignmentAfterContentChange( return abs(delta) > alignmentTolerancePx } +internal fun shouldAutoSettleViewportAfterContentReady( + viewportPhase: ChatViewportPhase, + pendingScrollCommand: ChatScrollCommand?, + hasMessages: Boolean, + viewAsTopics: Boolean, + currentTopicId: Long?, + topicsCount: Int +): Boolean { + if (viewportPhase == ChatViewportPhase.Settled) return false + if (pendingScrollCommand != null) return false + return hasMessages || (viewAsTopics && currentTopicId == null && topicsCount > 0) +} + @Composable internal fun ChatContentEffects( component: ChatComponent, @@ -50,6 +115,8 @@ internal fun ChatContentEffects( isComments: Boolean, isForumList: Boolean, effectsEnabled: Boolean, + componentInstanceId: String?, + uiInstanceId: String, isDragged: Boolean, isRecordingVideo: Boolean, showInitialLoading: Boolean, @@ -69,14 +136,57 @@ internal fun ChatContentEffects( val isViewportSettled = effectsEnabled && state.viewportPhase == ChatViewportPhase.Settled val firstGroupedMessageId = groupedMessages.firstOrNull()?.firstMessageId val lastGroupedMessageId = groupedMessages.lastOrNull()?.firstMessageId + val conversationItems = remember( + groupedMessages, + state.channelSponsoredMessages, + isComments + ) { + if (isComments) { + groupedMessages.mapIndexed { index, item -> + ConversationListItem.Grouped( + groupedIndex = index, + groupedMessageItem = item + ) + } + } else { + buildConversationListItems( + groupedMessages = groupedMessages, + sponsoredFeed = state.channelSponsoredMessages?.takeIf { it.messages.isNotEmpty() } + ) + } + } + var followLatestArmed by remember { mutableStateOf(false) } + var previousLastGroupedMessageId by remember { mutableStateOf(null) } suspend fun consumeScrollCommandAndSettle() { + ChatConversationLog.logState( + stream = ChatConversationLog.STREAM_VIEWPORT, + event = "effects_consume_scroll_command", + state = latestUiState.value, + componentInstanceId = componentInstanceId, + uiInstanceId = uiInstanceId, + extra = "pending=${latestUiState.value.pendingScrollCommand?.javaClass?.simpleName ?: "none"}" + ) component.onScrollCommandConsumed() withFrameNanos { } + ChatConversationLog.logState( + stream = ChatConversationLog.STREAM_VIEWPORT, + event = "effects_settle_after_scroll_command", + state = latestUiState.value, + componentInstanceId = componentInstanceId, + uiInstanceId = uiInstanceId + ) component.onViewportSettled() } LaunchedEffect(effectsEnabled, state.viewportPhase) { if (!isViewportSettled) return@LaunchedEffect + ChatConversationLog.logState( + stream = ChatConversationLog.STREAM_VIEWPORT, + event = "effects_viewport_settled_visible", + state = state, + componentInstanceId = componentInstanceId, + uiInstanceId = uiInstanceId + ) onVisible() if (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) { component.onDismissVideo() @@ -87,20 +197,37 @@ internal fun ChatContentEffects( effectsEnabled, state.viewportPhase, state.pendingScrollCommand, - state.isLoading, state.messages.size, state.topics.size, state.viewAsTopics, state.currentTopicId ) { if (!effectsEnabled) return@LaunchedEffect - if (state.viewportPhase == ChatViewportPhase.Settled || state.pendingScrollCommand != null || state.isLoading) { - return@LaunchedEffect - } - val hasContent = state.messages.isNotEmpty() || - (state.viewAsTopics && state.currentTopicId == null && state.topics.isNotEmpty()) - if (!hasContent) return@LaunchedEffect + val shouldAutoSettle = shouldAutoSettleViewportAfterContentReady( + viewportPhase = state.viewportPhase, + pendingScrollCommand = state.pendingScrollCommand, + hasMessages = state.messages.isNotEmpty(), + viewAsTopics = state.viewAsTopics, + currentTopicId = state.currentTopicId, + topicsCount = state.topics.size + ) + ChatConversationLog.logState( + stream = ChatConversationLog.STREAM_VIEWPORT, + event = "effects_auto_settle_check", + state = state, + componentInstanceId = componentInstanceId, + uiInstanceId = uiInstanceId, + extra = "effectsEnabled=$effectsEnabled shouldAutoSettle=$shouldAutoSettle" + ) + if (!shouldAutoSettle) return@LaunchedEffect withFrameNanos { } + ChatConversationLog.logState( + stream = ChatConversationLog.STREAM_VIEWPORT, + event = "effects_auto_settle_fire", + state = state, + componentInstanceId = componentInstanceId, + uiInstanceId = uiInstanceId + ) component.onViewportSettled() } @@ -146,7 +273,8 @@ internal fun ChatContentEffects( isComments, groupedMessages.size, firstGroupedMessageId, - lastGroupedMessageId + lastGroupedMessageId, + conversationItems ) { if (!effectsEnabled) return@LaunchedEffect val command = state.pendingScrollCommand ?: return@LaunchedEffect @@ -159,6 +287,10 @@ internal fun ChatContentEffects( isAtBottom = state.isAtBottom, hasMessages = groupedMessages.isNotEmpty() ) + val groupedLazyIndexByFirstMessageId = buildGroupedLazyIndexByFirstMessageId( + conversationItems = conversationItems, + leadingItemsCount = leadingItems + ) when (command) { is ChatScrollCommand.RestoreViewport -> { @@ -175,7 +307,9 @@ internal fun ChatContentEffects( ) ?: -1 if (groupedIndex >= 0) { - val targetIndex = groupedIndexToLazyIndex(groupedIndex, leadingItems) + val targetIndex = groupedLazyIndexByFirstMessageId[ + groupedMessages[groupedIndex].firstMessageId + ] ?: groupedIndexToLazyIndex(groupedIndex, leadingItems) scrollState.restoreViewportAtIndex( targetIndex = targetIndex, anchorOffsetPx = command.anchorOffsetPx @@ -198,7 +332,9 @@ internal fun ChatContentEffects( ) ?: -1 if (groupedIndex >= 0) { - val targetIndex = groupedIndexToLazyIndex(groupedIndex, leadingItems) + val targetIndex = groupedLazyIndexByFirstMessageId[ + groupedMessages[groupedIndex].firstMessageId + ] ?: groupedIndexToLazyIndex(groupedIndex, leadingItems) scrollState.scrollToMessageIndex( index = targetIndex, align = command.align, @@ -229,6 +365,13 @@ internal fun ChatContentEffects( } } + LaunchedEffect(effectsEnabled, state.viewportPhase, state.pendingScrollCommand) { + if (!effectsEnabled) return@LaunchedEffect + if (shouldDisarmFollowLatest(state.pendingScrollCommand)) { + followLatestArmed = false + } + } + LaunchedEffect( effectsEnabled, state.viewportPhase, @@ -259,22 +402,34 @@ internal fun ChatContentEffects( lastReportedBottomState = snapshot.isAtBottom } + val nextHasUserScrolledAwayFromBottom = when { + snapshot.isNearBottom -> false + isDragged -> true + else -> hasUserScrolledAwayFromBottom + } if (snapshot.isNearBottom) { onHasUserScrolledAwayFromBottomChanged(false) } else if (isDragged) { onHasUserScrolledAwayFromBottomChanged(true) } + followLatestArmed = updateFollowLatestArmed( + previousArmed = followLatestArmed, + isNearBottom = snapshot.isNearBottom, + hasUserScrolledAwayFromBottom = nextHasUserScrolledAwayFromBottom, + isDragged = isDragged + ) val shouldShow = !isForumList && !showInitialLoading && - (snapshot.unreadCount > 0 || (hasUserScrolledAwayFromBottom && !snapshot.isNearBottom)) + (snapshot.unreadCount > 0 || + (nextHasUserScrolledAwayFromBottom && !snapshot.isNearBottom)) if (shouldShow) { onShowScrollToBottomButtonChanged(true) } else { delay(120) val keepVisible = snapshot.unreadCount > 0 || - (hasUserScrolledAwayFromBottom && !snapshot.isNearBottom) + (nextHasUserScrolledAwayFromBottom && !snapshot.isNearBottom) if (!keepVisible) { onShowScrollToBottomButtonChanged(false) } @@ -282,6 +437,41 @@ internal fun ChatContentEffects( } } + LaunchedEffect( + effectsEnabled, + state.viewportPhase, + lastGroupedMessageId, + state.pendingScrollCommand, + state.isLoading, + state.isLoadingOlder, + state.isLoadingNewer, + state.isLatestLoaded, + showInitialLoading, + isDragged + ) { + if (!effectsEnabled) return@LaunchedEffect + val shouldAutoFollow = shouldAutoFollowLatestAfterContentChange( + previousLastGroupedMessageId = previousLastGroupedMessageId, + currentLastGroupedMessageId = lastGroupedMessageId, + followLatestArmed = followLatestArmed, + viewportPhase = state.viewportPhase, + pendingScrollCommand = state.pendingScrollCommand, + isLoading = state.isLoading, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isScrollInProgress = isDragged || scrollState.isScrollInProgress, + showInitialLoading = showInitialLoading, + isLatestLoaded = state.isLatestLoaded + ) + if (shouldAutoFollow) { + scrollState.scrollToChatBottomStaged( + isComments = isComments, + animated = state.isChatAnimationsEnabled + ) + } + previousLastGroupedMessageId = lastGroupedMessageId + } + LaunchedEffect( effectsEnabled, state.viewportPhase, @@ -289,6 +479,7 @@ internal fun ChatContentEffects( groupedMessages.size, firstGroupedMessageId, lastGroupedMessageId, + conversationItems, isComments, state.isLatestLoaded, state.isLoadingOlder, @@ -300,6 +491,7 @@ internal fun ChatContentEffects( buildViewportSnapshot( scrollState = scrollState, groupedMessages = groupedMessages, + conversationItems = conversationItems, isComments = isComments, isLatestLoaded = state.isLatestLoaded, isLoadingOlder = state.isLoadingOlder, @@ -323,6 +515,7 @@ internal fun ChatContentEffects( groupedMessages.size, firstGroupedMessageId, lastGroupedMessageId, + conversationItems, isComments, state.currentTopicId, state.isLatestLoaded, @@ -335,6 +528,7 @@ internal fun ChatContentEffects( val viewport = buildViewportSnapshot( scrollState = scrollState, groupedMessages = groupedMessages, + conversationItems = conversationItems, isComments = isComments, isLatestLoaded = state.isLatestLoaded, isLoadingOlder = state.isLoadingOlder, @@ -354,7 +548,8 @@ internal fun ChatContentEffects( scrollState, groupedMessages.size, firstGroupedMessageId, - lastGroupedMessageId + lastGroupedMessageId, + conversationItems ) { if (!isViewportSettled) return@LaunchedEffect snapshotFlow { scrollState.layoutInfo.visibleItemsInfo } @@ -375,8 +570,15 @@ internal fun ChatContentEffects( val maxIndex = visibleItems.maxOf { it.index } visibleItems.forEach { item -> - val groupedIndex = lazyIndexToGroupedIndex(item.index, leadingItemsCount) - groupedMessages.getOrNull(groupedIndex)?.let { grouped -> + val grouped = when ( + val conversationItem = conversationItems.getOrNull( + lazyIndexToGroupedIndex(item.index, leadingItemsCount) + ) + ) { + is ConversationListItem.Grouped -> conversationItem.groupedMessageItem + else -> null + } + grouped?.let { grouped -> when (grouped) { is GroupedMessageItem.Single -> visibleIds.add(grouped.message.id) is GroupedMessageItem.Album -> grouped.messages.forEach { message -> @@ -390,8 +592,15 @@ internal fun ChatContentEffects( val nearbyEnd = maxIndex + 5 for (index in nearbyStart..nearbyEnd) { if (index in minIndex..maxIndex) continue - val groupedIndex = lazyIndexToGroupedIndex(index, leadingItemsCount) - groupedMessages.getOrNull(groupedIndex)?.let { grouped -> + val grouped = when ( + val conversationItem = conversationItems.getOrNull( + lazyIndexToGroupedIndex(index, leadingItemsCount) + ) + ) { + is ConversationListItem.Grouped -> conversationItem.groupedMessageItem + else -> null + } + grouped?.let { grouped -> when (grouped) { is GroupedMessageItem.Single -> nearbyIds.add(grouped.message.id) is GroupedMessageItem.Album -> grouped.messages.forEach { message -> diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentInputConfiguration.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentInputConfiguration.kt index ac827230..24e772b8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentInputConfiguration.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentInputConfiguration.kt @@ -72,8 +72,9 @@ internal fun rememberChatInputBarState( isDraftLinkPreviewLoading = state.isDraftLinkPreviewLoading, draftLinkPreviewError = state.draftLinkPreviewError, isDraftLinkPreviewDisabledForSend = state.isDraftLinkPreviewDisabledForSend, - pendingMediaPaths = pendingMediaPaths, - pendingDocumentPaths = pendingDocumentPaths, + pendingAttachments = state.stagedAttachments, + pendingMediaPaths = state.pendingMediaPaths, + pendingDocumentPaths = state.pendingDocumentPaths, isClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed ?: false, permissions = state.effectiveInputPermissions ?: state.permissions, @@ -144,53 +145,12 @@ internal fun rememberChatInputBarActions( onDismissDraftLinkPreview = component::onDismissDraftLinkPreview, onRestoreDraftLinkPreview = component::onRestoreDraftLinkPreview, onTyping = component::onTyping, - onCancelMedia = { onSetPendingMediaPaths(emptyList()) }, - onSendMedia = { paths, caption, captionEntities, options -> - if (options.sendAsDocument) { - if (paths.size > 1) { - component.onSendAlbum(paths, caption, captionEntities, options) - } else { - paths.firstOrNull()?.let { - component.onSendDocument(it, caption, captionEntities, options) - } - } - } else if (paths.size > 1) { - component.onSendAlbum(paths, caption, captionEntities, options) - } else { - paths.firstOrNull()?.let { - if (it.endsWith(".mp4")) { - component.onSendVideo(it, caption, captionEntities, options) - } else { - component.onSendPhoto(it, caption, captionEntities, options) - } - } - } - onSetPendingMediaPaths(emptyList()) - onSetPendingDocumentPaths(emptyList()) + onCancelMedia = component::onClearPendingAttachments, + onSendAttachments = { attachments, caption, captionEntities, options -> + component.onSendPendingAttachments(attachments, caption, captionEntities, options) }, - onSendDocuments = { paths, caption, captionEntities, options -> - paths.forEachIndexed { index, path -> - component.onSendDocument( - path, - caption = if (index == 0) caption else "", - captionEntities = if (index == 0) captionEntities else emptyList(), - sendOptions = options - ) - } - onSetPendingDocumentPaths(emptyList()) - onSetPendingMediaPaths(emptyList()) - }, - onMediaOrderChange = { - onSetPendingMediaPaths(it) - if (it.isNotEmpty()) { - onSetPendingDocumentPaths(emptyList()) - } - }, - onDocumentOrderChange = { - onSetPendingDocumentPaths(it) - if (it.isNotEmpty()) { - onSetPendingMediaPaths(emptyList()) - } + onPendingAttachmentsChange = { attachments -> + component.onStageAttachments(attachments) }, onMediaClick = onEditMediaPath, onDraftLinkPreviewAction = onDraftLinkPreviewAction, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt index 97c74aec..7b34ade5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt @@ -90,6 +90,8 @@ import kotlinx.coroutines.launch import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.SponsoredMessageModel +import org.monogram.domain.models.SponsoredMessagesFeedModel import org.monogram.domain.models.TopicModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar @@ -120,6 +122,7 @@ data class ChatMessageListUiState( val selectedMessageIds: Set, val unreadSeparatorCount: Int, val unreadSeparatorLastReadInboxMessageId: Long, + val channelSponsoredMessages: SponsoredMessagesFeedModel?, val viewAsTopics: Boolean, val topics: List, val rootMessage: MessageModel?, @@ -162,6 +165,69 @@ data class ChatMessageListUiState( val isChannelFeed: Boolean get() = isChannel && currentTopicId == null + + val sponsoredMessages: List + get() = if (!isComments && !isForumList && isChannelFeed) { + channelSponsoredMessages?.messages.orEmpty() + } else { + emptyList() + } +} + +internal fun shouldShowUnreadSeparator( + isComments: Boolean, + unreadBoundaryIndex: Int?, + unreadSeparatorCount: Int, + itemIndex: Int +): Boolean { + if (isComments) return false + if (unreadSeparatorCount <= 0) return false + return unreadBoundaryIndex == itemIndex +} + +internal data class VisibleMessageReadReportingContext( + val chatId: Long, + val currentTopicId: Long?, + val rootMessageId: Long? +) + +internal data class VisibleMessageReadReportingState( + val context: VisibleMessageReadReportingContext, + val isEnabled: Boolean +) + +@Immutable +private data class MessageRowRenderState( + val item: GroupedMessageItem, + val olderMsg: MessageModel?, + val newerMsg: MessageModel?, + val behavior: MessageRowBehaviorConfig, + val uiFlags: MessageRowUiFlags, + val highlightRequest: MessageHighlightRequest?, + val isTargetVisibleInViewport: Boolean, + val canReportVisibleMessagesAsRead: Boolean, + val rootMessageId: Long?, + val isEntryAnimationPending: Boolean +) + +internal fun updateVisibleMessageReadReportingState( + previousState: VisibleMessageReadReportingState?, + nextContext: VisibleMessageReadReportingContext, + hasUserDraggedList: Boolean +): VisibleMessageReadReportingState { + val isSameContext = previousState?.context == nextContext + val wasEnabled = isSameContext && previousState.isEnabled + return VisibleMessageReadReportingState( + context = nextContext, + isEnabled = wasEnabled || hasUserDraggedList + ) +} + +internal fun shouldReportVisibleMessageAsRead( + reportingState: VisibleMessageReadReportingState?, + context: VisibleMessageReadReportingContext +): Boolean { + return reportingState?.context == context && reportingState.isEnabled } @OptIn(ExperimentalFoundationApi::class) @@ -170,6 +236,7 @@ internal fun ChatContentList( state: ChatMessageListUiState, component: ChatComponent, scrollState: LazyListState, + isDragged: Boolean, groupedMessages: List, onPhotoClick: (MessageModel, List, List, List, Int) -> Unit, onPhotoDownload: (Int) -> Unit, @@ -202,9 +269,55 @@ internal fun ChatContentList( val latestState by rememberUpdatedState(state) var lastOlderLoadTriggerUptimeMs by remember { mutableLongStateOf(0L) } var lastNewerLoadTriggerUptimeMs by remember { mutableLongStateOf(0L) } + var lastOlderAnchorTriggerId by remember( + state.chatId, + state.currentTopicId + ) { mutableStateOf(null) } + var lastNewerAnchorTriggerId by remember( + state.chatId, + state.currentTopicId + ) { mutableStateOf(null) } val loadTriggerThrottleMs = 350L val groupedMessageIds = remember(groupedMessages) { groupedMessages.map(GroupedMessageItem::firstMessageId) } + val conversationItems = remember( + groupedMessages, + state.channelSponsoredMessages, + isComments + ) { + if (isComments) { + groupedMessages.mapIndexed { index, item -> + ConversationListItem.Grouped( + groupedIndex = index, + groupedMessageItem = item + ) + } + } else { + buildConversationListItems( + groupedMessages = groupedMessages, + sponsoredFeed = state.channelSponsoredMessages?.takeIf { it.messages.isNotEmpty() } + ) + } + } + val visibleMessageReadReportingContext = remember( + state.chatId, + state.currentTopicId, + state.rootMessage?.id + ) { + VisibleMessageReadReportingContext( + chatId = state.chatId, + currentTopicId = state.currentTopicId, + rootMessageId = state.rootMessage?.id + ) + } + var visibleMessageReadReportingState by remember { + mutableStateOf( + VisibleMessageReadReportingState( + context = visibleMessageReadReportingContext, + isEnabled = false + ) + ) + } var hasSeededEntryAnimations by rememberSaveable( state.chatId, state.currentTopicId @@ -234,21 +347,6 @@ internal fun ChatContentList( } } } - val unreadBoundaryGroupId = remember(unreadBoundaryIndex, groupedMessages) { - unreadBoundaryIndex - ?.takeIf { it in groupedMessages.indices } - ?.let { groupedMessages[it].firstMessageId } - } - var hasUnreadSeparatorBeenVisible by rememberSaveable( - state.chatId, - state.currentTopicId, - unreadBoundaryGroupId - ) { mutableStateOf(false) } - var hasUnreadSeparatorDismissed by rememberSaveable( - state.chatId, - state.currentTopicId, - unreadBoundaryGroupId - ) { mutableStateOf(false) } val visibleGroupedMessageIds by remember( scrollState, groupedMessages, @@ -256,7 +354,8 @@ internal fun ChatContentList( showNavPadding, state.isLoadingOlder, state.isLoadingNewer, - state.isAtBottom + state.isAtBottom, + conversationItems ) { derivedStateOf { val leadingItems = chatContentLeadingItemsCount( @@ -268,8 +367,17 @@ internal fun ChatContentList( hasMessages = groupedMessages.isNotEmpty() ) scrollState.layoutInfo.visibleItemsInfo.mapNotNull { visibleItem -> - val groupedIndex = lazyIndexToGroupedIndex(visibleItem.index, leadingItems) - groupedMessages.getOrNull(groupedIndex)?.firstMessageId + when ( + val item = conversationItems.getOrNull( + lazyIndexToGroupedIndex( + lazyIndex = visibleItem.index, + leadingItemsCount = leadingItems + ) + ) + ) { + is ConversationListItem.Grouped -> item.groupedMessageItem.firstMessageId + else -> null + } }.toSet() } } @@ -282,7 +390,8 @@ internal fun ChatContentList( topOverlayPadding, state.isLoadingOlder, state.isLoadingNewer, - state.isAtBottom + state.isAtBottom, + conversationItems ) { derivedStateOf { if (!scrollState.isScrollInProgress || groupedMessages.isEmpty()) { @@ -303,7 +412,7 @@ internal fun ChatContentList( } val anchor = layoutInfo.visibleItemsInfo .topAnchoredGroupedItem( - groupedMessages = groupedMessages, + conversationItems = conversationItems, leadingItemsCount = leadingItems, viewportTopOffset = viewportTopOffset ) @@ -345,12 +454,26 @@ internal fun ChatContentList( } } + LaunchedEffect(visibleMessageReadReportingContext, isDragged) { + visibleMessageReadReportingState = updateVisibleMessageReadReportingState( + previousState = visibleMessageReadReportingState, + nextContext = visibleMessageReadReportingContext, + hasUserDraggedList = isDragged + ) + } + + val canReportVisibleMessagesAsRead = shouldReportVisibleMessageAsRead( + reportingState = visibleMessageReadReportingState, + context = visibleMessageReadReportingContext + ) + LaunchedEffect( scrollState, groupedMessages.size, isComments, - state.isViewportSettled + state.isViewportSettled, + conversationItems ) { if (!state.isViewportSettled) return@LaunchedEffect snapshotFlow { scrollState.layoutInfo.visibleItemsInfo } @@ -365,40 +488,85 @@ internal fun ChatContentList( val currentState = latestState if (currentState.isLoading || currentState.isLoadingOlder || currentState.isLoadingNewer) return@collect + val groupedLastLazyIndex = if (groupedMessages.isEmpty()) { + -1 + } else { + val leadingItems = chatContentLeadingItemsCount( + isComments = isComments, + showNavPadding = showNavPadding, + isLoadingOlder = currentState.isLoadingOlder, + isLoadingNewer = currentState.isLoadingNewer, + isAtBottom = currentState.isAtBottom, + hasMessages = groupedMessages.isNotEmpty() + ) + val lastConversationIndex = + conversationItems.indexOfLast { it is ConversationListItem.Grouped } + if (lastConversationIndex >= 0) { + leadingItems + lastConversationIndex + } else { + -1 + } + } val olderPreloadThreshold = 2 val newerPreloadThreshold = 8 val nearStartForOlder = firstVisibleIndex <= olderPreloadThreshold val nearStartForNewer = firstVisibleIndex <= newerPreloadThreshold - val nearEnd = lastVisibleIndex >= (groupedMessages.size - 3).coerceAtLeast(0) + val nearEnd = lastVisibleIndex >= (groupedLastLazyIndex - 2).coerceAtLeast(0) val nearEndForNewer = - lastVisibleIndex >= (groupedMessages.size - (newerPreloadThreshold + 1)).coerceAtLeast( + lastVisibleIndex >= (groupedLastLazyIndex - newerPreloadThreshold).coerceAtLeast( 0 ) val now = SystemClock.uptimeMillis() + val olderAnchorId = if (isComments) { + currentState.messages.firstOrNull { it.id > 0 }?.id + } else { + currentState.messages.lastOrNull { it.id > 0 }?.id + } + val newerAnchorId = if (isComments) { + currentState.messages.lastOrNull { it.id > 0 }?.id + } else { + currentState.messages.firstOrNull { it.id > 0 }?.id + } + val decision = decideEdgeLoad( + isComments = isComments, + isOldestLoaded = currentState.isOldestLoaded, + isLatestLoaded = currentState.isLatestLoaded, + isAtBottom = currentState.isAtBottom, + nearOlderEdge = if (isComments) nearStartForOlder else nearEnd, + nearNewerEdge = if (isComments) nearEndForNewer else nearStartForNewer, + olderAnchorId = olderAnchorId, + newerAnchorId = newerAnchorId, + lastOlderAnchorId = lastOlderAnchorTriggerId, + lastNewerAnchorId = lastNewerAnchorTriggerId + ) if (isComments) { if (!scrollState.isScrollInProgress) return@collect - if (nearStartForOlder && !currentState.isOldestLoaded) { + if (decision.shouldLoadOlder) { if (now - lastOlderLoadTriggerUptimeMs >= loadTriggerThrottleMs) { lastOlderLoadTriggerUptimeMs = now + lastOlderAnchorTriggerId = decision.nextOlderAnchorId component.loadMore() } - } else if (nearEndForNewer && !currentState.isLatestLoaded) { + } else if (decision.shouldLoadNewer) { if (now - lastNewerLoadTriggerUptimeMs >= loadTriggerThrottleMs) { lastNewerLoadTriggerUptimeMs = now + lastNewerAnchorTriggerId = decision.nextNewerAnchorId component.loadNewer() } } } else { - if (nearEnd && !currentState.isOldestLoaded) { + if (decision.shouldLoadOlder) { if (now - lastOlderLoadTriggerUptimeMs >= loadTriggerThrottleMs) { lastOlderLoadTriggerUptimeMs = now + lastOlderAnchorTriggerId = decision.nextOlderAnchorId component.loadMore() } - } else if (nearStartForNewer && !currentState.isAtBottom && !currentState.isLatestLoaded) { + } else if (decision.shouldLoadNewer) { if (now - lastNewerLoadTriggerUptimeMs >= loadTriggerThrottleMs) { lastNewerLoadTriggerUptimeMs = now + lastNewerAnchorTriggerId = decision.nextNewerAnchorId component.loadNewer() } } @@ -408,20 +576,15 @@ internal fun ChatContentList( LaunchedEffect( scrollState, - unreadBoundaryIndex, - isComments, - showNavPadding, - state.isLoadingOlder, - state.isLoadingNewer, - state.isAtBottom, - groupedMessages.isNotEmpty(), + conversationItems, + groupedMessages, state.isViewportSettled ) { - if (!state.isViewportSettled) return@LaunchedEffect - val boundaryIndex = unreadBoundaryIndex ?: return@LaunchedEffect - snapshotFlow { scrollState.layoutInfo.visibleItemsInfo } + if (!state.isViewportSettled || groupedMessages.isEmpty()) return@LaunchedEffect + snapshotFlow { scrollState.layoutInfo.visibleItemsInfo.map(LazyListItemInfo::index) } .filter { it.isNotEmpty() } - .map { visibleItems -> + .distinctUntilChanged() + .collect { visibleLazyIndices -> val leadingItems = chatContentLeadingItemsCount( isComments = isComments, showNavPadding = showNavPadding, @@ -430,17 +593,24 @@ internal fun ChatContentList( isAtBottom = state.isAtBottom, hasMessages = groupedMessages.isNotEmpty() ) - visibleItems.any { item -> - lazyIndexToGroupedIndex(item.index, leadingItems) == boundaryIndex - } - } - .distinctUntilChanged() - .collect { isBoundaryVisible -> - if (isBoundaryVisible) { - hasUnreadSeparatorBeenVisible = true - } else if (hasUnreadSeparatorBeenVisible) { - hasUnreadSeparatorDismissed = true + val visibleGroupedIndices = visibleLazyIndices.mapNotNull { lazyIndex -> + val groupedIndex = lazyIndexToGroupedIndex(lazyIndex, leadingItems) + groupedIndex.takeIf { it in groupedMessages.indices } } + if (visibleGroupedIndices.isEmpty()) return@collect + val firstVisibleGroupedIndex = visibleGroupedIndices.minOrNull() ?: return@collect + val lastVisibleGroupedIndex = visibleGroupedIndices.maxOrNull() ?: return@collect + val nearbyStart = (firstVisibleGroupedIndex - 3).coerceAtLeast(0) + val nearbyEnd = + (lastVisibleGroupedIndex + 3).coerceAtMost(groupedMessages.lastIndex) + val visibleIds = + visibleGroupedIndices.mapTo(linkedSetOf()) { groupedMessages[it].firstMessageId } + val nearbyIds = + (nearbyStart..nearbyEnd).mapTo(linkedSetOf()) { groupedMessages[it].firstMessageId } + component.onMessageViewportChanged( + visibleMessageIds = visibleIds, + nearbyMessageIds = nearbyIds + ) } } @@ -512,38 +682,53 @@ internal fun ChatContentList( } } ) { index, item -> - val olderMsg = remember(groupedMessages, index) { - getMessageAt( - groupedMessages, - index - 1 - ) - } - val newerMsg = remember(groupedMessages, index) { - getMessageAt( - groupedMessages, - index + 1 + val rowState = remember( + groupedMessages, + index, + state.selectedMessageIds, + state.unreadSeparatorCount, + unreadBoundaryIndex, + state.highlightRequest, + visibleGroupedMessageIds, + canReportVisibleMessagesAsRead, + state.rootMessage?.id, + pendingEntryAnimationIds, + isAnyViewerOpen + ) { + MessageRowRenderState( + item = item, + olderMsg = getMessageAt(groupedMessages, index - 1), + newerMsg = getMessageAt(groupedMessages, index + 1), + behavior = state.toBehaviorConfig( + isSelectionMode = state.selectedMessageIds.isNotEmpty(), + isAnyViewerOpen = isAnyViewerOpen + ), + uiFlags = MessageRowUiFlags( + isSelected = isItemSelected(item, state.selectedMessageIds), + showUnreadSeparator = shouldShowUnreadSeparator( + isComments = isComments, + unreadBoundaryIndex = unreadBoundaryIndex, + unreadSeparatorCount = state.unreadSeparatorCount, + itemIndex = index + ), + unreadCount = state.unreadSeparatorCount, + shouldReportPosition = item.lastMessageId == selectedMessageId + ), + highlightRequest = highlightRequestForItem( + item, + state.highlightRequest + ), + isTargetVisibleInViewport = item.firstMessageId in visibleGroupedMessageIds, + canReportVisibleMessagesAsRead = canReportVisibleMessagesAsRead, + rootMessageId = state.rootMessage?.id, + isEntryAnimationPending = pendingEntryAnimationIds.containsKey(item.firstMessageId) ) } MessageRowItem( - item = item, + rowState = rowState, appearance = appearance, component = component, - olderMsg = olderMsg, - newerMsg = newerMsg, - behavior = state.toBehaviorConfig( - isSelectionMode = state.selectedMessageIds.isNotEmpty(), - isAnyViewerOpen = isAnyViewerOpen - ), - uiFlags = MessageRowUiFlags( - isSelected = isItemSelected(item, state.selectedMessageIds), - showUnreadSeparator = index == unreadBoundaryIndex && !hasUnreadSeparatorDismissed, - unreadCount = state.unreadSeparatorCount, - shouldReportPosition = item.lastMessageId == selectedMessageId - ), - highlightRequest = highlightRequestForItem(item, state.highlightRequest), - isTargetVisibleInViewport = item.firstMessageId in visibleGroupedMessageIds, - rootMessageId = state.rootMessage?.id, onPhotoClick = onPhotoClick, onPhotoDownload = onPhotoDownload, onVideoClick = onVideoClick, @@ -561,7 +746,6 @@ internal fun ChatContentList( onLinkPreviewAction = onLinkPreviewAction, isChatAnimationsEnabled = state.isChatAnimationsEnabled, isScrolling = isScrolling, - isEntryAnimationPending = pendingEntryAnimationIds.containsKey(item.firstMessageId), onEntryAnimationConsumed = { pendingEntryAnimationIds.remove(it) }, downloadUtils = downloadUtils ) @@ -579,68 +763,117 @@ internal fun ChatContentList( } } itemsIndexed( - items = groupedMessages, + items = conversationItems, key = { _, item -> item.lazyItemKey }, contentType = { _, item -> when (item) { - is GroupedMessageItem.Single -> "single" - is GroupedMessageItem.Album -> "album" + is ConversationListItem.Grouped -> when (item.groupedMessageItem) { + is GroupedMessageItem.Single -> "single" + is GroupedMessageItem.Album -> "album" + } + + is ConversationListItem.Sponsored -> "sponsored" } } ) { index, item -> - val olderMsg = remember(groupedMessages, index) { - getMessageAt( - groupedMessages, - index + 1 - ) - } - val newerMsg = remember(groupedMessages, index) { - getMessageAt( - groupedMessages, - index - 1 - ) - } + when (item) { + is ConversationListItem.Sponsored -> { + ChannelSponsoredMessageRow( + sponsoredMessage = item.sponsoredMessage, + onCardClick = { + component.onChannelSponsoredMessageClick( + messageId = item.sponsoredMessage.messageId, + url = item.sponsoredMessage.sponsor.url, + isMediaClick = false + ) + }, + onMediaClick = { + component.onChannelSponsoredMessageClick( + messageId = item.sponsoredMessage.messageId, + url = item.sponsoredMessage.sponsor.url, + isMediaClick = true + ) + } + ) + } - MessageRowItem( - item = item, - appearance = appearance, - component = component, - olderMsg = olderMsg, - newerMsg = newerMsg, - behavior = state.toBehaviorConfig( - isSelectionMode = state.selectedMessageIds.isNotEmpty(), - isAnyViewerOpen = isAnyViewerOpen - ), - uiFlags = MessageRowUiFlags( - isSelected = isItemSelected(item, state.selectedMessageIds), - showUnreadSeparator = index == unreadBoundaryIndex && !hasUnreadSeparatorDismissed, - unreadCount = state.unreadSeparatorCount, - shouldReportPosition = item.lastMessageId == selectedMessageId - ), - highlightRequest = highlightRequestForItem(item, state.highlightRequest), - isTargetVisibleInViewport = item.firstMessageId in visibleGroupedMessageIds, - rootMessageId = state.rootMessage?.id, - onPhotoClick = onPhotoClick, - onPhotoDownload = onPhotoDownload, - onVideoClick = onVideoClick, - onDocumentClick = onDocumentClick, - onAudioClick = onAudioClick, - onMessageOptionsClick = onMessageOptionsClick, - onGoToReply = onGoToReply, - onMessagePositionChange = onMessagePositionChange, - onViaBotClick = onViaBotClick, - toProfile = toProfile, - onForwardOriginClick = onForwardOriginClick, - onChecklistTaskToggle = onChecklistTaskToggle, - onChecklistEdit = onChecklistEdit, - onPaidMediaBuy = onPaidMediaBuy, - onLinkPreviewAction = onLinkPreviewAction, - isChatAnimationsEnabled = state.isChatAnimationsEnabled, - isScrolling = isScrolling, - isEntryAnimationPending = pendingEntryAnimationIds.containsKey(item.firstMessageId), - onEntryAnimationConsumed = { pendingEntryAnimationIds.remove(it) }, - downloadUtils = downloadUtils - ) + is ConversationListItem.Grouped -> { + val groupedItem = item.groupedMessageItem + val groupedIndex = item.groupedIndex + val rowState = remember( + groupedMessages, + groupedIndex, + state.selectedMessageIds, + state.unreadSeparatorCount, + unreadBoundaryIndex, + state.highlightRequest, + visibleGroupedMessageIds, + canReportVisibleMessagesAsRead, + state.rootMessage?.id, + pendingEntryAnimationIds, + isAnyViewerOpen + ) { + MessageRowRenderState( + item = groupedItem, + olderMsg = getMessageAt(groupedMessages, groupedIndex + 1), + newerMsg = getMessageAt(groupedMessages, groupedIndex - 1), + behavior = state.toBehaviorConfig( + isSelectionMode = state.selectedMessageIds.isNotEmpty(), + isAnyViewerOpen = isAnyViewerOpen + ), + uiFlags = MessageRowUiFlags( + isSelected = isItemSelected( + groupedItem, + state.selectedMessageIds + ), + showUnreadSeparator = shouldShowUnreadSeparator( + isComments = isComments, + unreadBoundaryIndex = unreadBoundaryIndex, + unreadSeparatorCount = state.unreadSeparatorCount, + itemIndex = groupedIndex + ), + unreadCount = state.unreadSeparatorCount, + shouldReportPosition = groupedItem.lastMessageId == selectedMessageId + ), + highlightRequest = highlightRequestForItem( + groupedItem, + state.highlightRequest + ), + isTargetVisibleInViewport = groupedItem.firstMessageId in visibleGroupedMessageIds, + canReportVisibleMessagesAsRead = canReportVisibleMessagesAsRead, + rootMessageId = state.rootMessage?.id, + isEntryAnimationPending = pendingEntryAnimationIds.containsKey( + groupedItem.firstMessageId + ) + ) + } + + MessageRowItem( + rowState = rowState, + appearance = appearance, + component = component, + onPhotoClick = onPhotoClick, + onPhotoDownload = onPhotoDownload, + onVideoClick = onVideoClick, + onDocumentClick = onDocumentClick, + onAudioClick = onAudioClick, + onMessageOptionsClick = onMessageOptionsClick, + onGoToReply = onGoToReply, + onMessagePositionChange = onMessagePositionChange, + onViaBotClick = onViaBotClick, + toProfile = toProfile, + onForwardOriginClick = onForwardOriginClick, + onChecklistTaskToggle = onChecklistTaskToggle, + onChecklistEdit = onChecklistEdit, + onPaidMediaBuy = onPaidMediaBuy, + onLinkPreviewAction = onLinkPreviewAction, + isChatAnimationsEnabled = state.isChatAnimationsEnabled, + isScrolling = isScrolling, + onEntryAnimationConsumed = { pendingEntryAnimationIds.remove(it) }, + downloadUtils = downloadUtils + ) + } + } } } @@ -741,16 +974,9 @@ private fun PagingLoadingIndicator() { @Composable private fun MessageRowItem( - item: GroupedMessageItem, + rowState: MessageRowRenderState, appearance: MessageAppearanceConfig, component: ChatComponent, - olderMsg: MessageModel?, - newerMsg: MessageModel?, - behavior: MessageRowBehaviorConfig, - uiFlags: MessageRowUiFlags, - highlightRequest: MessageHighlightRequest?, - isTargetVisibleInViewport: Boolean, - rootMessageId: Long?, onPhotoClick: (MessageModel, List, List, List, Int) -> Unit, onPhotoDownload: (Int) -> Unit, onVideoClick: (MessageModel, String?, String?) -> Unit, @@ -768,10 +994,19 @@ private fun MessageRowItem( onLinkPreviewAction: (LinkPreviewAction) -> Unit, isChatAnimationsEnabled: Boolean, isScrolling: Boolean, - isEntryAnimationPending: Boolean, onEntryAnimationConsumed: (Long) -> Unit, downloadUtils: IDownloadUtils ) { + val item = rowState.item + val olderMsg = rowState.olderMsg + val newerMsg = rowState.newerMsg + val behavior = rowState.behavior + val uiFlags = rowState.uiFlags + val highlightRequest = rowState.highlightRequest + val isTargetVisibleInViewport = rowState.isTargetVisibleInViewport + val canReportVisibleMessagesAsRead = rowState.canReportVisibleMessagesAsRead + val rootMessageId = rowState.rootMessageId + val isEntryAnimationPending = rowState.isEntryAnimationPending val mainMsg = remember(item) { if (item is GroupedMessageItem.Single) item.message else (item as GroupedMessageItem.Album).messages.last() } @@ -800,7 +1035,8 @@ private fun MessageRowItem( val highlightBorderAlpha = remember { Animatable(0f) } val highlightScale = remember { Animatable(1f) } - LaunchedEffect(mainMsg.id) { + LaunchedEffect(mainMsg.id, canReportVisibleMessagesAsRead) { + if (!canReportVisibleMessagesAsRead) return@LaunchedEffect component.onMessageVisible(mainMsg.id) } @@ -1015,11 +1251,13 @@ private fun MessageBubbleSwitcher( }, onDownloadPhoto = onPhotoDownload, onVideoClick = { - if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else handleVideoClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else handleVideoTap( it, + component::onDownloadFile, onVideoClick ) }, + onDownloadVideo = { component.onDownloadFile(it) }, onDocumentClick = { if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else onDocumentClick( it @@ -1067,6 +1305,11 @@ private fun MessageBubbleSwitcher( it ) }, + onDownloadSticker = { fileId -> + if (behavior.isSelectionMode) component.onToggleMessageSelection( + sanitizedItem.message.id + ) else component.onDownloadFile(fileId) + }, onPollOptionClick = { id, opt -> if (behavior.isSelectionMode) component.onToggleMessageSelection(id) else component.onPollOptionClick( id, @@ -1129,11 +1372,13 @@ private fun MessageBubbleSwitcher( }, onDownloadPhoto = onPhotoDownload, onVideoClick = { - if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else handleVideoClick( + if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else handleVideoTap( it, + component::onDownloadFile, onVideoClick ) }, + onDownloadVideo = { component.onDownloadFile(it) }, onDocumentClick = { if (behavior.isSelectionMode) component.onToggleMessageSelection(it.id) else onDocumentClick( it @@ -1181,6 +1426,11 @@ private fun MessageBubbleSwitcher( it ) }, + onDownloadSticker = { fileId -> + if (behavior.isSelectionMode) component.onToggleMessageSelection( + sanitizedItem.message.id + ) else component.onDownloadFile(fileId) + }, onPollOptionClick = { id, opt -> if (behavior.isSelectionMode) component.onToggleMessageSelection(id) else component.onPollOptionClick( id, @@ -1249,6 +1499,7 @@ private fun MessageBubbleSwitcher( it, sanitizedItem.messages, onPhotoClick, + component::onDownloadFile, onVideoClick ) }, @@ -1349,7 +1600,8 @@ private fun RootMessageSection( senderGrouping = senderGrouping, onPhotoClick = { handlePhotoClick(it, onPhotoClick) }, onDownloadPhoto = onPhotoDownload, - onVideoClick = { handleVideoClick(it, onVideoClick) }, + onVideoClick = { handleVideoTap(it, component::onDownloadFile, onVideoClick) }, + onDownloadVideo = { component.onDownloadFile(it) }, onDocumentClick = onDocumentClick, onAudioClick = onAudioClick, onCancelDownload = { component.onCancelDownloadFile(it) }, @@ -1358,6 +1610,7 @@ private fun RootMessageSection( onReactionClick = { id, r -> component.onSendReaction(id, r) }, onReplyMarkupButtonClick = { id, btn -> component.onReplyMarkupButtonClick(id, btn, root.senderId) }, onStickerClick = { component.onStickerClick(it) }, + onDownloadSticker = { component.onDownloadFile(it) }, onPollOptionClick = { id, opt -> component.onPollOptionClick(id, opt) }, onRetractVote = { component.onRetractVote(it) }, onShowVoters = { id, opt -> component.onShowVoters(id, opt) }, @@ -1379,7 +1632,8 @@ private fun RootMessageSection( senderGrouping = senderGrouping, onPhotoClick = { handlePhotoClick(it, onPhotoClick) }, onDownloadPhoto = onPhotoDownload, - onVideoClick = { handleVideoClick(it, onVideoClick) }, + onVideoClick = { handleVideoTap(it, component::onDownloadFile, onVideoClick) }, + onDownloadVideo = { component.onDownloadFile(it) }, onDocumentClick = onDocumentClick, onAudioClick = onAudioClick, onCancelDownload = { component.onCancelDownloadFile(it) }, @@ -1388,6 +1642,7 @@ private fun RootMessageSection( onReactionClick = { id, r -> component.onSendReaction(id, r) }, onReplyMarkupButtonClick = { id, btn -> component.onReplyMarkupButtonClick(id, btn, root.senderId) }, onStickerClick = { component.onStickerClick(it) }, + onDownloadSticker = { component.onDownloadFile(it) }, onPollOptionClick = { id, opt -> component.onPollOptionClick(id, opt) }, onRetractVote = { component.onRetractVote(it) }, onShowVoters = { id, opt -> component.onShowVoters(id, opt) }, @@ -1547,6 +1802,25 @@ private fun handleVideoClick(msg: MessageModel, onVideoClick: (MessageModel, Str } } +private fun handleVideoTap( + msg: MessageModel, + onDownloadVideo: (Int) -> Unit, + onVideoClick: (MessageModel, String?, String?) -> Unit +) { + when (val content = msg.content) { + is MessageContent.Video -> when (resolveVideoTapAction( + content.path, + content.supportsStreaming + )) { + VideoTapAction.Download -> onDownloadVideo(content.fileId) + VideoTapAction.Open -> onVideoClick(msg, content.path, content.caption) + } + + is MessageContent.Gif -> onVideoClick(msg, content.path, content.caption) + else -> {} + } +} + private fun handleAlbumPhotoClick( clickedMsg: MessageModel, messages: List, @@ -1579,6 +1853,7 @@ private fun handleAlbumVideoClick( clickedMsg: MessageModel, messages: List, onPhotoClick: (MessageModel, List, List, List, Int) -> Unit, + onDownloadVideo: (Int) -> Unit, onVideoClick: (MessageModel, String?, String?) -> Unit ) { val videoContent = clickedMsg.content as? MessageContent.Video @@ -1586,7 +1861,16 @@ private fun handleAlbumVideoClick( val supportsStreaming = videoContent?.supportsStreaming ?: false val path = (videoContent?.path ?: gifContent?.path)?.takeIf { it.isNotBlank() && File(it).exists() } val clickedCaption = clickedMsg.mediaCaption() - + + if (videoContent != null && resolveVideoTapAction( + videoContent.path, + supportsStreaming + ) == VideoTapAction.Download + ) { + onDownloadVideo(videoContent.fileId) + return + } + if (path == null && supportsStreaming) { onVideoClick(clickedMsg, null, clickedCaption) return @@ -1657,6 +1941,22 @@ internal fun MessageModel.mediaCaption(): String? { } } +internal enum class VideoTapAction { + Open, + Download +} + +internal fun resolveVideoTapAction( + path: String?, + supportsStreaming: Boolean +): VideoTapAction { + return if (path.isNullOrBlank() && !supportsStreaming) { + VideoTapAction.Download + } else { + VideoTapAction.Open + } +} + private fun GroupedMessageItem.mainTimestamp(): Int { return when (this) { is GroupedMessageItem.Single -> message.date @@ -1665,16 +1965,20 @@ private fun GroupedMessageItem.mainTimestamp(): Int { } private fun List.topAnchoredGroupedItem( - groupedMessages: List, + conversationItems: List, leadingItemsCount: Int, viewportTopOffset: Int ): GroupedMessageItem? { return this .sortedBy(LazyListItemInfo::offset) .mapNotNull { itemInfo -> - val groupedIndex = lazyIndexToGroupedIndex(itemInfo.index, leadingItemsCount) - groupedMessages.getOrNull(groupedIndex)?.let { groupedItem -> - itemInfo to groupedItem + when ( + val item = conversationItems.getOrNull( + lazyIndexToGroupedIndex(itemInfo.index, leadingItemsCount) + ) + ) { + is ConversationListItem.Grouped -> itemInfo to item.groupedMessageItem + else -> null } } .minByOrNull { (itemInfo, _) -> @@ -1686,7 +1990,6 @@ private fun List.topAnchoredGroupedItem( ?.second } - @Composable fun TopicsList( topics: List, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt index e2a42d7b..ee4207a8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt @@ -396,6 +396,7 @@ internal fun calculateAlignmentDelta( internal fun buildViewportSnapshot( scrollState: LazyListState, groupedMessages: List, + conversationItems: List, isComments: Boolean, isLatestLoaded: Boolean, isLoadingOlder: Boolean, @@ -425,12 +426,24 @@ internal fun buildViewportSnapshot( ) val info = scrollState.layoutInfo val anchorItem = info.visibleItemsInfo.firstOrNull { itemInfo -> - val groupedIndex = lazyIndexToGroupedIndex(itemInfo.index, leadingItems) - groupedIndex in groupedMessages.indices + when ( + conversationItems.getOrNull( + lazyIndexToGroupedIndex(itemInfo.index, leadingItems) + ) + ) { + is ConversationListItem.Grouped -> true + else -> false + } } ?: return null - val groupedIndex = lazyIndexToGroupedIndex(anchorItem.index, leadingItems) - val anchorMessageId = groupedMessages.getOrNull(groupedIndex)?.firstMessageId ?: return null + val anchorMessageId = when ( + val item = conversationItems.getOrNull( + lazyIndexToGroupedIndex(anchorItem.index, leadingItems) + ) + ) { + is ConversationListItem.Grouped -> item.groupedMessageItem.firstMessageId + else -> null + } ?: return null return ChatViewportCacheEntry( anchorMessageId = anchorMessageId, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentTopBar.kt index 52d3ea20..f4a6da5d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentTopBar.kt @@ -29,7 +29,6 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.rounded.PushPin import androidx.compose.material.icons.rounded.Report import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -62,9 +61,12 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.TopicModel import org.monogram.domain.models.UserModel import org.monogram.presentation.R -import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ExpressiveDefaults import org.monogram.presentation.core.util.rememberUserStatusText +import org.monogram.presentation.features.chats.common.ChatActionScreenContext +import org.monogram.presentation.features.chats.common.ChatActionState +import org.monogram.presentation.features.chats.common.ChatExitAction +import org.monogram.presentation.features.chats.common.resolveChatActionPolicy import org.monogram.presentation.features.chats.conversation.ChatComponent import org.monogram.presentation.features.chats.conversation.ui.ChatTopBar import org.monogram.presentation.features.chats.conversation.ui.pins.PinnedMessageBar @@ -97,10 +99,34 @@ data class ChatContentTopBarUiState( val isMuted: Boolean, val isSearchActive: Boolean, val searchQuery: String, + val isMember: Boolean, + val canDeleteChat: Boolean, val pinnedMessage: MessageModel?, - val pinnedMessageCount: Int + val pinnedMessageCount: Int, + val isPinnedMessageHidden: Boolean ) +internal fun shouldShowPinnedMessageBar( + topBarState: ChatContentTopBarUiState, + isSelectionMode: Boolean +): Boolean { + return topBarState.pinnedMessage != null && + !topBarState.isPinnedMessageHidden && + !isSelectionMode && + topBarState.rootMessage == null +} + +internal fun shouldShowPinnedMessageAction( + topBarState: ChatContentTopBarUiState, + isSelectionMode: Boolean +): Boolean { + return topBarState.pinnedMessage != null && + topBarState.isPinnedMessageHidden && + !isSelectionMode && + topBarState.rootMessage == null && + !topBarState.isSearchActive +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatContentTopBar( @@ -116,15 +142,32 @@ fun ChatContentTopBar( ) { val localClipboard = LocalClipboard.current val isAdBlockEnabled by component.appPreferences.isAdBlockEnabled.collectAsState() + val componentState by component.state.collectAsState() val isSelectionMode = selectedCount > 0 val isMainChat = topBarState.currentTopicId == null && topBarState.rootMessage == null - val canClearOrDeleteChat = (!topBarState.isGroup && !topBarState.isChannel) || topBarState.isAdmin val otherUserId = topBarState.otherUser?.id - val canReportChat = topBarState.isGroup || topBarState.isChannel || - (otherUserId != null && topBarState.currentUser?.id != otherUserId) + val actionPolicy = resolveChatActionPolicy( + isMainChat = isMainChat, + isGroup = topBarState.isGroup, + isChannel = topBarState.isChannel, + isMember = topBarState.isMember, + canDeleteChat = topBarState.canDeleteChat, + canReport = topBarState.isGroup || topBarState.isChannel || + (otherUserId != null && topBarState.currentUser?.id != otherUserId), + canJoin = (topBarState.isGroup || topBarState.isChannel) && !topBarState.isMember, + canBlockOrUnblock = otherUserId != null && !topBarState.isGroup && !topBarState.isChannel, + canPin = false, + context = ChatActionScreenContext.Chat + ) + val canClearHistory = actionPolicy.canClearHistory + val exitAction = actionPolicy.exitAction + val canReportChat = actionPolicy.canReport + val isActionPending = componentState.actionState is ChatActionState.Pending + val onShowPinnedMessage = if (shouldShowPinnedMessageAction(topBarState, isSelectionMode)) { + component::onShowPinnedMessage + } else null var showDeleteSheet by rememberSaveable { mutableStateOf(false) } - var pendingUnpinMessage by remember { mutableStateOf(null) } val iconButtonShapes = ExpressiveDefaults.iconButtonShapes() if (showDeleteSheet) { @@ -139,20 +182,6 @@ fun ChatContentTopBar( ) } - pendingUnpinMessage?.let { pinnedToUnpin -> - ConfirmationSheet( - icon = Icons.Rounded.PushPin, - title = stringResource(R.string.unpin_message_title), - description = stringResource(R.string.unpin_message_confirmation), - confirmText = stringResource(R.string.action_unpin), - onConfirm = { - component.onUnpinMessage(pinnedToUnpin) - pendingUnpinMessage = null - }, - onDismiss = { pendingUnpinMessage = null } - ) - } - Column( modifier = Modifier.graphicsLayer { alpha = contentAlpha @@ -371,22 +400,24 @@ fun ChatContentTopBar( searchQuery = topBarState.searchQuery, onSearchToggle = component::onSearchToggle, onSearchQueryChange = component::onSearchQueryChange, - onClearHistory = if (isMainChat && canClearOrDeleteChat) component::onClearHistory else null, - onDeleteChat = if (isMainChat && canClearOrDeleteChat) component::onDeleteChat else null, - onReport = if (isMainChat && canReportChat) component::onReport else null, + onClearHistory = if (canClearHistory && !isActionPending) component::onClearHistory else null, + onLeaveChat = if (exitAction == ChatExitAction.Leave && !isActionPending) component::onLeaveChat else null, + onDeleteChat = if (exitAction == ChatExitAction.Delete && !isActionPending) component::onDeleteChat else null, + onReport = if (isMainChat && canReportChat && !isActionPending) component::onReport else null, onCopyLink = if (isMainChat && (topBarState.isGroup || topBarState.isChannel)) { { component.onCopyLink(localClipboard) } } else null, onManageMembers = if (isMainChat && topBarState.isGroup && (topBarState.isAdmin || topBarState.permissions.canInviteUsers)) { { component.onProfileClicked() } } else null, + onShowPinnedMessage = onShowPinnedMessage, showBack = showBack, personalAvatarPath = topBarState.chatPersonalAvatar ) } } - val showPinned = topBarState.pinnedMessage != null && !isSelectionMode && topBarState.rootMessage == null + val showPinned = shouldShowPinnedMessageBar(topBarState, isSelectionMode) AnimatedVisibility( visible = showPinned, enter = expandVertically(), @@ -396,7 +427,7 @@ fun ChatContentTopBar( PinnedMessageBar( message = pinned, count = topBarState.pinnedMessageCount, - onClose = { pendingUnpinMessage = pinned }, + onClose = component::onHidePinnedMessage, onClick = { onPinnedMessageClick(pinned) }, onShowAll = { component.onShowAllPinnedMessages() } ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentUtils.kt index b7faa498..ef9dbf71 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentUtils.kt @@ -2,6 +2,8 @@ package org.monogram.presentation.features.chats.conversation.ui.content import androidx.compose.runtime.Immutable import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.SponsoredMessageModel +import org.monogram.domain.models.SponsoredMessagesFeedModel import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -33,6 +35,29 @@ sealed class GroupedMessageItem { } } +@Immutable +sealed interface ConversationListItem { + val lazyItemKey: String + + @Immutable + data class Grouped( + val groupedIndex: Int, + val groupedMessageItem: GroupedMessageItem + ) : ConversationListItem { + override val lazyItemKey: String + get() = groupedMessageItem.lazyItemKey + } + + @Immutable + data class Sponsored( + val sponsoredIndex: Int, + val sponsoredMessage: SponsoredMessageModel + ) : ConversationListItem { + override val lazyItemKey: String + get() = "channel_sponsored_message_${sponsoredMessage.messageId}" + } +} + fun groupMessagesByAlbum(messages: List): List { if (messages.isEmpty()) return emptyList() @@ -101,3 +126,100 @@ fun shouldShowDate(current: MessageModel, older: MessageModel?): Boolean { return !fmt.format(Date(msgTimestamp)).equals(fmt.format(Date(older.date.toLong() * 1000))) } +internal fun buildConversationListItems( + groupedMessages: List, + sponsoredFeed: SponsoredMessagesFeedModel? +): List { + val sponsoredMessages = sponsoredFeed?.messages.orEmpty() + if (groupedMessages.isEmpty() && sponsoredMessages.isEmpty()) return emptyList() + if (sponsoredMessages.isEmpty()) { + return groupedMessages.mapIndexed { index, item -> + ConversationListItem.Grouped( + groupedIndex = index, + groupedMessageItem = item + ) + } + } + + val messagesBetween = sponsoredFeed?.messagesBetween ?: 0 + if (messagesBetween <= 0) { + return buildList { + sponsoredMessages.forEachIndexed { sponsoredIndex, sponsoredMessage -> + add( + ConversationListItem.Sponsored( + sponsoredIndex = sponsoredIndex, + sponsoredMessage = sponsoredMessage + ) + ) + } + groupedMessages.forEachIndexed { groupedIndex, item -> + add( + ConversationListItem.Grouped( + groupedIndex = groupedIndex, + groupedMessageItem = item + ) + ) + } + } + } + + return buildList { + add( + ConversationListItem.Sponsored( + sponsoredIndex = 0, + sponsoredMessage = sponsoredMessages.first() + ) + ) + + var nextSponsoredIndex = 1 + var messagesSinceLastSponsored = 0 + + groupedMessages.forEachIndexed { groupedIndex, item -> + add( + ConversationListItem.Grouped( + groupedIndex = groupedIndex, + groupedMessageItem = item + ) + ) + messagesSinceLastSponsored += item.messageCount() + + if ( + nextSponsoredIndex < sponsoredMessages.size && + messagesSinceLastSponsored >= messagesBetween + ) { + add( + ConversationListItem.Sponsored( + sponsoredIndex = nextSponsoredIndex, + sponsoredMessage = sponsoredMessages[nextSponsoredIndex] + ) + ) + nextSponsoredIndex += 1 + messagesSinceLastSponsored = 0 + } + } + } +} + +internal fun buildGroupedLazyIndexByFirstMessageId( + conversationItems: List, + leadingItemsCount: Int +): Map { + return buildMap { + conversationItems.forEachIndexed { conversationIndex, item -> + if (item is ConversationListItem.Grouped) { + put( + item.groupedMessageItem.firstMessageId, + conversationIndex + leadingItemsCount + ) + } + } + } +} + +internal fun GroupedMessageItem.messageCount(): Int { + return when (this) { + is GroupedMessageItem.Single -> 1 + is GroupedMessageItem.Album -> messages.size + } +} + diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt index 46e58296..8b20cd35 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt @@ -1,6 +1,7 @@ package org.monogram.presentation.features.chats.conversation.ui.content import android.content.ClipData +import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -13,9 +14,11 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.presentation.R import org.monogram.presentation.features.chats.conversation.ChatComponent import org.monogram.presentation.features.chats.conversation.ui.message.PreviewImageViewerRequest import org.monogram.presentation.features.chats.conversation.ui.message.PreviewVideoViewerRequest @@ -75,6 +78,9 @@ internal fun ChatContentViewersInternal( @Composable internal fun InstantViewOverlay(state: ChatComponent.State, component: ChatComponent) { + val context = LocalContext.current + val openErrorMessage = context.getString(R.string.instant_view_open_error) + AnimatedVisibility( visible = state.instantViewUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), @@ -86,7 +92,11 @@ internal fun InstantViewOverlay(state: ChatComponent.State, component: ChatCompo messageRepository = component.repositoryMessage, fileRepository = component.repositoryMessage, onDismiss = { component.onDismissInstantView() }, - onOpenWebView = { component.onOpenWebView(it) } + onOpenWebView = { component.onOpenWebView(it) }, + onOpenError = { + Toast.makeText(context, openErrorMessage, Toast.LENGTH_SHORT).show() + component.onDismissInstantView() + } ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarComposerSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarComposerSection.kt index 892150fe..8458d789 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarComposerSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarComposerSection.kt @@ -51,6 +51,7 @@ import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R import org.monogram.presentation.features.chats.conversation.ui.message.BotCommandSuggestions import org.monogram.presentation.features.chats.conversation.ui.message.LinkPreviewAction +import org.monogram.presentation.features.share.PendingAttachmentKind import org.monogram.presentation.features.stickers.ui.menu.StickerEmojiMenu @Composable @@ -87,8 +88,7 @@ internal fun ChatInputBarComposerSection( onCancelDocuments: () -> Unit, onAddMedia: () -> Unit, onAddDocuments: () -> Unit, - onMediaOrderChange: (List) -> Unit, - onDocumentOrderChange: (List) -> Unit, + onPendingAttachmentsChange: (List) -> Unit, onMediaClick: (String) -> Unit, onDraftLinkPreviewAction: (LinkPreviewAction) -> Unit, onPasteImages: (List) -> Unit, @@ -170,7 +170,7 @@ internal fun ChatInputBarComposerSection( InputBarSendButtonState( isTextEmpty = rowState.textValue.text.isBlank(), isEditing = rowState.editingMessage != null, - hasPendingAttachments = attachments.pendingMediaPaths.isNotEmpty() || attachments.pendingDocumentPaths.isNotEmpty(), + hasPendingAttachments = attachments.pendingAttachments.isNotEmpty(), isOverCharLimit = rowState.isOverMessageLimit, canWriteText = capabilities.canWriteText, canSendAttachments = canSendAttachments, @@ -206,6 +206,7 @@ internal fun ChatInputBarComposerSection( isDraftLinkPreviewLoading = isDraftLinkPreviewLoading, draftLinkPreviewError = draftLinkPreviewError, isDraftLinkPreviewDisabledForSend = isDraftLinkPreviewDisabledForSend, + pendingAttachments = attachments.pendingAttachments, pendingMediaPaths = attachments.pendingMediaPaths, pendingDocumentPaths = attachments.pendingDocumentPaths, onCancelEdit = onCancelEdit, @@ -217,8 +218,7 @@ internal fun ChatInputBarComposerSection( onCancelDocuments = onCancelDocuments, onAddMedia = onAddMedia, onAddDocuments = onAddDocuments, - onMediaOrderChange = onMediaOrderChange, - onDocumentOrderChange = onDocumentOrderChange, + onPendingAttachmentsChange = onPendingAttachmentsChange, onMediaClick = onMediaClick ) @@ -297,7 +297,7 @@ internal fun ChatInputBarComposerSection( ComposerSendOptionsPopup( expanded = rowState.showSendOptionsSheet, scheduledMessagesCount = attachments.scheduledMessagesCount, - showSendAsDocument = attachments.pendingMediaPaths.isNotEmpty(), + showSendAsDocument = attachments.pendingAttachments.any { it.kind != PendingAttachmentKind.DOCUMENT }, onDismiss = onDismissSendOptions, onSendAsDocument = onSendAsDocument, onSendSilent = onSendSilent, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarContract.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarContract.kt index b8b9d511..cadc349d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarContract.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarContract.kt @@ -19,6 +19,7 @@ import org.monogram.domain.models.UserModel import org.monogram.domain.models.WebPage import org.monogram.domain.repository.InlineBotResultsModel import org.monogram.presentation.features.chats.conversation.ui.message.LinkPreviewAction +import org.monogram.presentation.features.share.PendingAttachment @Immutable data class ChatInputBarState( @@ -33,6 +34,7 @@ data class ChatInputBarState( val isDraftLinkPreviewLoading: Boolean = false, val draftLinkPreviewError: String? = null, val isDraftLinkPreviewDisabledForSend: Boolean = false, + val pendingAttachments: List = emptyList(), val pendingMediaPaths: List = emptyList(), val pendingDocumentPaths: List = emptyList(), val isClosed: Boolean = false, @@ -75,10 +77,8 @@ internal data class ChatInputBarActions( val onRestoreDraftLinkPreview: () -> Unit = {}, val onTyping: () -> Unit = {}, val onCancelMedia: () -> Unit = {}, - val onSendMedia: (List, String, List, MessageSendOptions) -> Unit = { _, _, _, _ -> }, - val onSendDocuments: (List, String, List, MessageSendOptions) -> Unit = { _, _, _, _ -> }, - val onMediaOrderChange: (List) -> Unit = {}, - val onDocumentOrderChange: (List) -> Unit = {}, + val onSendAttachments: (List, String, List, MessageSendOptions) -> Unit = { _, _, _, _ -> }, + val onPendingAttachmentsChange: (List) -> Unit = {}, val onMediaClick: (String) -> Unit = {}, val onDraftLinkPreviewAction: (LinkPreviewAction) -> Unit = {}, val onSendPoll: (PollDraft) -> Unit = {}, @@ -115,6 +115,7 @@ internal data class ChatInputBarCapabilities( @Immutable internal data class ComposerAttachmentState( + val pendingAttachments: List = emptyList(), val pendingMediaPaths: List = emptyList(), val pendingDocumentPaths: List = emptyList(), val scheduledMessagesCount: Int = 0 diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarHelpers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarHelpers.kt index 3daff214..351fb2c2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarHelpers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/ChatInputBarHelpers.kt @@ -2,7 +2,6 @@ package org.monogram.presentation.features.chats.conversation.ui.inputbar import android.content.Context import android.content.pm.PackageManager -import android.provider.OpenableColumns import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.buildAnnotatedString @@ -15,8 +14,11 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.StickerFormat import org.monogram.domain.models.StickerModel import org.monogram.domain.models.UserModel -import java.io.File -import java.io.FileOutputStream +import org.monogram.presentation.core.util.copyUriToTempDocumentFile +import org.monogram.presentation.core.util.copyUriToTempMediaFile +import org.monogram.presentation.features.share.PendingAttachment +import org.monogram.presentation.features.share.PendingAttachmentKind +import org.monogram.presentation.features.share.inferPendingAttachmentKind internal data class InlineQueryInput( val botUsername: String, @@ -180,54 +182,32 @@ internal fun Context.declaredPermissions(): Set { } internal fun Context.copyUriToTempPath(uri: android.net.Uri): String? { - return try { - if (uri.scheme == "file") return uri.path - val mime = contentResolver.getType(uri).orEmpty() - val ext = when { - mime.contains("video") -> "mp4" - mime.contains("gif") -> "gif" - else -> "jpg" - } - val file = File(cacheDir, "attach_${System.nanoTime()}.$ext") - contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(file).use { output -> input.copyTo(output) } - } ?: return null - file.absolutePath - } catch (_: Exception) { - null - } + return copyUriToTempMediaFile(uri)?.localPath } internal fun Context.copyUriToTempDocumentPath(uri: android.net.Uri): String? { - return try { - if (uri.scheme == "file") return uri.path - - val displayName = - contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) - ?.use { cursor -> - if (cursor.moveToFirst()) { - val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (index >= 0) cursor.getString(index) else null - } else { - null - } - } - ?.trim() - .orEmpty() - - val mime = contentResolver.getType(uri).orEmpty() - val safeName = when { - displayName.isNotBlank() -> displayName.replace(Regex("[\\\\/:*?\"<>|]"), "_") - mime.startsWith("application/pdf") -> "document_${System.nanoTime()}.pdf" - else -> "document_${System.nanoTime()}.bin" - } - val tempDir = File(cacheDir, "doc_${System.nanoTime()}").apply { mkdirs() } - val file = File(tempDir, safeName) - contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(file).use { output -> input.copyTo(output) } - } ?: return null - file.absolutePath - } catch (_: Exception) { - null + return copyUriToTempDocumentFile(uri)?.localPath +} + +internal fun Context.copyUriToPendingAttachment(uri: android.net.Uri): PendingAttachment? { + val media = copyUriToTempMediaFile(uri) + if (media != null) { + val path = media.localPath + return PendingAttachment( + localPath = path, + kind = inferPendingAttachmentKind( + localPath = path, + mimeType = media.mimeType + ), + deleteAfterUse = true + ) + } + + return copyUriToTempDocumentFile(uri)?.localPath?.let { path -> + PendingAttachment( + localPath = path, + kind = PendingAttachmentKind.DOCUMENT, + deleteAfterUse = true + ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputPreviewSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputPreviewSection.kt index fe09a147..969f8c89 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputPreviewSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/inputbar/InputPreviewSection.kt @@ -83,6 +83,8 @@ import org.monogram.presentation.features.chats.conversation.ui.message.LinkPrev import org.monogram.presentation.features.chats.conversation.ui.message.buildAnnotatedMessageTextWithEmoji import org.monogram.presentation.features.chats.conversation.ui.message.rememberMessageInlineContent import org.monogram.presentation.features.chats.conversation.ui.message.resolveLinkPreview +import org.monogram.presentation.features.share.PendingAttachment +import org.monogram.presentation.features.share.PendingAttachmentKind import java.io.File import java.util.Collections @@ -90,7 +92,7 @@ sealed class InputPreviewState { object None : InputPreviewState() data class Reply(val message: MessageModel) : InputPreviewState() data class Edit(val message: MessageModel) : InputPreviewState() - data class Media(val paths: List) : InputPreviewState() + data class Attachments(val attachments: List) : InputPreviewState() data class Documents(val paths: List) : InputPreviewState() } @@ -104,6 +106,7 @@ internal fun InputPreviewSection( isDraftLinkPreviewLoading: Boolean, draftLinkPreviewError: String?, isDraftLinkPreviewDisabledForSend: Boolean, + pendingAttachments: List, pendingMediaPaths: List, pendingDocumentPaths: List, onCancelEdit: () -> Unit, @@ -115,15 +118,20 @@ internal fun InputPreviewSection( onCancelDocuments: () -> Unit, onAddMedia: () -> Unit, onAddDocuments: () -> Unit, - onMediaOrderChange: (List) -> Unit, - onDocumentOrderChange: (List) -> Unit, + onPendingAttachmentsChange: (List) -> Unit, onMediaClick: (String) -> Unit, onDraftLinkPreviewAction: (LinkPreviewAction) -> Unit = {} ) { val previewState = - remember(editingMessage, replyMessage, pendingMediaPaths, pendingDocumentPaths) { + remember( + editingMessage, + replyMessage, + pendingAttachments, + pendingMediaPaths, + pendingDocumentPaths + ) { when { - pendingMediaPaths.isNotEmpty() -> InputPreviewState.Media(pendingMediaPaths) + pendingAttachments.isNotEmpty() -> InputPreviewState.Attachments(pendingAttachments) pendingDocumentPaths.isNotEmpty() -> InputPreviewState.Documents(pendingDocumentPaths) editingMessage != null -> InputPreviewState.Edit(editingMessage) replyMessage != null -> InputPreviewState.Reply(replyMessage) @@ -145,19 +153,18 @@ internal fun InputPreviewSection( when (state) { is InputPreviewState.Edit -> EditPreview(message = state.message, onCancel = onCancelEdit) is InputPreviewState.Reply -> ReplyPreview(message = state.message, onCancel = onCancelReply) - is InputPreviewState.Media -> MediaPreview( - paths = state.paths, + is InputPreviewState.Attachments -> AttachmentPreview( + attachments = state.attachments, onCancel = onCancelMedia, onAdd = onAddMedia, + onAddDocuments = onAddDocuments, onRemove = { path -> - val newList = pendingMediaPaths.toMutableList() - newList.remove(path) - onMediaOrderChange(newList) + onPendingAttachmentsChange(pendingAttachments.filterNot { it.localPath == path }) }, onMove = { from, to -> - val newList = pendingMediaPaths.toMutableList() + val newList = pendingAttachments.toMutableList() Collections.swap(newList, from, to) - onMediaOrderChange(newList) + onPendingAttachmentsChange(newList) }, onMediaClick = onMediaClick ) @@ -168,7 +175,12 @@ internal fun InputPreviewSection( onRemove = { path -> val newList = pendingDocumentPaths.toMutableList() newList.remove(path) - onDocumentOrderChange(newList) + onPendingAttachmentsChange(newList.map { + PendingAttachment( + it, + PendingAttachmentKind.DOCUMENT + ) + }) } ) @@ -981,10 +993,11 @@ private fun MessageContent.toPreviewContent( @OptIn(ExperimentalFoundationApi::class) @Composable -private fun MediaPreview( - paths: List, +private fun AttachmentPreview( + attachments: List, onCancel: () -> Unit, onAdd: () -> Unit, + onAddDocuments: () -> Unit, onRemove: (String) -> Unit, onMove: (Int, Int) -> Unit, onMediaClick: (String) -> Unit = {} @@ -993,6 +1006,10 @@ private fun MediaPreview( var dragOffset by remember { mutableStateOf(0f) } val haptic = LocalHapticFeedback.current val density = LocalDensity.current + val hasDocuments = + remember(attachments) { attachments.any { it.kind == PendingAttachmentKind.DOCUMENT } } + val hasVisualMedia = + remember(attachments) { attachments.any { it.kind != PendingAttachmentKind.DOCUMENT } } Column( modifier = Modifier @@ -1007,7 +1024,11 @@ private fun MediaPreview( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = if (paths.size > 1) stringResource(R.string.action_send_items_count, paths.size) else stringResource(R.string.action_send_media), + text = if (attachments.size > 1) { + stringResource(R.string.action_send_items_count, attachments.size) + } else { + stringResource(R.string.action_send_media) + }, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 4.dp) @@ -1022,6 +1043,17 @@ private fun MediaPreview( Spacer(modifier = Modifier.width(4.dp)) Text(text = stringResource(R.string.action_add)) } + if (hasVisualMedia && !hasDocuments) { + TextButton(onClick = onAddDocuments) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(text = stringResource(R.string.action_add)) + } + } IconButton(onClick = onCancel, modifier = Modifier.size(24.dp)) { Icon( imageVector = Icons.Default.Close, @@ -1041,8 +1073,8 @@ private fun MediaPreview( modifier = Modifier.fillMaxWidth() ) { itemsIndexed( - paths, - key = { index, path -> "media_preview_${path}_$index" }) { index, path -> + attachments, + key = { index, attachment -> "attachment_preview_${attachment.localPath}_$index" }) { index, attachment -> val isDragging = draggingIndex == index val scale by animateFloatAsState( targetValue = if (isDragging) 1.1f else 1f, @@ -1055,7 +1087,7 @@ private fun MediaPreview( .size(80.dp) .scale(scale) .zIndex(if (isDragging) 1f else 0f) - .pointerInput(paths) { + .pointerInput(attachments) { detectDragGesturesAfterLongPress( onDragStart = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) @@ -1073,7 +1105,7 @@ private fun MediaPreview( else -> index } - if (targetIndex in paths.indices && targetIndex != index) { + if (targetIndex in attachments.indices && targetIndex != index) { onMove(index, targetIndex) draggingIndex = targetIndex dragOffset = 0f @@ -1091,24 +1123,48 @@ private fun MediaPreview( } .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { onMediaClick(path) } + .clickable(enabled = attachment.kind != PendingAttachmentKind.DOCUMENT) { + onMediaClick(attachment.localPath) + } ) { - AsyncImage( - model = File(path), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - - if (path.endsWith(".mp4")) { - Icon( - imageVector = Icons.Default.PlayCircle, - contentDescription = null, - tint = Color.White, + if (attachment.kind == PendingAttachmentKind.DOCUMENT) { + Column( modifier = Modifier - .align(Alignment.Center) - .size(24.dp) + .fillMaxSize() + .padding(8.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = File(attachment.localPath).name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + Text( + text = formatFileSize(File(attachment.localPath).length()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + AsyncImage( + model = File(attachment.localPath), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) + + if (attachment.kind == PendingAttachmentKind.VIDEO || attachment.kind == PendingAttachmentKind.GIF) { + Icon( + imageVector = Icons.Default.PlayCircle, + contentDescription = null, + tint = Color.White, + modifier = Modifier + .align(Alignment.Center) + .size(24.dp) + ) + } } Box( @@ -1117,7 +1173,7 @@ private fun MediaPreview( .padding(4.dp) .size(20.dp) .background(Color.Black.copy(alpha = 0.6f), CircleShape) - .clickable { onRemove(path) }, + .clickable { onRemove(attachment.localPath) }, contentAlignment = Alignment.Center ) { Icon( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt index 8480ea82..97b1b5ed 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt @@ -52,6 +52,7 @@ fun ChatAlbumMessageBubble( onPhotoClick: (MessageModel) -> Unit, onDownloadPhoto: (Int) -> Unit = {}, onVideoClick: (MessageModel) -> Unit, + onDownloadVideo: (Int) -> Unit = {}, onDocumentClick: (MessageModel) -> Unit = {}, onAudioClick: (MessageModel) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, @@ -217,6 +218,7 @@ fun ChatAlbumMessageBubble( onPhotoClick = onPhotoClick, onDownloadPhoto = onDownloadPhoto, onVideoClick = onVideoClick, + onDownloadVideo = onDownloadVideo, onCancelDownload = onCancelDownload, onLongClick = { tappedMessage, offset -> if (onMessageLongPress != null) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt index 2572b6cb..428b99c3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt @@ -7,18 +7,16 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -61,12 +59,12 @@ import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.R +import org.monogram.presentation.core.media.VideoStickerPlayer +import org.monogram.presentation.core.media.VideoType import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression -import org.monogram.presentation.core.media.VideoStickerPlayer -import org.monogram.presentation.core.media.VideoType @OptIn(UnstableApi::class, ExperimentalMaterial3ExpressiveApi::class) @kotlin.OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -154,19 +152,24 @@ fun GifMessageBubble( var gifPosition by remember { mutableStateOf(Offset.Zero) } val revealedSpoilers = remember { mutableStateListOf() } var isMediaSpoilerRevealed by remember { mutableStateOf(!content.hasSpoiler) } + val mediaRatio = remember(content.width, content.height) { + resolveMediaBubbleAspectRatio(content.width, content.height) + } + + BoxWithConstraints(modifier = modifier) { + val bubbleWidth = resolveMediaBubbleWidth(maxWidth) - Column( - modifier = modifier.width(IntrinsicSize.Max), - horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start - ) { - Surface( - shape = bubbleShape, - color = if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start ) { - Column(modifier = Modifier - .widthIn(min = 160.dp, max = 320.dp) + Surface( + modifier = Modifier.width(bubbleWidth), + shape = bubbleShape, + color = if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, ) { + Column(modifier = Modifier.fillMaxWidth()) { msg.forwardInfo?.let { forward -> Box( modifier = Modifier @@ -194,22 +197,21 @@ fun GifMessageBubble( } } - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 160.dp, max = 360.dp) - .aspectRatio( - if (content.width > 0 && content.height > 0) - (content.width.toFloat() / content.height.toFloat()).coerceIn( - 0.5f, - 2f - ) - else 1f + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val mediaHeight = resolveMediaBubbleHeight( + containerWidth = bubbleWidth, + aspectRatio = mediaRatio, + maxHeight = 360.dp ) - .clipToBounds() - .onGloballyPositioned { gifPosition = it.positionInWindow() } - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(mediaHeight) + .clipToBounds() + .onGloballyPositioned { gifPosition = it.positionInWindow() } + + ) { if (!stablePath.isNullOrBlank()) { if (autoplayGifs) { VideoStickerPlayer( @@ -388,6 +390,7 @@ fun GifMessageBubble( } } } + } if (content.caption.isNotEmpty()) { val timeColor = @@ -471,14 +474,15 @@ fun GifMessageBubble( } } } - } + } - if (showReactions) { - MessageReactionsView( - reactions = msg.reactions, - onReactionClick = onReactionClick, - modifier = Modifier.padding(horizontal = 4.dp) - ) + if (showReactions) { + MessageReactionsView( + reactions = msg.reactions, + onReactionClick = onReactionClick, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaBubbleSizing.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaBubbleSizing.kt new file mode 100644 index 00000000..3dbd2490 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaBubbleSizing.kt @@ -0,0 +1,40 @@ +package org.monogram.presentation.features.chats.conversation.ui.message + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.monogram.domain.models.MessageContent + +private const val DEFAULT_MEDIA_ASPECT_RATIO = 1f +private const val MIN_MEDIA_ASPECT_RATIO = 0.5f +private const val MAX_MEDIA_ASPECT_RATIO = 2f +private const val MEDIA_BUBBLE_WIDTH_FRACTION = 0.92f + +internal fun MessageContent.prefersExpandedBubbleWidth(): Boolean = + this is MessageContent.Photo || this is MessageContent.Video || this is MessageContent.Gif + +internal fun resolveMediaBubbleAspectRatio( + mediaWidth: Int, + mediaHeight: Int +): Float { + if (mediaWidth <= 0 || mediaHeight <= 0) return DEFAULT_MEDIA_ASPECT_RATIO + return (mediaWidth.toFloat() / mediaHeight.toFloat()).coerceIn( + MIN_MEDIA_ASPECT_RATIO, + MAX_MEDIA_ASPECT_RATIO + ) +} + +internal fun resolveMediaBubbleHeight( + containerWidth: Dp, + aspectRatio: Float, + minHeight: Dp = 160.dp, + maxHeight: Dp = 320.dp +): Dp { + val safeAspectRatio = aspectRatio.takeIf { it > 0f } ?: DEFAULT_MEDIA_ASPECT_RATIO + return (resolveMediaBubbleWidth(containerWidth) / safeAspectRatio).coerceIn( + minHeight, + maxHeight + ) +} + +internal fun resolveMediaBubbleWidth(containerWidth: Dp): Dp = + containerWidth * MEDIA_BUBBLE_WIDTH_FRACTION diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageReactionsView.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageReactionsView.kt index c20d824c..886a0124 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageReactionsView.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageReactionsView.kt @@ -40,7 +40,7 @@ fun MessageReactionsView( onReactionClick: (String) -> Unit, modifier: Modifier = Modifier, emojiFontFamily: FontFamily = LocalMessageRenderDependencies.current.emojiFontFamily, - customEmojiPathsById: Map = LocalMessageRenderDependencies.current.customEmojiPaths + customEmojiPathsById: Map = rememberReactionCustomEmojiPaths(reactions) ) { if (reactions.isEmpty()) return @@ -63,6 +63,19 @@ fun MessageReactionsView( } } +@Composable +private fun rememberReactionCustomEmojiPaths( + reactions: List +): Map { + val dependencies = LocalMessageRenderDependencies.current + val requests = remember(reactions) { collectCustomEmojiRequests(reactions = reactions) } + val resolved by dependencies.customEmojiResolver.resolve( + customEmojiIds = requests.ids, + explicitPaths = requests.explicitPaths + ) + return resolved +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun MessageReactionItem( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageRenderDependencies.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageRenderDependencies.kt index 8caea365..f89c21cb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageRenderDependencies.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageRenderDependencies.kt @@ -12,13 +12,13 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.compose.koinInject -import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageEntityType -import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageReactionModel import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.coRunCatching @@ -26,16 +26,33 @@ import org.monogram.presentation.core.util.coRunCatching @Immutable data class MessageRenderDependencies( val emojiFontFamily: FontFamily = FontFamily.Default, - val customEmojiPaths: Map = emptyMap() + val customEmojiResolver: CustomEmojiPathResolver = EmptyCustomEmojiPathResolver ) +fun interface CustomEmojiPathResolver { + @Composable + fun resolve( + customEmojiIds: Set, + explicitPaths: Map + ): State> +} + +private object EmptyCustomEmojiPathResolver : CustomEmojiPathResolver { + @Composable + override fun resolve( + customEmojiIds: Set, + explicitPaths: Map + ): State> = remember(explicitPaths) { + androidx.compose.runtime.mutableStateOf(explicitPaths) + } +} + internal val LocalMessageRenderDependencies = staticCompositionLocalOf { MessageRenderDependencies() } @Composable internal fun rememberChatMessageRenderDependencies( - messages: List, appPreferences: AppPreferences = koinInject(), stickerRepository: StickerRepository = koinInject() ): State { @@ -60,34 +77,55 @@ internal fun rememberChatMessageRenderDependencies( } } } - val requests = remember(messages) { collectCustomEmojiRequests(messages) } + + val resolver = remember(stickerRepository, customEmojiFileIdsById) { + CustomEmojiPathResolver { customEmojiIds, explicitPaths -> + resolveCustomEmojiPaths( + customEmojiIds = customEmojiIds, + explicitPaths = explicitPaths, + customEmojiFileIdsById = customEmojiFileIdsById, + stickerRepository = stickerRepository + ) + } + } + + return remember(emojiFontFamily, resolver) { + androidx.compose.runtime.mutableStateOf( + MessageRenderDependencies( + emojiFontFamily = emojiFontFamily, + customEmojiResolver = resolver + ) + ) + } +} + +@Composable +private fun resolveCustomEmojiPaths( + customEmojiIds: Set, + explicitPaths: Map, + customEmojiFileIdsById: Map, + stickerRepository: StickerRepository +): State> { + val stableIds = remember(customEmojiIds) { customEmojiIds.toSet() } + val stableExplicitPaths = remember(explicitPaths) { explicitPaths.toMap() } return produceState( - initialValue = MessageRenderDependencies( - emojiFontFamily = emojiFontFamily, - customEmojiPaths = requests.explicitPaths - ), - emojiFontFamily, - requests, + initialValue = stableExplicitPaths, + stableIds, + stableExplicitPaths, customEmojiFileIdsById, stickerRepository ) { - value = MessageRenderDependencies( - emojiFontFamily = emojiFontFamily, - customEmojiPaths = requests.explicitPaths - ) - + value = stableExplicitPaths coroutineScope { - requests.ids.forEach { customEmojiId -> + stableIds.forEach { customEmojiId -> launch { - val resolvedFlow = + val resolvedFlow: Flow = customEmojiFileIdsById[customEmojiId]?.let(stickerRepository::getStickerFile) ?: stickerRepository.getCustomEmojiFile(customEmojiId) resolvedFlow.collectLatest { resolvedPath -> - val nextPath = resolvedPath ?: requests.explicitPaths[customEmojiId] - if (value.customEmojiPaths[customEmojiId] != nextPath) { - value = value.copy( - customEmojiPaths = value.customEmojiPaths + (customEmojiId to nextPath) - ) + val nextPath = resolvedPath ?: stableExplicitPaths[customEmojiId] + if (value[customEmojiId] != nextPath) { + value = value + (customEmojiId to nextPath) } } } @@ -96,48 +134,33 @@ internal fun rememberChatMessageRenderDependencies( } } -@Immutable -private data class CustomEmojiRequests( +internal data class CustomEmojiRequests( val ids: Set, val explicitPaths: Map ) -private fun collectCustomEmojiRequests(messages: List): CustomEmojiRequests { +internal fun collectCustomEmojiRequests( + entities: List = emptyList(), + reactions: List = emptyList() +): CustomEmojiRequests { val ids = LinkedHashSet() val explicitPaths = LinkedHashMap() - fun appendEntities(entities: List) { - entities.forEach { entity -> - val type = entity.type as? MessageEntityType.CustomEmoji ?: return@forEach - ids += type.emojiId - if (type.path != null) { - explicitPaths[type.emojiId] = type.path - } + entities.forEach { entity -> + val type = entity.type as? MessageEntityType.CustomEmoji ?: return@forEach + ids += type.emojiId + if (type.path != null) { + explicitPaths[type.emojiId] = type.path } } - messages.forEach { message -> - when (val content = message.content) { - is MessageContent.Text -> appendEntities(content.entities) - is MessageContent.Photo -> appendEntities(content.entities) - is MessageContent.Video -> appendEntities(content.entities) - is MessageContent.Document -> appendEntities(content.entities) - is MessageContent.Audio -> appendEntities(content.entities) - is MessageContent.Gif -> appendEntities(content.entities) - else -> Unit - } - - message.reactions.forEach { reaction -> - val customEmojiId = reaction.customEmojiId ?: return@forEach - ids += customEmojiId - if (reaction.customEmojiPath != null) { - explicitPaths[customEmojiId] = reaction.customEmojiPath - } + reactions.forEach { reaction -> + val customEmojiId = reaction.customEmojiId ?: return@forEach + ids += customEmojiId + if (reaction.customEmojiPath != null) { + explicitPaths[customEmojiId] = reaction.customEmojiPath } } - return CustomEmojiRequests( - ids = ids, - explicitPaths = explicitPaths - ) + return CustomEmojiRequests(ids = ids, explicitPaths = explicitPaths) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageTextFormatter.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageTextFormatter.kt index 28dfb7b7..4ee131be 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageTextFormatter.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MessageTextFormatter.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,12 +58,25 @@ private fun rememberResolvedCustomEmojiPaths( } } +@Composable +private fun rememberRowCustomEmojiPaths( + entities: List +): Map { + val dependencies = LocalMessageRenderDependencies.current + val requests = remember(entities) { collectCustomEmojiRequests(entities = entities) } + val resolved by dependencies.customEmojiResolver.resolve( + customEmojiIds = requests.ids, + explicitPaths = requests.explicitPaths + ) + return resolved +} + @Composable fun rememberMessageInlineContent( entities: List, fontSize: Float, isBigEmoji: Boolean = false, - customEmojiPaths: Map = LocalMessageRenderDependencies.current.customEmojiPaths + customEmojiPaths: Map = rememberRowCustomEmojiPaths(entities) ): Map { val emojiEntities = entities.filter { it.type is MessageEntityType.CustomEmoji }.sortedBy { it.offset } @@ -97,7 +111,7 @@ fun rememberMessageTextRenderData( isOutgoing: Boolean = false, revealedSpoilers: List = emptyList(), emojiFontFamily: FontFamily = LocalMessageRenderDependencies.current.emojiFontFamily, - customEmojiPaths: Map = LocalMessageRenderDependencies.current.customEmojiPaths + customEmojiPaths: Map = rememberRowCustomEmojiPaths(entities) ): MessageTextRenderData { val bigEmoji = remember(text, entities, allowBigEmoji) { allowBigEmoji && isBigEmoji(text, entities) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt index 174c98c4..912ddd78 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt @@ -3,15 +3,13 @@ package org.monogram.presentation.features.chats.conversation.ui.message import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download @@ -129,19 +127,24 @@ fun PhotoMessageBubble( var imagePosition by remember { mutableStateOf(Offset.Zero) } val revealedSpoilers = remember { mutableStateListOf() } var isMediaSpoilerRevealed by remember { mutableStateOf(!content.hasSpoiler) } + val mediaRatio = remember(content.width, content.height) { + resolveMediaBubbleAspectRatio(content.width, content.height) + } + + BoxWithConstraints(modifier = modifier) { + val bubbleWidth = resolveMediaBubbleWidth(maxWidth) - Column( - modifier = modifier.width(IntrinsicSize.Max), - horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start - ) { - Surface( - shape = bubbleShape, - color = if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start ) { - Column(modifier = Modifier - .widthIn(max = 280.dp) + Surface( + modifier = Modifier.width(bubbleWidth), + shape = bubbleShape, + color = if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, ) { + Column(modifier = Modifier.fillMaxWidth()) { if (isGroup && !isOutgoing && !isSameSenderAbove) { Box( modifier = Modifier @@ -181,41 +184,36 @@ fun PhotoMessageBubble( } } - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 160.dp, max = 320.dp) - .aspectRatio( - if (content.width > 0 && content.height > 0) - (content.width.toFloat() / content.height.toFloat()).coerceIn( - 0.5f, - 2f - ) - else 1f - ) - .clipToBounds() - .onGloballyPositioned { imagePosition = it.positionInWindow() } - .pointerInput(Unit) { - detectTapGestures( - onTap = { - if (content.hasSpoiler) { - isMediaSpoilerRevealed = !isMediaSpoilerRevealed - } else if (content.isDownloading) { - AutoDownloadSuppression.suppress(content.fileId) - onCancelDownload(content.fileId) - } else { - AutoDownloadSuppression.clear(content.fileId) - if (hasPath) { - onPhotoClick(msg) - } else { - onDownloadPhoto(content.fileId) - } - } - }, - onLongPress = { offset -> onLongClick(imagePosition + offset) } - ) - } - ) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val mediaHeight = resolveMediaBubbleHeight(bubbleWidth, mediaRatio) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(mediaHeight) + .clipToBounds() + .onGloballyPositioned { imagePosition = it.positionInWindow() } + .pointerInput(Unit) { + detectTapGestures( + onTap = { + if (content.hasSpoiler) { + isMediaSpoilerRevealed = !isMediaSpoilerRevealed + } else if (content.isDownloading) { + AutoDownloadSuppression.suppress(content.fileId) + onCancelDownload(content.fileId) + } else { + AutoDownloadSuppression.clear(content.fileId) + if (hasPath) { + onPhotoClick(msg) + } else { + onDownloadPhoto(content.fileId) + } + } + }, + onLongPress = { offset -> onLongClick(imagePosition + offset) } + ) + } + ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -308,6 +306,7 @@ fun PhotoMessageBubble( } } } + } if (content.caption.isNotEmpty()) { val timeColor = if (isOutgoing) @@ -367,14 +366,15 @@ fun PhotoMessageBubble( } } } - } + } - if (showReactions) { - MessageReactionsView( - reactions = msg.reactions, - onReactionClick = onReactionClick, - modifier = Modifier.padding(horizontal = 4.dp) - ) + if (showReactions) { + MessageReactionsView( + reactions = msg.reactions, + onReactionClick = onReactionClick, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/RichMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/RichMessageBubble.kt index ed6e0314..1c7ba004 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/RichMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/RichMessageBubble.kt @@ -13,12 +13,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -26,16 +22,13 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch -import org.koin.compose.koinInject import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.WebPage import org.monogram.domain.models.webapp.PageBlock import org.monogram.domain.repository.FileRepository -import org.monogram.domain.repository.MessageRepository import org.monogram.presentation.R import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.instantview.InstantViewBlock @@ -64,53 +57,11 @@ internal fun RichMessageBubble( onForwardOriginClick: (ForwardInfo) -> Unit = {}, modifier: Modifier = Modifier ) { - val messageRepository: MessageRepository = koinInject() - val fileRepository: FileRepository = koinInject() - val dateFormatManager: DateFormatManager = koinInject() + val fileRepository: FileRepository = org.koin.compose.koinInject() + val dateFormatManager: DateFormatManager = org.koin.compose.koinInject() val uriHandler = LocalUriHandler.current val scope = rememberCoroutineScope() - var displayedContent by remember(content.chatId, content.messageId) { - mutableStateOf(content) - } - - fun mergeRichContent( - current: MessageContent.RichMessage, - incoming: MessageContent.RichMessage - ): MessageContent.RichMessage { - if (current.isFull && !incoming.isFull && incoming.blocks.isEmpty()) return current - if (incoming.isFull) return incoming - if (incoming.blocks.isNotEmpty()) return incoming - return current - } - - LaunchedEffect(content) { - displayedContent = mergeRichContent(displayedContent, content) - if (!content.isFull) { - messageRepository.getFullRichMessage(content.chatId, content.messageId)?.let { full -> - displayedContent = mergeRichContent(displayedContent, full) - } - } - } - - LaunchedEffect(content.chatId, content.messageId) { - messageRepository.messageEditedFlow - .filter { it.chatId == content.chatId && it.id == content.messageId } - .collect { updatedMessage -> - val updatedContent = - updatedMessage.content as? MessageContent.RichMessage ?: return@collect - displayedContent = mergeRichContent(displayedContent, updatedContent) - if (!updatedContent.isFull) { - launch { - messageRepository.getFullRichMessage(content.chatId, content.messageId) - ?.let { full -> - displayedContent = mergeRichContent(displayedContent, full) - } - } - } - } - } - fun openRichPhoto(photo: WebPage.Photo, caption: String?, sourceUrl: String) { if (onLinkPreviewAction == null) return scope.launch { @@ -176,6 +127,16 @@ internal fun RichMessageBubble( if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant val timeColor = contentColor.copy(alpha = 0.7f) val timeText = formatTime(msg.date, dateFormatManager.getHourMinuteFormat()) + val renderModels = remember(msg.id, content.blocks) { + content.blocks.mapIndexed { index, block -> + RichBlockRenderModel( + block = block, + sourceUrl = block.richSourceUrl(), + caption = block.richCaptionOrNull(), + stateKeyPrefix = "message:${msg.id}:block:$index" + ) + } + } Column( modifier = modifier @@ -218,23 +179,20 @@ internal fun RichMessageBubble( LocalOnUrlClick provides { url -> uriHandler.openUri(normalizeUrl(url)) }, LocalFileRepository provides fileRepository ) { - if (!displayedContent.isFull && displayedContent.blocks.isEmpty()) { + if (!content.isFull && content.blocks.isEmpty()) { RichMessagePlaceholder() } else { - displayedContent.blocks.forEach { block -> + renderModels.forEach { renderModel -> InstantViewBlock( - block = block, + block = renderModel.block, textSizeMultiplier = (fontSize / 16f).coerceIn(0.75f, 1.5f), + stateKeyPrefix = renderModel.stateKeyPrefix, onOpenPhotoFullscreen = { photo, caption -> openRichPhoto( photo = photo, - caption = caption.renderedTextOrNull(), - sourceUrl = when (block) { - is PageBlock.PhotoBlock -> block.url - is PageBlock.Embedded -> block.url - is PageBlock.EmbeddedPost -> block.url - else -> "" - } + caption = renderModel.caption + ?: caption.renderedTextOrNull(), + sourceUrl = renderModel.sourceUrl ) }, onOpenVideoFullscreen = { path, fileId, supportsStreaming, caption -> @@ -243,12 +201,7 @@ internal fun RichMessageBubble( fileId = fileId, supportsStreaming = supportsStreaming, caption = caption, - sourceUrl = when (block) { - is PageBlock.PhotoBlock -> block.url - is PageBlock.Embedded -> block.url - is PageBlock.EmbeddedPost -> block.url - else -> "" - } + sourceUrl = renderModel.sourceUrl ) } ) @@ -276,6 +229,35 @@ internal fun RichMessageBubble( } } +private data class RichBlockRenderModel( + val block: PageBlock, + val sourceUrl: String, + val caption: String?, + val stateKeyPrefix: String +) + +private fun PageBlock.richSourceUrl(): String = + when (this) { + is PageBlock.PhotoBlock -> url + is PageBlock.Embedded -> url + is PageBlock.EmbeddedPost -> url + else -> "" + } + +private fun PageBlock.richCaptionOrNull(): String? = + when (this) { + is PageBlock.PhotoBlock -> caption.renderedTextOrNull() + is PageBlock.VideoBlock -> caption.renderedTextOrNull() + is PageBlock.AnimationBlock -> caption.renderedTextOrNull() + is PageBlock.AudioBlock -> caption.renderedTextOrNull() + is PageBlock.Embedded -> caption.renderedTextOrNull() + is PageBlock.EmbeddedPost -> caption.renderedTextOrNull() + is PageBlock.Collage -> caption.renderedTextOrNull() + is PageBlock.Slideshow -> caption.renderedTextOrNull() + is PageBlock.MapBlock -> caption.renderedTextOrNull() + else -> null + } + @Composable private fun RichMessagePlaceholder() { Surface( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/StickerMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/StickerMessageBubble.kt index d9ca636e..0b4e91fa 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/StickerMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/StickerMessageBubble.kt @@ -43,6 +43,22 @@ import org.monogram.presentation.features.stickers.ui.view.StickerImage import org.monogram.presentation.features.stickers.ui.view.StickerSkeleton import java.io.File +internal fun resolveStickerTapAction( + path: String?, + pathExists: (String) -> Boolean = { File(it).exists() } +): StickerTapAction { + return if (!path.isNullOrBlank() && pathExists(path)) { + StickerTapAction.OpenSet + } else { + StickerTapAction.Download + } +} + +internal enum class StickerTapAction { + OpenSet, + Download +} + @OptIn(UnstableApi::class, ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @kotlin.OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -54,6 +70,7 @@ fun StickerMessageBubble( onReplyClick: (MessageModel) -> Unit = {}, onReactionClick: (String) -> Unit = {}, onStickerClick: (Long) -> Unit = {}, + onDownloadSticker: (Int) -> Unit = {}, onLongClick: () -> Unit = {}, toProfile: (Long) -> Unit = {}, onForwardOriginClick: (ForwardInfo) -> Unit = {}, @@ -104,7 +121,10 @@ fun StickerMessageBubble( .size(stickerSize.dp) .combinedClickable( onClick = { - onStickerClick(content.setId) + when (resolveStickerTapAction(content.path)) { + StickerTapAction.OpenSet -> onStickerClick(content.setId) + StickerTapAction.Download -> onDownloadSticker(content.fileId) + } }, onLongClick = onLongClick ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt index 41539851..b0459d49 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt @@ -5,14 +5,14 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -85,6 +85,7 @@ fun VideoMessageBubble( autoDownloadRoaming: Boolean, autoplayVideos: Boolean, onVideoClick: (MessageModel) -> Unit, + onDownloadVideo: (Int) -> Unit = {}, modifier: Modifier = Modifier, onCancelDownload: (Int) -> Unit = {}, onLongClick: (Offset) -> Unit, @@ -111,6 +112,7 @@ fun VideoMessageBubble( content.minithumbnail?.let { namespacedCacheKey("chat_video_mini:${content.fileId}", it) } } var isAutoDownloadSuppressed by remember(msg.id, content.fileId) { mutableStateOf(false) } + val onDownloadVideoState by rememberUpdatedState(onDownloadVideo) LaunchedEffect(content.path, content.fileId) { if (!content.path.isNullOrBlank()) { @@ -131,7 +133,7 @@ fun VideoMessageBubble( else -> autoDownloadMobile } if (shouldDownload) { - onVideoClick(msg) + onDownloadVideoState(content.fileId) } } } @@ -164,22 +166,29 @@ fun VideoMessageBubble( val onLongClickState by rememberUpdatedState(onLongClick) val onVideoClickState by rememberUpdatedState(onVideoClick) val onCancelDownloadState by rememberUpdatedState(onCancelDownload) + val mediaRatio = remember(content.width, content.height) { + resolveMediaBubbleAspectRatio(content.width, content.height) + } - Column( + BoxWithConstraints( modifier = modifier.onGloballyPositioned { val rect = it.boundsInWindow() isVisible = rect.bottom > 0 && rect.top < screenHeightPx - }, - horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start + } ) { - Surface( - shape = bubbleShape, - color = if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + val bubbleWidth = resolveMediaBubbleWidth(maxWidth) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start ) { - Column(modifier = Modifier - .widthIn(max = 280.dp) + Surface( + modifier = Modifier.width(bubbleWidth), + shape = bubbleShape, + color = if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, ) { + Column(modifier = Modifier.fillMaxWidth()) { msg.forwardInfo?.let { forward -> Box( modifier = Modifier @@ -207,21 +216,23 @@ fun VideoMessageBubble( } } - val ratio = if (content.width > 0 && content.height > 0) - (content.width.toFloat() / content.height.toFloat()).coerceIn(0.5f, 2f) - else 1f - - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 160.dp, max = 360.dp) - .aspectRatio(ratio) - .clipToBounds() - .onGloballyPositioned { - layoutTracker.videoPosition = it.positionInWindow() - } + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val mediaHeight = resolveMediaBubbleHeight( + containerWidth = bubbleWidth, + aspectRatio = mediaRatio, + maxHeight = 360.dp + ) - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(mediaHeight) + .clipToBounds() + .onGloballyPositioned { + layoutTracker.videoPosition = it.positionInWindow() + } + + ) { if (hasPath || content.supportsStreaming) { if (autoplayVideos) { val videoPath = stablePath ?: "http://streaming/${content.fileId}" @@ -316,7 +327,7 @@ fun VideoMessageBubble( onStartDownload = { isAutoDownloadSuppressed = false AutoDownloadSuppression.clear(content.fileId) - onVideoClickState(msg) + onDownloadVideoState(content.fileId) } ) } @@ -335,7 +346,11 @@ fun VideoMessageBubble( onOpenVideo = { isAutoDownloadSuppressed = false AutoDownloadSuppression.clear(content.fileId) - onVideoClickState(msg) + if (content.path.isNullOrBlank() && !content.supportsStreaming) { + onDownloadVideoState(content.fileId) + } else { + onVideoClickState(msg) + } }, onLongClick = { anchor -> onLongClickState(anchor) } ) @@ -373,6 +388,7 @@ fun VideoMessageBubble( ) } } + } if (content.caption.isNotEmpty()) { val timeColor = if (isOutgoing) @@ -432,14 +448,15 @@ fun VideoMessageBubble( } } } - } + } - if (showReactions) { - MessageReactionsView( - reactions = msg.reactions, - onReactionClick = onReactionClick, - modifier = Modifier.padding(horizontal = 4.dp) - ) + if (showReactions) { + MessageReactionsView( + reactions = msg.reactions, + onReactionClick = onReactionClick, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/pins/PinnedMessageBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/pins/PinnedMessageBar.kt index d913806a..93ec0aeb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/pins/PinnedMessageBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/pins/PinnedMessageBar.kt @@ -24,8 +24,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.FormatListBulleted import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.rounded.FormatListBulleted import androidx.compose.material.icons.rounded.PushPin import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -194,7 +194,7 @@ private fun ShowAllButton( modifier = Modifier.size(40.dp) ) { Icon( - imageVector = Icons.Rounded.FormatListBulleted, + imageVector = Icons.AutoMirrored.Rounded.FormatListBulleted, contentDescription = stringResource(R.string.pinned_show_all), modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt index 9e944f1b..8935dd00 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListComponent.kt @@ -13,6 +13,8 @@ import org.monogram.domain.models.UserModel import org.monogram.domain.repository.ConnectionStatus import org.monogram.domain.repository.ForwardTarget import org.monogram.presentation.core.util.AppPreferences +import org.monogram.presentation.features.chats.common.ChatActionState +import org.monogram.presentation.features.share.ShareTarget interface ChatListComponent { val state: StateFlow @@ -55,6 +57,8 @@ interface ChatListComponent { commentText: String = "", commentEntities: List = emptyList() ) + fun onShareTopicSelected(chatId: Long, topicId: Int?) + fun onDismissShareTopicPicker() fun onForwardTopicSelected(chatId: Long, topicId: Int?) fun onDismissForwardTopicPicker() @@ -75,6 +79,8 @@ interface ChatListComponent { fun onDismissWebView() fun onUpdateClicked() + fun onProjectChannelSubscribe() + fun onProjectChannelLater() fun handleBack(): Boolean @@ -86,7 +92,10 @@ interface ChatListComponent { val chatsByFolder: Map> = emptyMap(), val folders: List = emptyList(), val selectedFolderId: Int = -1, + val lastNonArchiveFolderId: Int? = null, val currentUser: UserModel? = null, + val projectChannelSubscriptionState: ProjectChannelSubscriptionState = ProjectChannelSubscriptionState.UNKNOWN, + val isProjectChannelJoinInProgress: Boolean = false, val isLoadingByFolder: Map = emptyMap(), val selectedChatIds: Set = emptySet(), val selectedForwardTargets: List = emptyList(), @@ -106,6 +115,7 @@ interface ChatListComponent { val isArchivePinned: Boolean = true, val isArchiveAlwaysVisible: Boolean = false, val isForwarding: Boolean = false, + val isShareTargetMode: Boolean = false, val isForwardSubmitInProgress: Boolean = false, val canLoadMoreMessages: Boolean = false, val instantViewUrl: String? = null, @@ -118,7 +128,8 @@ interface ChatListComponent { val webAppBotName: String? = null, val webViewUrl: String? = null, val updateState: UpdateState = UpdateState.Idle, - val scrollPositions: Map> = emptyMap() + val scrollPositions: Map> = emptyMap(), + val actionState: ChatActionState = ChatActionState.Idle ) { val chats: List get() = chatsByFolder[selectedFolderId].orEmpty() val isLoading: Boolean get() = isLoadingByFolder[selectedFolderId] ?: false @@ -127,10 +138,13 @@ interface ChatListComponent { @Immutable data class UiState( val currentUser: UserModel? = null, + val projectChannelSubscriptionState: ProjectChannelSubscriptionState = ProjectChannelSubscriptionState.UNKNOWN, + val isProjectChannelJoinInProgress: Boolean = false, val connectionStatus: ConnectionStatus = ConnectionStatus.Connected, val isArchivePinned: Boolean = true, val isArchiveAlwaysVisible: Boolean = false, val isForwarding: Boolean = false, + val isShareTargetMode: Boolean = false, val isForwardSubmitInProgress: Boolean = false, val instantViewUrl: String? = null, val isProxyEnabled: Boolean = false, @@ -182,6 +196,7 @@ interface ChatListComponent { val forwardTopics: List = emptyList(), val isLoadingForwardTopics: Boolean = false, val isForwardSubmitInProgress: Boolean = false, + val isShareTargetMode: Boolean = false, val allPinned: Boolean = false, val allMuted: Boolean = false, val canMarkUnread: Boolean = false, @@ -199,4 +214,15 @@ interface ChatListComponent { val recentOthers: List = emptyList(), val canLoadMoreMessages: Boolean = false ) + + sealed class ShareResult { + data class Selected(val target: ShareTarget) : ShareResult() + data object Cancelled : ShareResult() + } + + enum class ProjectChannelSubscriptionState { + UNKNOWN, + NOT_SUBSCRIBED, + SUBSCRIBED + } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt index 5f45e49a..416547ce 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt @@ -1,6 +1,7 @@ package org.monogram.presentation.features.chats.list import android.util.Log +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -147,7 +148,10 @@ import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ConfirmationSheet +import org.monogram.presentation.core.ui.ScreenSwipeBackState import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled +import org.monogram.presentation.features.chats.common.ChatActionState +import org.monogram.presentation.features.chats.conversation.ui.content.ReportChatDialog import org.monogram.presentation.features.chats.conversation.ui.message.getEmojiFontFamily import org.monogram.presentation.features.chats.list.components.AccountMenu import org.monogram.presentation.features.chats.list.components.ArchiveHeaderCard @@ -169,13 +173,40 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ChatListContent(component: ChatListComponent) { +fun ChatListContent( + component: ChatListComponent, + previewMode: ChatListPreviewMode = ChatListPreviewMode.Active, + onSwipeBackStateChanged: (ScreenSwipeBackState) -> Unit = {}, +) { val stickerRepository: StickerRepository = koinInject() val uiState by component.uiState.collectAsState() val foldersState by component.foldersState.collectAsState() val chatsState by component.chatsState.collectAsState() val selectionState by component.selectionState.collectAsState() val searchState by component.searchState.collectAsState() + val showAllChatsFolder by component.appPreferences.showAllChatsFolder.collectAsState() + val isProjectChannelPromoDismissed by component.appPreferences.isProjectChannelPromoDismissed.collectAsState() + + val isPreview = previewMode != ChatListPreviewMode.Active + val previewFolderId = (previewMode as? ChatListPreviewMode.FolderPreview)?.folderId + val effectiveSelectedFolderId = previewFolderId ?: foldersState.selectedFolderId + val effectiveSearchActive = if (isPreview) false else searchState.isSearchActive + val effectiveFoldersState = remember(foldersState, effectiveSelectedFolderId) { + foldersState.copy(selectedFolderId = effectiveSelectedFolderId) + } + val effectiveSearchState = remember(searchState, effectiveSearchActive) { + searchState.copy(isSearchActive = effectiveSearchActive) + } + val showProjectChannelPromo = shouldShowProjectChannelPromo( + subscriptionState = uiState.projectChannelSubscriptionState, + isDismissed = isProjectChannelPromoDismissed, + isPreview = isPreview, + isSearchActive = effectiveSearchState.isSearchActive, + selectedFolderId = effectiveFoldersState.selectedFolderId, + isForwarding = uiState.isForwarding, + isShareTargetMode = uiState.isShareTargetMode, + hasSelection = selectionState.selectedChatIds.isNotEmpty() + ) val scope = rememberCoroutineScope() @@ -188,6 +219,7 @@ fun ChatListContent(component: ChatListComponent) { var showDeleteChatsSheet by remember { mutableStateOf(false) } var showLeaveChatSheet by remember { mutableStateOf(false) } var showClearHistorySheet by remember { mutableStateOf(false) } + var showReportSheet by remember { mutableStateOf(false) } var forwardCommentText by remember { mutableStateOf("") } var forwardSendCopy by remember { mutableStateOf(false) } var forwardRemoveCaption by remember { mutableStateOf(false) } @@ -207,6 +239,8 @@ fun ChatListContent(component: ChatListComponent) { val isPermissionRequested by component.appPreferences.isPermissionRequested.collectAsState() var showPermissionRequest by remember { mutableStateOf(!isPermissionRequested) } + val fullState by component.state.collectAsState() + val isActionPending = fullState.actionState is ChatActionState.Pending val adaptiveInfo = currentWindowAdaptiveInfo() val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current @@ -214,11 +248,11 @@ fun ChatListContent(component: ChatListComponent) { adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) && isTabletInterfaceEnabled val isCustomBackHandlingEnabled = - searchState.isSearchActive || + effectiveSearchState.isSearchActive || selectionState.selectedChatIds.isNotEmpty() || selectionState.selectedForwardTargets.isNotEmpty() || selectionState.forwardTopicPickerChatId != null || - foldersState.selectedFolderId == -2 || + effectiveFoldersState.selectedFolderId == -2 || uiState.isForwarding || uiState.instantViewUrl != null || uiState.webAppUrl != null || @@ -227,19 +261,44 @@ fun ChatListContent(component: ChatListComponent) { showChatListActionsMenu || showSelectionActionsMenu - BackHandler(enabled = isCustomBackHandlingEnabled) { - if (showChatListActionsMenu) { - showChatListActionsMenu = false - } else if (showSelectionActionsMenu) { - showSelectionActionsMenu = false - } else if (showStatusMenu) { - showStatusMenu = false - } else { - component.handleBack() + LaunchedEffect( + fullState, + showAllChatsFolder, + showChatListActionsMenu, + showSelectionActionsMenu, + showStatusMenu, + previewMode, + onSwipeBackStateChanged, + ) { + val transientUiActive = + showChatListActionsMenu || showSelectionActionsMenu || showStatusMenu || isPreview + onSwipeBackStateChanged( + resolveChatListSwipeBackState( + state = fullState, + showAllChatsFolder = showAllChatsFolder, + hasTransientBlockingUi = transientUiActive, + ) + ) + } + + DisposableEffect(onSwipeBackStateChanged) { + onDispose { onSwipeBackStateChanged(ScreenSwipeBackState()) } + } + + if (!isPreview) { + BackHandler(enabled = isCustomBackHandlingEnabled) { + if (showChatListActionsMenu) { + showChatListActionsMenu = false + } else if (showSelectionActionsMenu) { + showSelectionActionsMenu = false + } else if (showStatusMenu) { + showStatusMenu = false + } else { + component.handleBack() + } } } - val showAllChatsFolder by component.appPreferences.showAllChatsFolder.collectAsState() val visibleFolders = remember(foldersState.folders, showAllChatsFolder) { val userFolders = foldersState.folders.filter { it.id >= 0 } if (showAllChatsFolder || userFolders.isEmpty()) { @@ -250,21 +309,29 @@ fun ChatListContent(component: ChatListComponent) { } val pagerState = rememberPagerState( - initialPage = visibleFolders.indexOfFirst { it.id == foldersState.selectedFolderId } + initialPage = visibleFolders.indexOfFirst { it.id == effectiveFoldersState.selectedFolderId } .coerceAtLeast(0), pageCount = { visibleFolders.size } ) - LaunchedEffect(foldersState.selectedFolderId, visibleFolders) { + LaunchedEffect(effectiveFoldersState.selectedFolderId, visibleFolders, isPreview) { + if (isPreview) return@LaunchedEffect val index = - visibleFolders.indexOfFirst { it.id == foldersState.selectedFolderId }.coerceAtLeast(0) + visibleFolders.indexOfFirst { it.id == effectiveFoldersState.selectedFolderId } + .coerceAtLeast(0) if (pagerState.currentPage != index) pagerState.animateScrollToPage(index) } - LaunchedEffect(pagerState.currentPage, visibleFolders, foldersState.selectedFolderId) { + LaunchedEffect( + pagerState.currentPage, + visibleFolders, + effectiveFoldersState.selectedFolderId, + isPreview + ) { + if (isPreview) return@LaunchedEffect if (visibleFolders.isNotEmpty() && pagerState.currentPage < visibleFolders.size) { val folderId = visibleFolders[pagerState.currentPage].id - if (foldersState.selectedFolderId != folderId && foldersState.selectedFolderId != -2) { + if (effectiveFoldersState.selectedFolderId != folderId && effectiveFoldersState.selectedFolderId != -2) { component.onFolderClicked(folderId) } } @@ -290,7 +357,8 @@ fun ChatListContent(component: ChatListComponent) { val isArchivePersistent = uiState.isArchivePinned && (uiState.isArchiveAlwaysVisible || isMainFolder) val canShowArchive = isArchivePersistent || isMainFolder - val currentFolderChats = foldersState.chatsByFolder[foldersState.selectedFolderId].orEmpty() + val currentFolderChats = + foldersState.chatsByFolder[effectiveFoldersState.selectedFolderId].orEmpty() val hasUnreadInCurrentFolder = remember(currentFolderChats) { currentFolderChats.any(::hasChatListUnreadState) } @@ -335,11 +403,13 @@ fun ChatListContent(component: ChatListComponent) { } val isArchiveRevealed = archiveRevealPx > 0f && !isArchivePersistent - BackHandler(enabled = isArchiveRevealed) { - revealAnimationJob?.cancel() - revealAnimationJob = scope.launch { - animate(initialValue = archiveRevealPx, targetValue = 0f) { value, _ -> - archiveRevealPx = value + if (!isPreview) { + BackHandler(enabled = isArchiveRevealed) { + revealAnimationJob?.cancel() + revealAnimationJob = scope.launch { + animate(initialValue = archiveRevealPx, targetValue = 0f) { value, _ -> + archiveRevealPx = value + } } } } @@ -521,9 +591,6 @@ fun ChatListContent(component: ChatListComponent) { currentUser?.id?.let { component.onChatClicked(it) } }, onSettingsClick = { component.onSettingsClicked() }, - onHelpClick = { - component.onOpenInstantView("https://telegram.org/faq#general-questions") - }, onProfileClick = { currentUser?.id?.let { component.onProfileClicked(it) } }, @@ -568,24 +635,26 @@ fun ChatListContent(component: ChatListComponent) { val topBarMode = remember( selectionState.selectedChatIds, uiState.isForwarding, - foldersState.selectedFolderId, - searchState.isSearchActive, + uiState.isShareTargetMode, + effectiveFoldersState.selectedFolderId, + effectiveSearchState.isSearchActive, currentUser, uiState.connectionStatus, uiState.isProxyEnabled ) { when { selectionState.selectedChatIds.isNotEmpty() && !uiState.isForwarding -> { - ChatListTopBarMode.Selection(isInArchive = foldersState.selectedFolderId == -2) + ChatListTopBarMode.Selection(isInArchive = effectiveFoldersState.selectedFolderId == -2) } + uiState.isShareTargetMode -> ChatListTopBarMode.ShareTarget uiState.isForwarding -> ChatListTopBarMode.Forwarding - foldersState.selectedFolderId == -2 && !searchState.isSearchActive -> ChatListTopBarMode.Archive + effectiveFoldersState.selectedFolderId == -2 && !effectiveSearchState.isSearchActive -> ChatListTopBarMode.Archive else -> ChatListTopBarMode.Default( user = currentUser, connectionStatus = uiState.connectionStatus, isProxyEnabled = uiState.isProxyEnabled, - isSearchActive = searchState.isSearchActive + isSearchActive = effectiveSearchState.isSearchActive ) } } @@ -613,6 +682,13 @@ fun ChatListContent(component: ChatListComponent) { ForwardingModeTopBar(onBackClick = component::handleBack) } + ChatListTopBarMode.ShareTarget -> { + ForwardingModeTopBar( + title = stringResource(R.string.action_share), + onBackClick = component::handleBack + ) + } + ChatListTopBarMode.Archive -> { ArchiveModeTopBar( onBackClick = component::handleBack, @@ -629,7 +705,7 @@ fun ChatListContent(component: ChatListComponent) { onRetryConnection = { component.retryConnection() }, onProxySettingsClick = { component.onProxySettingsClicked() }, isSearchActive = mode.isSearchActive, - searchQuery = searchState.searchQuery, + searchQuery = effectiveSearchState.searchQuery, onSearchQueryChange = component::onSearchQueryChange, onSearchToggle = component::onSearchToggle, onStatusClick = { anchorBounds -> @@ -655,7 +731,8 @@ fun ChatListContent(component: ChatListComponent) { } } - val isMainView = !searchState.isSearchActive && foldersState.selectedFolderId != -2 + val isMainView = + !effectiveSearchState.isSearchActive && effectiveFoldersState.selectedFolderId != -2 if (isMainView) { Box( @@ -714,8 +791,16 @@ fun ChatListContent(component: ChatListComponent) { ) { ArchiveHeaderCard( isPinned = uiState.isArchivePinned, - onClick = { component.onFolderClicked(-2) }, - onLongClick = { component.onArchivePinToggle() } + onClick = { + if (!isPreview) { + component.onFolderClicked(-2) + } + }, + onLongClick = { + if (!isPreview) { + component.onArchivePinToggle() + } + } ) Spacer(Modifier.height(6.dp)) } @@ -727,6 +812,7 @@ fun ChatListContent(component: ChatListComponent) { folders = visibleFolders, pagerState = pagerState, onTabClick = { index -> + if (isPreview) return@FolderTabs if (pagerState.currentPage == index) { val folderId = visibleFolders[index].id scope.launch { @@ -738,10 +824,18 @@ fun ChatListContent(component: ChatListComponent) { } } }, - onEditClick = { component.onEditFoldersClicked() }, - onEditFolderClick = { folder -> component.onEditFolder(folder.id) }, - onDeleteFolderClick = { folder -> component.onDeleteFolder(folder.id) }, - onReorderFoldersClick = { component.onEditFoldersClicked() } + onEditClick = { if (!isPreview) component.onEditFoldersClicked() }, + onEditFolderClick = { folder -> + if (!isPreview) component.onEditFolder( + folder.id + ) + }, + onDeleteFolderClick = { folder -> + if (!isPreview) component.onDeleteFolder( + folder.id + ) + }, + onReorderFoldersClick = { if (!isPreview) component.onEditFoldersClicked() } ) } } @@ -752,7 +846,11 @@ fun ChatListContent(component: ChatListComponent) { floatingActionButton = { if (!isTablet) { AnimatedVisibility( - visible = !searchState.isSearchActive && foldersState.selectedFolderId != -2 && !uiState.isForwarding, + visible = !effectiveSearchState.isSearchActive && + effectiveFoldersState.selectedFolderId != -2 && + !uiState.isForwarding && + !uiState.isShareTargetMode && + !isPreview, enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut() ) { @@ -778,12 +876,13 @@ fun ChatListContent(component: ChatListComponent) { ) { ChatListBody( component = component, - foldersState = foldersState, + foldersState = effectiveFoldersState, chatsState = chatsState, selectionState = selectionState, selectedForwardChatIds = selectedForwardChatIds, isForwarding = uiState.isForwarding, - searchState = searchState, + isShareTargetMode = uiState.isShareTargetMode, + searchState = effectiveSearchState, visibleFolders = visibleFolders, pagerState = pagerState, scrollStates = scrollStates, @@ -793,6 +892,11 @@ fun ChatListContent(component: ChatListComponent) { emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos, + interactionsEnabled = !isPreview, + showProjectChannelPromo = showProjectChannelPromo, + isProjectChannelJoinInProgress = uiState.isProjectChannelJoinInProgress, + onProjectChannelSubscribe = component::onProjectChannelSubscribe, + onProjectChannelLater = component::onProjectChannelLater, onChatClicked = onChatClicked, onChatLongClicked = onChatLongClicked ) @@ -804,8 +908,16 @@ fun ChatListContent(component: ChatListComponent) { title = selectionState.forwardTopicPickerChatTitle, topics = selectionState.forwardTopics, isLoading = selectionState.isLoadingForwardTopics, - onTopicSelected = component::onForwardTopicSelected, - onDismiss = component::onDismissForwardTopicPicker + onTopicSelected = if (uiState.isShareTargetMode) { + component::onShareTopicSelected + } else { + component::onForwardTopicSelected + }, + onDismiss = if (uiState.isShareTargetMode) { + component::onDismissShareTopicPicker + } else { + component::onDismissForwardTopicPicker + } ) ForwardConfirmationPanel( @@ -944,7 +1056,15 @@ fun ChatListContent(component: ChatListComponent) { messageRepository = koinInject(), fileRepository = koinInject(), onDismiss = { component.onDismissInstantView() }, - onOpenWebView = { component.onOpenWebView(it) } + onOpenWebView = { component.onOpenWebView(it) }, + onOpenError = { + Toast.makeText( + context, + context.getString(R.string.instant_view_open_error), + Toast.LENGTH_SHORT + ).show() + component.onDismissInstantView() + } ) } } @@ -994,7 +1114,6 @@ fun ChatListContent(component: ChatListComponent) { confirmText = stringResource(R.string.action_leave), onConfirm = { component.onLeaveSelected() - showLeaveChatSheet = false }, onDismiss = { showLeaveChatSheet = false } ) @@ -1007,12 +1126,20 @@ fun ChatListContent(component: ChatListComponent) { description = stringResource(R.string.clear_history_confirmation), confirmText = stringResource(R.string.action_clear_history), onConfirm = { - component.onClearHistorySelected(revoke = false) - showClearHistorySheet = false + component.onClearHistorySelected(revoke = true) }, onDismiss = { showClearHistorySheet = false } ) } + if (showReportSheet) { + ReportChatDialog( + onDismiss = { showReportSheet = false }, + onReasonSelected = { reason -> + component.onReportSelected(reason) + showReportSheet = false + } + ) + } ChatListActionsPopup( visible = showChatListActionsMenu, @@ -1033,6 +1160,7 @@ fun ChatListContent(component: ChatListComponent) { visible = showSelectionActionsMenu, selectionState = selectionState, isInArchive = foldersState.selectedFolderId == -2, + isActionPending = isActionPending, onDismiss = { showSelectionActionsMenu = false }, onPin = { showSelectionActionsMenu = false @@ -1056,7 +1184,7 @@ fun ChatListContent(component: ChatListComponent) { }, onReport = { showSelectionActionsMenu = false - component.onReportSelected("other") + showReportSheet = true }, onLeave = { showSelectionActionsMenu = false @@ -1072,6 +1200,7 @@ fun ChatListContent(component: ChatListComponent) { private sealed interface ChatListTopBarMode { data class Selection(val isInArchive: Boolean) : ChatListTopBarMode data object Forwarding : ChatListTopBarMode + data object ShareTarget : ChatListTopBarMode data object Archive : ChatListTopBarMode data class Default( val user: UserModel?, @@ -1118,6 +1247,7 @@ private fun SelectionActionsPopup( visible: Boolean, selectionState: ChatListComponent.SelectionState, isInArchive: Boolean, + isActionPending: Boolean, onDismiss: () -> Unit, onPin: () -> Unit, onMute: () -> Unit, @@ -1166,21 +1296,21 @@ private fun SelectionActionsPopup( enabled = capabilities.canToggleRead, onClick = onToggleRead ) - if (capabilities.canClearHistory) { + if (capabilities.canClearHistory && !isActionPending) { MenuOptionRow( icon = Icons.Rounded.CleaningServices, title = stringResource(R.string.menu_clear_history), onClick = onClearHistory ) } - if (capabilities.canReport) { + if (capabilities.canReport && !isActionPending) { MenuOptionRow( icon = Icons.Rounded.Report, title = stringResource(R.string.menu_report), onClick = onReport ) } - if (capabilities.canLeave) { + if (capabilities.canLeave && !isActionPending) { MenuOptionRow( icon = Icons.AutoMirrored.Rounded.ExitToApp, title = stringResource(R.string.menu_leave), @@ -1207,6 +1337,7 @@ private fun ChatListBody( selectionState: ChatListComponent.SelectionState, selectedForwardChatIds: Set, isForwarding: Boolean, + isShareTargetMode: Boolean, searchState: ChatListComponent.SearchState, visibleFolders: List, pagerState: PagerState, @@ -1217,6 +1348,11 @@ private fun ChatListBody( emojiFontFamily: FontFamily, messageLines: Int, showPhotos: Boolean, + interactionsEnabled: Boolean, + showProjectChannelPromo: Boolean, + isProjectChannelJoinInProgress: Boolean, + onProjectChannelSubscribe: () -> Unit, + onProjectChannelLater: () -> Unit, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit ) { @@ -1229,6 +1365,7 @@ private fun ChatListBody( selectionState = selectionState, selectedForwardChatIds = selectedForwardChatIds, isForwarding = isForwarding, + isShareTargetMode = isShareTargetMode, searchState = searchState, firstFolderTransitionCompleted = firstFolderTransitionCompleted, scrollStates = scrollStates, @@ -1237,6 +1374,7 @@ private fun ChatListBody( emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos, + interactionsEnabled = interactionsEnabled, onChatClicked = onChatClicked, onChatLongClicked = onChatLongClicked ) @@ -1247,6 +1385,7 @@ private fun ChatListBody( selectionState = selectionState, selectedForwardChatIds = selectedForwardChatIds, isForwarding = isForwarding, + isShareTargetMode = isShareTargetMode, visibleFolders = visibleFolders, pagerState = pagerState, firstFolderTransitionCompleted = firstFolderTransitionCompleted, @@ -1256,6 +1395,11 @@ private fun ChatListBody( emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos, + interactionsEnabled = interactionsEnabled, + showProjectChannelPromo = showProjectChannelPromo, + isProjectChannelJoinInProgress = isProjectChannelJoinInProgress, + onProjectChannelSubscribe = onProjectChannelSubscribe, + onProjectChannelLater = onProjectChannelLater, onChatClicked = onChatClicked, onChatLongClicked = onChatLongClicked ) @@ -1272,6 +1416,7 @@ private fun SearchOrArchiveContent( selectionState: ChatListComponent.SelectionState, selectedForwardChatIds: Set, isForwarding: Boolean, + isShareTargetMode: Boolean, searchState: ChatListComponent.SearchState, firstFolderTransitionCompleted: MutableMap, scrollStates: MutableMap, @@ -1280,6 +1425,7 @@ private fun SearchOrArchiveContent( emojiFontFamily: FontFamily, messageLines: Int, showPhotos: Boolean, + interactionsEnabled: Boolean, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit ) { @@ -1298,7 +1444,7 @@ private fun SearchOrArchiveContent( RegisterScrollState( folderId = -2, - enabled = isArchivedView, + enabled = isArchivedView && interactionsEnabled, scrollState = scrollState, scrollStates = scrollStates, onSaveScrollPosition = component::updateScrollPosition @@ -1336,7 +1482,8 @@ private fun SearchOrArchiveContent( } } - LaunchedEffect(isArchivedView, archivedChats.firstOrNull()?.id) { + LaunchedEffect(isArchivedView, archivedChats.firstOrNull()?.id, interactionsEnabled) { + if (!interactionsEnabled) return@LaunchedEffect if (isArchivedView && !scrollState.isScrollInProgress && scrollState.firstVisibleItemIndex <= 1) { scrollState.scrollToItem(0, 0) } @@ -1346,9 +1493,10 @@ private fun SearchOrArchiveContent( isArchivedView, archivedChats.size, isArchivedLoading, - scrollState + scrollState, + interactionsEnabled, ) { - if (!isArchivedView || isArchivedLoading || archivedChats.isEmpty()) return@LaunchedEffect + if (!interactionsEnabled || !isArchivedView || isArchivedLoading || archivedChats.isEmpty()) return@LaunchedEffect snapshotFlow { val lastVisible = scrollState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 @@ -1362,8 +1510,13 @@ private fun SearchOrArchiveContent( } } - LaunchedEffect(messagePaginationKeys, searchState.canLoadMoreMessages, scrollState) { - if (!searchState.canLoadMoreMessages || messagePaginationKeys.isEmpty()) { + LaunchedEffect( + messagePaginationKeys, + searchState.canLoadMoreMessages, + scrollState, + interactionsEnabled + ) { + if (!interactionsEnabled || !searchState.canLoadMoreMessages || messagePaginationKeys.isEmpty()) { return@LaunchedEffect } @@ -1477,7 +1630,9 @@ private fun SearchOrArchiveContent( ChatListChatRow( chat = chat, currentUserId = currentUserId, - isSelected = if (isForwarding) selectedForwardChatIds.contains(chat.id) else selectionState.selectedChatIds.contains( + isSelected = if (isForwarding || isShareTargetMode) { + selectedForwardChatIds.contains(chat.id) + } else selectionState.selectedChatIds.contains( chat.id ), isTabletSelected = isTablet && selectionState.activeChatId == chat.id, @@ -1501,7 +1656,9 @@ private fun SearchOrArchiveContent( ChatListChatRow( chat = chat, currentUserId = currentUserId, - isSelected = if (isForwarding) selectedForwardChatIds.contains(chat.id) else selectionState.selectedChatIds.contains( + isSelected = if (isForwarding || isShareTargetMode) { + selectedForwardChatIds.contains(chat.id) + } else selectionState.selectedChatIds.contains( chat.id ), isTabletSelected = isTablet && selectionState.activeChatId == chat.id, @@ -1552,7 +1709,9 @@ private fun SearchOrArchiveContent( ChatListChatRow( chat = chat, currentUserId = currentUserId, - isSelected = if (isForwarding) selectedForwardChatIds.contains(chat.id) else selectionState.selectedChatIds.contains( + isSelected = if (isForwarding || isShareTargetMode) { + selectedForwardChatIds.contains(chat.id) + } else selectionState.selectedChatIds.contains( chat.id ), isTabletSelected = isTablet && selectionState.activeChatId == chat.id, @@ -1592,6 +1751,7 @@ private fun FolderPagerContent( selectionState: ChatListComponent.SelectionState, selectedForwardChatIds: Set, isForwarding: Boolean, + isShareTargetMode: Boolean, visibleFolders: List, pagerState: PagerState, firstFolderTransitionCompleted: MutableMap, @@ -1601,6 +1761,11 @@ private fun FolderPagerContent( emojiFontFamily: FontFamily, messageLines: Int, showPhotos: Boolean, + interactionsEnabled: Boolean, + showProjectChannelPromo: Boolean, + isProjectChannelJoinInProgress: Boolean, + onProjectChannelSubscribe: () -> Unit, + onProjectChannelLater: () -> Unit, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit ) { @@ -1627,6 +1792,7 @@ private fun FolderPagerContent( selectionState = selectionState, selectedForwardChatIds = selectedForwardChatIds, isForwarding = isForwarding, + isShareTargetMode = isShareTargetMode, shouldAnimateFirstFolderTransition = firstFolderTransitionCompleted[folderId] != true, scrollStates = scrollStates, onMarkFirstTransitionComplete = { firstFolderTransitionCompleted[folderId] = true }, @@ -1635,6 +1801,11 @@ private fun FolderPagerContent( emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos, + interactionsEnabled = interactionsEnabled, + showProjectChannelPromo = showProjectChannelPromo && folderId == foldersState.selectedFolderId, + isProjectChannelJoinInProgress = isProjectChannelJoinInProgress, + onProjectChannelSubscribe = onProjectChannelSubscribe, + onProjectChannelLater = onProjectChannelLater, onChatClicked = onChatClicked, onChatLongClicked = onChatLongClicked ) @@ -1653,6 +1824,7 @@ private fun FolderPageContent( selectionState: ChatListComponent.SelectionState, selectedForwardChatIds: Set, isForwarding: Boolean, + isShareTargetMode: Boolean, shouldAnimateFirstFolderTransition: Boolean, scrollStates: MutableMap, onMarkFirstTransitionComplete: () -> Unit, @@ -1661,6 +1833,11 @@ private fun FolderPageContent( emojiFontFamily: FontFamily, messageLines: Int, showPhotos: Boolean, + interactionsEnabled: Boolean, + showProjectChannelPromo: Boolean, + isProjectChannelJoinInProgress: Boolean, + onProjectChannelSubscribe: () -> Unit, + onProjectChannelLater: () -> Unit, onChatClicked: (Long) -> Unit, onChatLongClicked: (Long) -> Unit ) { @@ -1676,23 +1853,26 @@ private fun FolderPageContent( RegisterScrollState( folderId = folderId, - enabled = true, + enabled = interactionsEnabled, scrollState = scrollState, scrollStates = scrollStates, onSaveScrollPosition = component::updateScrollPosition ) - LaunchedEffect(folderChats.firstOrNull()?.id) { + LaunchedEffect(folderChats.firstOrNull()?.id, interactionsEnabled) { + if (!interactionsEnabled) return@LaunchedEffect if (!scrollState.isScrollInProgress && scrollState.firstVisibleItemIndex <= 1) { scrollState.scrollToItem(0, 0) } } - LaunchedEffect(folderId, folderChats.size, isFolderLoading, scrollState) { - if (isFolderLoading || folderChats.isEmpty()) return@LaunchedEffect + LaunchedEffect(folderId, folderChats.size, isFolderLoading, scrollState, interactionsEnabled) { + if (!interactionsEnabled || isFolderLoading || folderChats.isEmpty()) return@LaunchedEffect + val headerItemsCount = if (showProjectChannelPromo) 1 else 0 snapshotFlow { - val lastVisible = scrollState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + val lastVisible = (scrollState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + ?: -1) - headerItemsCount lastVisible >= folderChats.lastIndex - 5 } .distinctUntilChanged() @@ -1703,12 +1883,13 @@ private fun FolderPageContent( } } - LaunchedEffect(folderId, folderChats, isFolderLoading, scrollState) { - if (isFolderLoading || folderChats.isEmpty()) return@LaunchedEffect + LaunchedEffect(folderId, folderChats, isFolderLoading, scrollState, interactionsEnabled) { + if (!interactionsEnabled || isFolderLoading || folderChats.isEmpty()) return@LaunchedEffect + val headerItemsCount = if (showProjectChannelPromo) 1 else 0 snapshotFlow { scrollState.layoutInfo.visibleItemsInfo - .mapNotNull { item -> folderChats.getOrNull(item.index)?.id } + .mapNotNull { item -> folderChats.getOrNull(item.index - headerItemsCount)?.id } .distinct() } .distinctUntilChanged() @@ -1756,6 +1937,17 @@ private fun FolderPageContent( end = if (isTablet) 4.dp else 0.dp ) ) { + if (showProjectChannelPromo) { + item { + ProjectChannelPromoCard( + isJoining = isProjectChannelJoinInProgress, + onSubscribe = onProjectChannelSubscribe, + onLater = onProjectChannelLater, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + } + if (folderChats.isEmpty() && hasFolderLoadState && !isFolderLoading) { item { EmptyStateView(modifier = Modifier.fillParentMaxSize()) @@ -1766,7 +1958,9 @@ private fun FolderPageContent( ChatListChatRow( chat = chat, currentUserId = currentUserId, - isSelected = if (isForwarding) selectedForwardChatIds.contains(chat.id) else selectionState.selectedChatIds.contains( + isSelected = if (isForwarding || isShareTargetMode) { + selectedForwardChatIds.contains(chat.id) + } else selectionState.selectedChatIds.contains( chat.id ), isTabletSelected = isTablet && selectionState.activeChatId == chat.id, @@ -1931,12 +2125,13 @@ private fun SelectionModeTopBar( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ForwardingModeTopBar( + title: String = stringResource(R.string.forward_to_title), onBackClick: () -> Unit ) { TopAppBar( title = { Text( - stringResource(R.string.forward_to_title), + title, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium ) @@ -2459,6 +2654,69 @@ private fun ArchiveModeTopBar( ) } +@Composable +private fun ProjectChannelPromoCard( + isJoining: Boolean, + onSubscribe: () -> Unit, + onLater: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + tonalElevation = 2.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = stringResource(R.string.project_channel_promo_badge), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.72f) + ) + Text( + text = stringResource(R.string.project_channel_promo_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(R.string.project_channel_promo_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.82f) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onLater, + enabled = !isJoining + ) { + Text(text = stringResource(R.string.project_channel_promo_later)) + } + Button( + onClick = onSubscribe, + enabled = !isJoining + ) { + Text( + text = stringResource( + if (isJoining) { + R.string.project_channel_promo_subscribing + } else { + R.string.project_channel_promo_subscribe + } + ) + ) + } + } + } + } +} + @Composable private fun rememberManagedChatListState( stateKey: String, @@ -2480,3 +2738,23 @@ private fun hasChatListUnreadState(chat: ChatModel): Boolean { chat.unreadMentionCount > 0 || chat.unreadReactionCount > 0 } + +internal fun shouldShowProjectChannelPromo( + subscriptionState: ChatListComponent.ProjectChannelSubscriptionState, + isDismissed: Boolean, + isPreview: Boolean, + isSearchActive: Boolean, + selectedFolderId: Int, + isForwarding: Boolean, + isShareTargetMode: Boolean, + hasSelection: Boolean +): Boolean { + return !isDismissed && + !isPreview && + !isSearchActive && + selectedFolderId != -2 && + !isForwarding && + !isShareTargetMode && + !hasSelection && + subscriptionState == ChatListComponent.ProjectChannelSubscriptionState.NOT_SUBSCRIBED +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListPreviewMode.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListPreviewMode.kt new file mode 100644 index 00000000..029c97b2 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListPreviewMode.kt @@ -0,0 +1,9 @@ +package org.monogram.presentation.features.chats.list + +sealed interface ChatListPreviewMode { + data object Active : ChatListPreviewMode + + data class FolderPreview( + val folderId: Int, + ) : ChatListPreviewMode +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListStore.kt index 383a3605..0dc72ddb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListStore.kt @@ -4,6 +4,7 @@ import com.arkivanov.mvikotlin.core.store.Store import org.monogram.domain.models.MessageEntity import org.monogram.domain.repository.ForwardRequest import org.monogram.domain.repository.ForwardTarget +import org.monogram.presentation.features.share.ShareTarget interface ChatListStore : Store { @@ -42,6 +43,8 @@ interface ChatListStore : Store) : Intent() data class UpdateScrollPosition(val folderId: Int, val index: Int, val offset: Int) : Intent() data class UpdateState(val state: ChatListComponent.State) : Intent() @@ -67,6 +72,8 @@ interface ChatListStore : Store component.handleDismissForwardTopicPicker() + is Intent.ShareTopicSelected -> component.handleShareTopicSelected( + intent.chatId, + intent.topicId + )?.let(::publish) + + Intent.DismissShareTopicPicker -> component.handleDismissShareTopicPicker() + ?.let(::publish) is Intent.RemoveForwardTarget -> component.handleRemoveForwardTarget(intent.target) Intent.NewChatClicked -> publish(Label.OpenNewChat) Intent.ProxySettingsClicked -> publish(Label.OpenProxySettings) @@ -74,6 +81,8 @@ class ChatListStoreFactory( is Intent.OpenWebView -> component.handleOpenWebView(intent.url) Intent.DismissWebView -> component.handleDismissWebView() Intent.UpdateClicked -> component.handleUpdateClicked()?.let(::publish) + Intent.ProjectChannelSubscribe -> component.handleProjectChannelSubscribe() + Intent.ProjectChannelLater -> component.handleProjectChannelLater() is Intent.VisibleChatIdsChanged -> component.handleVisibleChatIdsChanged(intent.ids) is Intent.UpdateScrollPosition -> component.handleUpdateScrollPosition( intent.folderId, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListSwipeBack.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListSwipeBack.kt new file mode 100644 index 00000000..e6100a5e --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListSwipeBack.kt @@ -0,0 +1,69 @@ +package org.monogram.presentation.features.chats.list + +import org.monogram.presentation.core.ui.ScreenSwipeBackAction +import org.monogram.presentation.core.ui.ScreenSwipeBackPreview +import org.monogram.presentation.core.ui.ScreenSwipeBackState + +fun resolveArchiveReturnFolderId( + folders: List, + showAllChatsFolder: Boolean, + lastNonArchiveFolderId: Int? = null, +): Int { + val userFolders = folders.filter { it.id >= 0 } + val allChatsFolder = folders.firstOrNull { it.id == -1 } + val visibleFolderIds = if (showAllChatsFolder || userFolders.isEmpty()) { + folders.map { it.id } + } else { + folders.filterNot { it.id == -1 }.map { it.id } + } + val rememberedFolderId = lastNonArchiveFolderId?.takeIf { it in visibleFolderIds && it != -2 } + + return when { + rememberedFolderId != null -> rememberedFolderId + userFolders.isNotEmpty() -> userFolders.first().id + allChatsFolder != null && allChatsFolder.id in visibleFolderIds -> allChatsFolder.id + visibleFolderIds.isNotEmpty() -> visibleFolderIds.first() + else -> -1 + } +} + +internal fun resolveChatListSwipeBackState( + state: ChatListComponent.State, + showAllChatsFolder: Boolean, + hasTransientBlockingUi: Boolean = false, +): ScreenSwipeBackState { + val preferredFolderId = resolveArchiveReturnFolderId( + folders = state.folders, + showAllChatsFolder = showAllChatsFolder, + lastNonArchiveFolderId = state.lastNonArchiveFolderId, + ) + val hasCustomBackState = + state.webViewUrl != null || + state.webAppUrl != null || + state.instantViewUrl != null || + state.isSearchActive || + state.forwardTopicPickerChatId != null || + state.isShareTargetMode || + state.selectedForwardTargets.isNotEmpty() || + state.selectedChatIds.isNotEmpty() || + state.isForwarding + + val supportsArchiveReturn = + state.selectedFolderId == -2 && + preferredFolderId != -2 && + preferredFolderId != state.selectedFolderId + + return if (supportsArchiveReturn) { + ScreenSwipeBackState( + isSupported = true, + isBlocked = hasTransientBlockingUi || hasCustomBackState, + action = ScreenSwipeBackAction.LocalChatListArchiveReturn, + preview = ScreenSwipeBackPreview.ChatListFolder, + ) + } else { + ScreenSwipeBackState( + isSupported = false, + isBlocked = hasTransientBlockingUi || hasCustomBackState, + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt index 702cc748..85aa6085 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt @@ -44,6 +44,13 @@ import org.monogram.presentation.BuildConfig import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope +import org.monogram.presentation.features.chats.common.ChatActionScreenContext +import org.monogram.presentation.features.chats.common.ChatActionState +import org.monogram.presentation.features.chats.common.ChatActionType +import org.monogram.presentation.features.chats.common.ChatExitAction +import org.monogram.presentation.features.chats.common.resolveChatActionPolicy +import org.monogram.presentation.features.chats.common.resolveChatExitAction +import org.monogram.presentation.features.share.ShareTarget import org.monogram.presentation.root.AppComponentContext class DefaultChatListComponent( @@ -53,9 +60,12 @@ class DefaultChatListComponent( private val onSettingsClick: () -> Unit, private val onProxySettingsClick: () -> Unit, private val onConfirmForward: (ForwardRequest) -> Unit = {}, + private val onShareTargetSelected: (ShareTarget) -> Unit = {}, + private val onShareCancelled: () -> Unit = {}, private val forwardingFromChatId: Long? = null, private val forwardingMessageIds: List = emptyList(), internal val isForwarding: Boolean = false, + internal val isShareTargetMode: Boolean = false, private val onNewChatClick: () -> Unit = {}, private val onEditFoldersClick: () -> Unit = {}, activeChatId: Value @@ -75,10 +85,13 @@ class DefaultChatListComponent( private val attachMenuBotRepository: AttachMenuBotRepository = container.repositories.attachMenuBotRepository private val updateRepository: UpdateRepository = container.repositories.updateRepository override val appPreferences: AppPreferences = container.preferences.appPreferences + private val messageDisplayer = container.utils.messageDisplayer() internal val _state = MutableStateFlow( ChatListComponent.State( isForwarding = isForwarding, + isShareTargetMode = isShareTargetMode, + projectChannelSubscriptionState = appPreferences.projectChannelSubscriptionState(), isLoadingByFolder = mapOf(-1 to true) ) ) @@ -113,10 +126,13 @@ class DefaultChatListComponent( private fun ChatListComponent.State.toUiState() = ChatListComponent.UiState( currentUser = currentUser, + projectChannelSubscriptionState = projectChannelSubscriptionState, + isProjectChannelJoinInProgress = isProjectChannelJoinInProgress, connectionStatus = connectionStatus, isArchivePinned = isArchivePinned, isArchiveAlwaysVisible = isArchiveAlwaysVisible, isForwarding = isForwarding, + isShareTargetMode = isShareTargetMode, isForwardSubmitInProgress = isForwardSubmitInProgress, instantViewUrl = instantViewUrl, isProxyEnabled = isProxyEnabled, @@ -163,6 +179,7 @@ class DefaultChatListComponent( forwardTopics = forwardTopics, isLoadingForwardTopics = isLoadingForwardTopics, isForwardSubmitInProgress = isForwardSubmitInProgress, + isShareTargetMode = isShareTargetMode, allPinned = allPinned, allMuted = allMuted, canMarkUnread = canMarkUnread, @@ -206,14 +223,22 @@ class DefaultChatListComponent( .toSet() val canPin = selectedChats.all { currentFolderChatIds.contains(it.id) } - val canDelete = - selectedChats.all { it.canBeDeletedOnlyForSelf || it.canBeDeletedForAllUsers } val singleChat = selectedChats.singleOrNull() - val canLeave = singleChat?.let { - it.isMember && (it.type == ChatType.BASIC_GROUP || it.type == ChatType.SUPERGROUP || it.isChannel) - } ?: false - val canClearHistory = singleChat != null - val canReport = singleChat?.canBeReported ?: false + val singlePolicy = singleChat?.let(::resolveChatListActionPolicy) + val canDelete = when { + singlePolicy?.exitAction == ChatExitAction.Delete -> true + selectedChats.size > 1 -> selectedChats.all { resolveChatListActionPolicy(it).exitAction == ChatExitAction.Delete } + else -> false + } + val canLeave = when { + singlePolicy?.exitAction == ChatExitAction.Leave -> true + selectedChats.size > 1 -> selectedChats.all { resolveChatListActionPolicy(it).exitAction == ChatExitAction.Leave } + else -> false + } + val canClearHistory = + selectedChats.isNotEmpty() && selectedChats.all { resolveChatListActionPolicy(it).canClearHistory } + val canReport = + selectedChats.isNotEmpty() && selectedChats.all { resolveChatListActionPolicy(it).canReport } return ChatListComponent.SelectionCapabilities( canPin = canPin, @@ -253,8 +278,9 @@ class DefaultChatListComponent( repositoryUser.currentUserFlow .onEach { user -> + _state.update { it.copy(currentUser = user) } if (user != null) { - _state.update { it.copy(currentUser = user) } + refreshProjectChannelSubscription() } } .launchIn(scope) @@ -284,6 +310,7 @@ class DefaultChatListComponent( newChatsByFolder[update.folderId] = normalizedList it.copy(chatsByFolder = newChatsByFolder) } + syncProjectChannelSubscriptionFromChats(normalizedList) } .launchIn(scope) @@ -330,6 +357,14 @@ class DefaultChatListComponent( } .launchIn(scope) + appPreferences.isProjectChannelSubscribed + .onEach { subscribed -> + _state.update { + it.copy(projectChannelSubscriptionState = subscribed.toProjectChannelSubscriptionState()) + } + } + .launchIn(scope) + chatOperationsRepository.isArchivePinned .onEach { isPinned -> _state.update { it.copy(isArchivePinned = isPinned) } @@ -408,6 +443,8 @@ class DefaultChatListComponent( ChatListStore.Label.OpenProxySettings -> onProxySettingsClick() ChatListStore.Label.OpenNewChat -> onNewChatClick() is ChatListStore.Label.ConfirmForward -> onConfirmForward(label.request) + is ChatListStore.Label.ShareTargetSelected -> onShareTargetSelected(label.target) + ChatListStore.Label.CancelShareTarget -> onShareCancelled() is ChatListStore.Label.EditFolders -> onEditFoldersClick() } } @@ -430,6 +467,7 @@ class DefaultChatListComponent( loadingByFolder[id] = true it.copy( selectedFolderId = id, + lastNonArchiveFolderId = if (id != ARCHIVE_FOLDER_ID) id else it.lastNonArchiveFolderId, isLoadingByFolder = loadingByFolder ) } @@ -473,8 +511,10 @@ class DefaultChatListComponent( override fun onChatClicked(id: Long) = store.accept(ChatListStore.Intent.ChatClicked(id)) - internal fun handleChatClicked(id: Long): ChatListStore.Label.OpenChat? { - if (_state.value.isForwarding) { + internal fun handleChatClicked(id: Long): ChatListStore.Label? { + if (_state.value.isShareTargetMode) { + return resolveShareTarget(id) + } else if (_state.value.isForwarding) { toggleForwardTarget(id) return null } else if (_state.value.selectedChatIds.isNotEmpty()) { @@ -490,8 +530,10 @@ class DefaultChatListComponent( override fun onProfileClicked(id: Long) = store.accept(ChatListStore.Intent.ProfileClicked(id)) - internal fun handleMessageClicked(chatId: Long, messageId: Long): ChatListStore.Label.OpenChat? { - if (_state.value.isForwarding) { + internal fun handleMessageClicked(chatId: Long, messageId: Long): ChatListStore.Label? { + if (_state.value.isShareTargetMode) { + return resolveShareTarget(chatId) + } else if (_state.value.isForwarding) { toggleForwardTarget(chatId) return null } else if (_state.value.selectedChatIds.isNotEmpty()) { @@ -511,6 +553,7 @@ class DefaultChatListComponent( override fun onChatLongClicked(id: Long) = store.accept(ChatListStore.Intent.ChatLongClicked(id)) internal fun handleChatLongClicked(id: Long) { + if (_state.value.isShareTargetMode) return toggleSelection(id) } @@ -641,7 +684,7 @@ class DefaultChatListComponent( val selectedIds = selection.selectedChatIds if (selectedIds.isEmpty()) return - scope.launch(Dispatchers.IO) { + runSelectionAction(ChatActionType.Mute) { chatOperationsRepository.toggleMuteChats(selectedIds, mute) handleClearSelection() } @@ -656,7 +699,7 @@ class DefaultChatListComponent( val selectedIds = selection.selectedChatIds if (selectedIds.isEmpty()) return - scope.launch(Dispatchers.IO) { + runSelectionAction(ChatActionType.Archive) { chatOperationsRepository.toggleArchiveChats(selectedIds, archive) handleClearSelection() } @@ -674,7 +717,7 @@ class DefaultChatListComponent( val shouldPin = selection.selectedChats.any { !it.isPinned } val folderId = _state.value.selectedFolderId - scope.launch(Dispatchers.IO) { + runSelectionAction(ChatActionType.Pin) { chatOperationsRepository.togglePinChats(selectedIds, shouldPin, folderId) handleClearSelection() } @@ -691,7 +734,7 @@ class DefaultChatListComponent( val shouldMarkUnread = selection.canMarkUnread - scope.launch(Dispatchers.IO) { + runSelectionAction(ChatActionType.ToggleRead) { chatOperationsRepository.toggleReadChats(selectedIds, shouldMarkUnread) handleClearSelection() } @@ -706,7 +749,7 @@ class DefaultChatListComponent( val selectedIds = selection.selectedChatIds if (selectedIds.isEmpty()) return - scope.launch(Dispatchers.IO) { + runSelectionAction(ChatActionType.Delete) { chatOperationsRepository.deleteChats(selectedIds) handleClearSelection() } @@ -737,7 +780,7 @@ class DefaultChatListComponent( val selectedIds = selection.selectedChatIds if (selectedIds.isEmpty()) return - scope.launch(Dispatchers.IO) { + runSelectionAction(ChatActionType.Leave) { chatOperationsRepository.leaveChats(selectedIds) handleClearSelection() } @@ -752,7 +795,7 @@ class DefaultChatListComponent( val selectedIds = selection.selectedChatIds if (selectedIds.isEmpty()) return - scope.launch(Dispatchers.IO) { + runSelectionAction(ChatActionType.ClearHistory) { chatOperationsRepository.clearChatHistories(selectedIds, revoke) handleClearSelection() } @@ -767,7 +810,7 @@ class DefaultChatListComponent( val selectedIds = selection.selectedChatIds if (selectedIds.isEmpty()) return - scope.launch(Dispatchers.IO) { + runSelectionAction(ChatActionType.Report) { chatOperationsRepository.reportChats(selectedIds, reason) handleClearSelection() } @@ -861,6 +904,31 @@ class DefaultChatListComponent( } } + override fun onShareTopicSelected(chatId: Long, topicId: Int?) = + store.accept(ChatListStore.Intent.ShareTopicSelected(chatId, topicId)) + + internal fun handleShareTopicSelected( + chatId: Long, + topicId: Int? + ): ChatListStore.Label.ShareTargetSelected { + handleDismissForwardTopicPicker() + return ChatListStore.Label.ShareTargetSelected( + ShareTarget(chatId = chatId, topicId = topicId?.toLong()) + ) + } + + override fun onDismissShareTopicPicker() = + store.accept(ChatListStore.Intent.DismissShareTopicPicker) + + internal fun handleDismissShareTopicPicker(): ChatListStore.Label.CancelShareTarget? { + if (!_state.value.isShareTargetMode) { + handleDismissForwardTopicPicker() + return null + } + handleDismissForwardTopicPicker() + return ChatListStore.Label.CancelShareTarget + } + override fun onRemoveForwardTarget(target: ForwardTarget) = store.accept(ChatListStore.Intent.RemoveForwardTarget(target)) @@ -952,6 +1020,46 @@ class DefaultChatListComponent( override fun onUpdateClicked() = store.accept(ChatListStore.Intent.UpdateClicked) + override fun onProjectChannelSubscribe() = + store.accept(ChatListStore.Intent.ProjectChannelSubscribe) + + internal fun handleProjectChannelSubscribe() { + if (_state.value.isProjectChannelJoinInProgress) return + if (_state.value.projectChannelSubscriptionState == ChatListComponent.ProjectChannelSubscriptionState.SUBSCRIBED) { + return + } + + _state.update { it.copy(isProjectChannelJoinInProgress = true) } + scope.launch(Dispatchers.IO) { + runCatching { + messageRepository.joinChat(PROJECT_CHANNEL_CHAT_ID) + appPreferences.setProjectChannelPromoDismissed(false) + }.onSuccess { + val chat = runCatching { + chatListRepository.getChatById(PROJECT_CHANNEL_CHAT_ID) + }.getOrNull() + + if (chat != null) { + syncProjectChannelSubscriptionFromChat(chat, clearJoinInProgress = true) + } else { + _state.update { it.copy(isProjectChannelJoinInProgress = false) } + refreshProjectChannelSubscription() + } + }.onFailure { error -> + _state.update { it.copy(isProjectChannelJoinInProgress = false) } + messageDisplayer.show(error.message ?: "Unable to subscribe right now") + refreshProjectChannelSubscription() + } + } + } + + override fun onProjectChannelLater() = + store.accept(ChatListStore.Intent.ProjectChannelLater) + + internal fun handleProjectChannelLater() { + appPreferences.setProjectChannelPromoDismissed(true) + } + internal fun handleUpdateClicked(): ChatListStore.Label.OpenSettings? { val currentState = _state.value.updateState when (currentState) { @@ -994,10 +1102,18 @@ class DefaultChatListComponent( } state.value.forwardTopicPickerChatId != null -> { - handleDismissForwardTopicPicker() + if (state.value.isShareTargetMode) { + handleDismissShareTopicPicker() + } else { + handleDismissForwardTopicPicker() + } true } + state.value.isShareTargetMode -> { + onShareCancelled() + true + } state.value.selectedForwardTargets.isNotEmpty() -> { _state.update { it.copy(selectedForwardTargets = emptyList()) } true @@ -1006,12 +1122,12 @@ class DefaultChatListComponent( handleClearSelection() true } - state.value.selectedFolderId == -2 -> { + state.value.selectedFolderId == ARCHIVE_FOLDER_ID -> { handleFolderClicked( - resolvePreferredFolderId( + resolveArchiveReturnFolderId( folders = _state.value.folders, - currentFolderId = null, - showAllChatsFolder = appPreferences.showAllChatsFolder.value + showAllChatsFolder = appPreferences.showAllChatsFolder.value, + lastNonArchiveFolderId = _state.value.lastNonArchiveFolderId, ) ) true @@ -1071,6 +1187,47 @@ class DefaultChatListComponent( } } + private fun refreshProjectChannelSubscription() { + scope.launch(Dispatchers.IO) { + val chat = runCatching { + chatListRepository.getChatById(PROJECT_CHANNEL_CHAT_ID) + }.getOrNull() + + if (chat != null) { + syncProjectChannelSubscriptionFromChat(chat) + return@launch + } + + val currentState = _state.value.projectChannelSubscriptionState + if (currentState == ChatListComponent.ProjectChannelSubscriptionState.SUBSCRIBED) { + appPreferences.setProjectChannelSubscribed(true) + } + } + } + + private fun syncProjectChannelSubscriptionFromChats(chats: List) { + chats.firstOrNull { it.id == PROJECT_CHANNEL_CHAT_ID } + ?.let(::syncProjectChannelSubscriptionFromChat) + } + + private fun syncProjectChannelSubscriptionFromChat( + chat: ChatModel, + clearJoinInProgress: Boolean = false + ) { + val subscribed = chat.isMember + appPreferences.setProjectChannelSubscribed(subscribed) + _state.update { + it.copy( + projectChannelSubscriptionState = subscribed.toProjectChannelSubscriptionState(), + isProjectChannelJoinInProgress = if (subscribed || clearJoinInProgress) { + false + } else { + it.isProjectChannelJoinInProgress + } + ) + } + } + override fun updateScrollPosition(folderId: Int, index: Int, offset: Int) = store.accept(ChatListStore.Intent.UpdateScrollPosition(folderId, index, offset)) @@ -1084,6 +1241,8 @@ class DefaultChatListComponent( companion object { private const val TAG = "PinnedUiDiag" + private const val ARCHIVE_FOLDER_ID = -2 + private const val PROJECT_CHANNEL_CHAT_ID = -1003768707135L private const val PREFETCH_MAX_CANDIDATES = 6 private const val PREFETCH_CONCURRENCY = 2 private const val PREFETCH_PAGE_SIZE = 30 @@ -1100,6 +1259,35 @@ class DefaultChatListComponent( _state.value = _state.value.copy(selectedChatIds = newSelection) } + private fun runSelectionAction( + action: ChatActionType, + block: suspend () -> Unit + ) { + if (_state.value.actionState is ChatActionState.Pending) return + scope.launch(Dispatchers.IO) { + _state.update { it.copy(actionState = ChatActionState.Pending(action)) } + runCatching { block() } + .onSuccess { + _state.update { it.copy(actionState = ChatActionState.Success(action)) } + } + .onFailure { error -> + val message = error.message ?: "Action failed" + _state.update { + it.copy( + actionState = ChatActionState.Failure( + action, + message + ) + ) + } + messageDisplayer.show(message) + } + if (_state.value.actionState !is ChatActionState.Pending) { + _state.update { it.copy(actionState = ChatActionState.Idle) } + } + } + } + private fun toggleForwardTarget(id: Long) { val chat = _state.value.resolveSelectedChats(setOf(id)).firstOrNull() if (chat?.viewAsTopics == true) { @@ -1117,6 +1305,15 @@ class DefaultChatListComponent( _state.update { it.copy(selectedForwardTargets = newTargets) } } + private fun resolveShareTarget(id: Long): ChatListStore.Label? { + val chat = _state.value.resolveSelectedChats(setOf(id)).firstOrNull() ?: return null + if (chat.viewAsTopics) { + openForwardTopicPicker(chat) + return null + } + return ChatListStore.Label.ShareTargetSelected(ShareTarget(chatId = id)) + } + private fun openForwardTopicPicker(chat: ChatModel) { _state.update { it.copy( @@ -1173,6 +1370,30 @@ private fun hasUnreadState(chat: ChatModel): Boolean { chat.unreadReactionCount > 0 } +internal fun resolveChatListExitAction(chat: ChatModel): ChatExitAction { + return resolveChatExitAction( + isMainChat = true, + isGroup = chat.isGroup, + isChannel = chat.isChannel, + isMember = chat.isMember, + canDeleteChat = chat.canBeDeletedOnlyForSelf || chat.canBeDeletedForAllUsers + ) +} + +internal fun resolveChatListActionPolicy(chat: ChatModel) = + resolveChatActionPolicy( + isMainChat = true, + isGroup = chat.isGroup, + isChannel = chat.isChannel, + isMember = chat.isMember, + canDeleteChat = chat.canBeDeletedOnlyForSelf || chat.canBeDeletedForAllUsers, + canReport = chat.canBeReported, + canJoin = (chat.isGroup || chat.isChannel) && !chat.isMember, + canBlockOrUnblock = !chat.isGroup && !chat.isChannel, + canPin = true, + context = ChatActionScreenContext.ListSelection + ) + internal fun normalizeFolderChats(chats: List): List { if (chats.size < 2) return chats @@ -1185,3 +1406,15 @@ internal fun normalizeFolderChats(chats: List): List { } return if (normalized.size == chats.size) chats else normalized } + +private fun AppPreferences.projectChannelSubscriptionState(): ChatListComponent.ProjectChannelSubscriptionState { + return isProjectChannelSubscribed.value.toProjectChannelSubscriptionState() +} + +private fun Boolean.toProjectChannelSubscriptionState(): ChatListComponent.ProjectChannelSubscriptionState { + return if (this) { + ChatListComponent.ProjectChannelSubscriptionState.SUBSCRIBED + } else { + ChatListComponent.ProjectChannelSubscriptionState.NOT_SUBSCRIBED + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/AccountMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/AccountMenu.kt index fd5dcde4..01ed007a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/AccountMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/AccountMenu.kt @@ -34,7 +34,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Person @@ -99,7 +98,6 @@ fun AccountMenu( onDismiss: () -> Unit, onSavedMessagesClick: () -> Unit, onSettingsClick: () -> Unit, - onHelpClick: () -> Unit, onUpdateClick: () -> Unit = {}, onProfileClick: () -> Unit = {}, onBotClick: (AttachMenuBotModel) -> Unit = {} @@ -402,17 +400,6 @@ fun AccountMenu( } } - SettingsItem( - icon = Icons.AutoMirrored.Outlined.HelpOutline, - iconBackgroundColor = menuIconColor, - title = stringResource(R.string.menu_help_feedback), - subtitle = stringResource(R.string.menu_help_feedback_subtitle), - position = ItemPosition.BOTTOM, - onClick = { - onHelpClick() - animateDismiss() - } - ) } MenuFooter( diff --git a/presentation/src/main/java/org/monogram/presentation/features/editing/EditorPrimitives.kt b/presentation/src/main/java/org/monogram/presentation/features/editing/EditorPrimitives.kt new file mode 100644 index 00000000..7c428174 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/editing/EditorPrimitives.kt @@ -0,0 +1,57 @@ +package org.monogram.presentation.features.editing + +data class EditorScreenState( + val isDirty: Boolean = false, + val isSaving: Boolean = false, + val error: String? = null, + val showDiscardChangesDialog: Boolean = false +) { + val canSave: Boolean + get() = isDirty && !isSaving +} + +object EditorPrimitives { + fun cleanState(): EditorScreenState = EditorScreenState() + + fun updateState( + isDirty: Boolean, + isSaving: Boolean = false, + error: String? = null, + showDiscardChangesDialog: Boolean = false + ): EditorScreenState = EditorScreenState( + isDirty = isDirty, + isSaving = isSaving, + error = error, + showDiscardChangesDialog = showDiscardChangesDialog + ) + + fun markDirty( + current: EditorScreenState, + isDirty: Boolean + ): EditorScreenState = current.copy( + isDirty = isDirty, + error = null + ) + + fun beginSave(current: EditorScreenState): EditorScreenState = current.copy( + isSaving = true, + error = null + ) + + fun endSave(current: EditorScreenState): EditorScreenState = current.copy( + isSaving = false + ) + + fun fail(current: EditorScreenState, error: String?): EditorScreenState = current.copy( + isSaving = false, + error = error + ) + + fun showDiscardChanges(current: EditorScreenState): EditorScreenState = current.copy( + showDiscardChangesDialog = true + ) + + fun hideDiscardChanges(current: EditorScreenState): EditorScreenState = current.copy( + showDiscardChangesDialog = false + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/instantview/InstantViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/instantview/InstantViewer.kt index f018f6ad..90922bca 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/instantview/InstantViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/instantview/InstantViewer.kt @@ -150,6 +150,7 @@ import org.monogram.presentation.features.instantview.components.containsText import org.monogram.presentation.features.instantview.components.renderRichText import org.monogram.presentation.features.instantview.components.renderedTextOrNull import org.monogram.presentation.features.instantview.components.resolvePathForViewer +import org.monogram.presentation.features.instantview.components.richTextPlainText import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow import org.monogram.presentation.features.viewers.ImageViewer import org.monogram.presentation.features.viewers.VideoViewer @@ -165,7 +166,8 @@ fun InstantViewer( messageRepository: MessageRepository, fileRepository: FileRepository, onDismiss: () -> Unit, - onOpenWebView: (String) -> Unit + onOpenWebView: (String) -> Unit, + onOpenError: () -> Unit = onDismiss ) { val urlStack = remember { mutableStateListOf(url) } val currentUrl = urlStack.lastOrNull() ?: url @@ -223,16 +225,13 @@ fun InstantViewer( LaunchedEffect(currentUrl) { isLoading = true - val result = messageRepository.getWebPageInstantView(currentUrl) - if (result == null) { - onOpenWebView(normalizeUrl(currentUrl)) - if (urlStack.size > 1) { - urlStack.removeAt(urlStack.size - 1) - } else { - onDismiss() - } + val resolvedInstantView = runCatching { + messageRepository.getWebPageInstantView(currentUrl) + }.getOrNull() + if (resolvedInstantView != null) { + instantView = resolvedInstantView } else { - instantView = result + onOpenError() } isLoading = false } @@ -392,11 +391,17 @@ fun InstantViewer( itemsIndexed( items = blocks, key = { index, block -> instantViewBlockKey(index, block) } - ) { _, block -> + ) { index, block -> InstantViewBlock( block = block, textSizeMultiplier = textSizeMultiplier, searchQuery = searchQuery, + stateKeyPrefix = "instant_view:${ + instantViewBlockKey( + index, + block + ) + }", onOpenPhotoFullscreen = openPhotoFullscreen, onOpenVideoFullscreen = openVideoFullscreen, onOpenFileExternally = openFileExternally @@ -534,21 +539,51 @@ fun InstantViewer( } } -private fun instantViewBlockKey(index: Int, block: PageBlock): String { - return "${block::class.qualifiedName}:${block.hashCode()}:$index" +internal fun instantViewBlockKey(index: Int, block: PageBlock): String { + return "${block::class.qualifiedName}:$index:${block.stableBlockSignature()}" } +internal fun PageBlock.stableBlockSignature(): String { + return when (this) { + is PageBlock.PhotoBlock -> "photo:${photo.fileId}:${url}" + is PageBlock.VideoBlock -> "video:${video.fileId}:${caption.stableCaptionSignature()}:${video.duration}" + is PageBlock.AnimationBlock -> "animation:${animation.fileId}:${caption.stableCaptionSignature()}:${animation.duration}" + is PageBlock.AudioBlock -> "audio:${audio.fileId}:${audio.title.orEmpty()}" + is PageBlock.Embedded -> "embedded:$url:${posterPhoto?.fileId ?: 0}" + is PageBlock.EmbeddedPost -> "embedded_post:$author:$date:${authorPhoto?.fileId ?: 0}" + is PageBlock.Details -> "details:${richTextPlainText(header)}:${pageBlocks.joinToString("|") { it.stableBlockSignature() }}" + is PageBlock.Collage -> "collage:${caption.stableCaptionSignature()}:${ + pageBlocks.joinToString( + "|" + ) { it.stableBlockSignature() } + }" + + is PageBlock.Slideshow -> "slideshow:${caption.stableCaptionSignature()}:${ + pageBlocks.joinToString( + "|" + ) { it.stableBlockSignature() } + }" + + is PageBlock.Cover -> "cover:${cover.stableBlockSignature()}" + else -> "${this::class.qualifiedName}:${toString()}" + } +} + +private fun PageBlockCaption.stableCaptionSignature(): String = renderedTextOrNull().orEmpty() + @Composable fun InstantViewBlock( block: PageBlock, textSizeMultiplier: Float, searchQuery: String = "", + stateKeyPrefix: String = "instant_view_block", onOpenPhotoFullscreen: (WebPage.Photo, PageBlockCaption) -> Unit = { _, _ -> }, onOpenVideoFullscreen: (String?, Int, Boolean, String?) -> Unit = { _, _, _, _ -> }, onOpenFileExternally: (String?, Int) -> Unit = { _, _ -> } ) { val onUrlClick = LocalOnUrlClick.current LocalUriHandler.current + val blockStateKey = "$stateKeyPrefix:${block.stableBlockSignature()}" when (block) { is PageBlock.Title -> RichTextView( richText = block.title, @@ -727,7 +762,7 @@ fun InstantViewBlock( .fillMaxWidth() .aspectRatio(block.video.width.toFloat() / block.video.height.toFloat()) .clip(RoundedCornerShape(12.dp)), - stateKey = "video:${block.hashCode()}", + stateKey = "video:$blockStateKey", durationSeconds = block.video.duration, previewPath = block.video.thumbnailPath, previewFileId = block.video.thumbnailFileId, @@ -757,7 +792,7 @@ fun InstantViewBlock( .fillMaxWidth() .aspectRatio(block.animation.width.toFloat() / block.animation.height.toFloat()) .clip(RoundedCornerShape(12.dp)), - stateKey = "animation:${block.hashCode()}", + stateKey = "animation:$blockStateKey", durationSeconds = block.animation.duration, previewPath = block.animation.thumbnailPath, previewFileId = block.animation.thumbnailFileId, @@ -906,11 +941,12 @@ fun InstantViewBlock( } } Spacer(modifier = Modifier.height(12.dp)) - block.pageBlocks.forEach { innerBlock -> + block.pageBlocks.forEachIndexed { index, innerBlock -> InstantViewBlock( block = innerBlock, textSizeMultiplier = textSizeMultiplier, searchQuery = searchQuery, + stateKeyPrefix = "$blockStateKey:list:$index", onOpenPhotoFullscreen = onOpenPhotoFullscreen, onOpenVideoFullscreen = onOpenVideoFullscreen, onOpenFileExternally = onOpenFileExternally @@ -992,11 +1028,12 @@ fun InstantViewBlock( color = MaterialTheme.colorScheme.primary ) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - item.pageBlocks.forEach { innerBlock -> + item.pageBlocks.forEachIndexed { index, innerBlock -> InstantViewBlock( block = innerBlock, textSizeMultiplier = textSizeMultiplier, searchQuery = searchQuery, + stateKeyPrefix = "$blockStateKey:list_item:$index", onOpenPhotoFullscreen = onOpenPhotoFullscreen, onOpenVideoFullscreen = onOpenVideoFullscreen, onOpenFileExternally = onOpenFileExternally @@ -1011,6 +1048,7 @@ fun InstantViewBlock( block = block.cover, textSizeMultiplier = textSizeMultiplier, searchQuery = searchQuery, + stateKeyPrefix = "$blockStateKey:cover", onOpenPhotoFullscreen = onOpenPhotoFullscreen, onOpenVideoFullscreen = onOpenVideoFullscreen, onOpenFileExternally = onOpenFileExternally @@ -1018,7 +1056,9 @@ fun InstantViewBlock( is PageBlock.Details -> { val shouldForceOpen = searchQuery.isNotBlank() && block.pageBlocks.any { it.containsText(searchQuery) } - var isOpen by rememberSaveable(block.hashCode()) { mutableStateOf(block.isOpen || shouldForceOpen) } + var isOpen by rememberSaveable(blockStateKey) { + mutableStateOf(block.isOpen || shouldForceOpen) + } LaunchedEffect(shouldForceOpen) { if (shouldForceOpen) isOpen = true @@ -1059,11 +1099,12 @@ fun InstantViewBlock( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - block.pageBlocks.forEach { innerBlock -> + block.pageBlocks.forEachIndexed { index, innerBlock -> InstantViewBlock( block = innerBlock, textSizeMultiplier = textSizeMultiplier, searchQuery = searchQuery, + stateKeyPrefix = "$blockStateKey:details:$index", onOpenPhotoFullscreen = onOpenPhotoFullscreen, onOpenVideoFullscreen = onOpenVideoFullscreen, onOpenFileExternally = onOpenFileExternally @@ -1077,11 +1118,12 @@ fun InstantViewBlock( is PageBlock.Collage -> { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - block.pageBlocks.forEach { innerBlock -> + block.pageBlocks.forEachIndexed { index, innerBlock -> InstantViewBlock( block = innerBlock, textSizeMultiplier = textSizeMultiplier, searchQuery = searchQuery, + stateKeyPrefix = "$blockStateKey:collage:$index", onOpenPhotoFullscreen = onOpenPhotoFullscreen, onOpenVideoFullscreen = onOpenVideoFullscreen, onOpenFileExternally = onOpenFileExternally @@ -1093,11 +1135,12 @@ fun InstantViewBlock( is PageBlock.Slideshow -> { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - block.pageBlocks.forEach { innerBlock -> + block.pageBlocks.forEachIndexed { index, innerBlock -> InstantViewBlock( block = innerBlock, textSizeMultiplier = textSizeMultiplier, searchQuery = searchQuery, + stateKeyPrefix = "$blockStateKey:slideshow:$index", onOpenPhotoFullscreen = onOpenPhotoFullscreen, onOpenVideoFullscreen = onOpenVideoFullscreen, onOpenFileExternally = onOpenFileExternally diff --git a/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt index 200e6325..2c2e92b3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt @@ -65,7 +65,7 @@ fun RichTextView( ) { val onUrlClick = LocalOnUrlClick.current val linkColor = MaterialTheme.colorScheme.primary - val annotatedString = renderRichText(richText, linkColor) + val annotatedString = remember(richText, linkColor) { renderRichText(richText, linkColor) } var textLayoutResult by remember { mutableStateOf(null) } val scaledStyle = style.copy( diff --git a/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewUtils.kt index c573e2d4..c03a81ca 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewUtils.kt @@ -322,8 +322,8 @@ fun RichText.containsText(query: String): Boolean { fun PageBlockCaption.renderedTextOrNull(): String? { val text = buildString { - renderRichText(this@renderedTextOrNull.text).text.takeIf { it.isNotBlank() }?.let(::append) - renderRichText(this@renderedTextOrNull.credit).text.takeIf { it.isNotBlank() } + richTextPlainText(this@renderedTextOrNull.text).takeIf { it.isNotBlank() }?.let(::append) + richTextPlainText(this@renderedTextOrNull.credit).takeIf { it.isNotBlank() } ?.let { credit -> if (isNotEmpty()) append("\n") append(credit) @@ -332,6 +332,8 @@ fun PageBlockCaption.renderedTextOrNull(): String? { return text.ifBlank { null } } +fun richTextPlainText(richText: RichText): String = renderRichText(richText).text + suspend fun FileRepository.resolvePathForViewer( fileId: Int, initialPath: String? diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt index f2677765..5f8fa905 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt @@ -41,6 +41,8 @@ import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope +import org.monogram.presentation.features.chats.common.ChatActionState +import org.monogram.presentation.features.chats.common.ChatActionType import org.monogram.presentation.root.AppComponentContext class DefaultProfileComponent( @@ -53,6 +55,7 @@ class DefaultProfileComponent( private val onEditClicked: () -> Unit = {}, private val onSendMessageClicked: (Long) -> Unit = {}, private val onShowLogsClicked: (Long) -> Unit = {}, + private val onEditContactClicked: (Long) -> Unit = {}, private val onMemberClicked: (Long) -> Unit = {}, private val onMemberLongClicked: (Long, Long) -> Unit = { _, _ -> } ) : ProfileComponent, AppComponentContext by context { @@ -71,6 +74,7 @@ class DefaultProfileComponent( private val gifRepository: GifRepository = container.repositories.gifRepository private val botPreferences: BotPreferencesProvider = container.preferences.botPreferencesProvider private val stringProvider = container.utils.stringProvider() + private val messageDisplayer = container.utils.messageDisplayer() override val downloadUtils: IDownloadUtils = container.utils.downloadUtils() private val scope = componentScope @@ -837,7 +841,7 @@ class DefaultProfileComponent( val chat = _state.value.chat ?: return val shouldMute = !chat.isMuted - scope.launch { + runAction(ChatActionType.Mute) { chatOperationsRepository.toggleMuteChats(setOf(chatId), shouldMute) updateChat(chatId) } @@ -862,7 +866,7 @@ class DefaultProfileComponent( override fun onToggleBlockUser() { val userId = _state.value.user?.id ?: return val shouldBlock = !_state.value.isBlocked - scope.launch { + runAction(if (shouldBlock) ChatActionType.BlockUser else ChatActionType.UnblockUser) { if (shouldBlock) { privacyRepository.blockUser(userId) } else { @@ -874,7 +878,7 @@ class DefaultProfileComponent( } override fun onDeleteChat() { - scope.launch { + runAction(ChatActionType.Delete, closeOnSuccess = true) { chatOperationsRepository.deleteChats(setOf(chatId)) } } @@ -902,32 +906,9 @@ class DefaultProfileComponent( } } - override fun onEditContact(firstName: String, lastName: String) { - val user = _state.value.user ?: return - val trimmedFirstName = firstName.trim() - if (trimmedFirstName.isBlank()) return - val normalizedLastName = lastName.trim().ifBlank { null } - - scope.launch { - runCatching { - userRepository.addContact( - user.copy( - firstName = trimmedFirstName, - lastName = normalizedLastName, - isContact = true - ) - ) - }.onSuccess { - _state.update { current -> - current.copy( - user = current.user?.copy( - firstName = trimmedFirstName, - lastName = normalizedLastName - ) - ) - } - } - } + override fun onEditContact() { + val userId = _state.value.user?.id ?: return + onEditContactClicked(userId) } private fun isGroupOrChannelProfile(snapshot: ProfileComponent.State = _state.value): Boolean { @@ -938,38 +919,27 @@ class DefaultProfileComponent( override fun onToggleContact() { val user = _state.value.user ?: return - scope.launch { - if (user.isContact) { - userRepository.removeContact(user.id) - } else { - userRepository.addContact(user) - } - _state.update { current -> - current.copy(user = current.user?.copy(isContact = !user.isContact)) - } - } + onEditContactClicked(user.id) } override fun onLeave() { - scope.launch { + runAction(ChatActionType.Leave, closeOnSuccess = true) { chatOperationsRepository.leaveChat(chatId) updateChat(chatId) } } override fun onJoinChat() { - scope.launch { + runAction(ChatActionType.Join) { messageRepository.joinChat(chatId) updateChat(chatId) } } override fun onReport(reason: String) { - scope.launch(Dispatchers.IO) { + runAction(ChatActionType.Report) { chatOperationsRepository.reportChat(chatId, reason) - withContext(Dispatchers.Main) { - _state.update { it.copy(isReportVisible = false) } - } + _state.update { it.copy(isReportVisible = false) } } } @@ -1302,4 +1272,35 @@ class DefaultProfileComponent( } } } + + private fun runAction( + action: ChatActionType, + closeOnSuccess: Boolean = false, + block: suspend () -> Unit + ) { + if (_state.value.actionState is ChatActionState.Pending) return + scope.launch { + _state.update { it.copy(actionState = ChatActionState.Pending(action)) } + runCatching { block() } + .onSuccess { + _state.update { it.copy(actionState = ChatActionState.Success(action)) } + if (closeOnSuccess) { + onBackClicked() + } + } + .onFailure { error -> + val message = error.message ?: "Action failed" + _state.update { + it.copy( + actionState = ChatActionState.Failure( + action, + message + ) + ) + } + messageDisplayer.show(message) + } + _state.update { it.copy(actionState = ChatActionState.Idle) } + } + } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt index ce037a14..fec13679 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt @@ -12,6 +12,7 @@ import org.monogram.domain.models.UserModel import org.monogram.domain.repository.ChatMemberStatus import org.monogram.domain.repository.MessageRepository import org.monogram.presentation.core.util.IDownloadUtils +import org.monogram.presentation.features.chats.common.ChatActionState interface ProfileComponent { val state: Value @@ -48,7 +49,7 @@ interface ProfileComponent { fun onSendMessage() fun onToggleBlockUser() fun onDeleteChat() - fun onEditContact(firstName: String, lastName: String) + fun onEditContact() fun onToggleContact() fun onLeave() fun onJoinChat() @@ -138,6 +139,7 @@ interface ProfileComponent { val isQrVisible: Boolean = false, val qrContent: String = "", val isReportVisible: Boolean = false, + val actionState: ChatActionState = ChatActionState.Idle, val statistics: ChatStatisticsModel? = null, val revenueStatistics: ChatRevenueStatisticsModel? = null, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt index 952d3cac..3cec27cb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -26,19 +25,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ExitToApp import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.Person -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -58,7 +49,6 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.window.core.layout.WindowSizeClass import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.koin.compose.koinInject @@ -67,8 +57,6 @@ import org.monogram.domain.models.UserTypeEnum import org.monogram.presentation.R import org.monogram.presentation.core.ui.CollapsingToolbarScaffold import org.monogram.presentation.core.ui.ConfirmationSheet -import org.monogram.presentation.core.ui.ItemPosition -import org.monogram.presentation.core.ui.SettingsTextField import org.monogram.presentation.core.ui.rememberCollapsingToolbarScaffoldState import org.monogram.presentation.core.ui.rememberShimmerBrush import org.monogram.presentation.core.util.CountryManager @@ -76,13 +64,17 @@ import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.core.util.ScrollStrategy import org.monogram.presentation.core.util.getUserStatusText +import org.monogram.presentation.features.chats.common.ChatActionScreenContext +import org.monogram.presentation.features.chats.common.ChatActionState +import org.monogram.presentation.features.chats.common.ChatExitAction +import org.monogram.presentation.features.chats.common.resolveChatActionPolicy +import org.monogram.presentation.features.chats.conversation.ui.content.ReportChatDialog import org.monogram.presentation.features.profile.components.LocationViewer import org.monogram.presentation.features.profile.components.ProfileHeaderTransformed import org.monogram.presentation.features.profile.components.ProfileInfoSection import org.monogram.presentation.features.profile.components.ProfileInfoSectionSkeleton import org.monogram.presentation.features.profile.components.ProfilePermissionsDialog import org.monogram.presentation.features.profile.components.ProfileQRDialog -import org.monogram.presentation.features.profile.components.ProfileReportDialog import org.monogram.presentation.features.profile.components.ProfileTOSDialog import org.monogram.presentation.features.profile.components.ProfileTopBar import org.monogram.presentation.features.profile.components.StatisticsViewer @@ -209,14 +201,36 @@ fun ProfileContent(component: ProfileComponent) { val canEditContactTopBar = !isCurrentUserProfile && !isGroupOrChannel && user?.isContact == true val canToggleContactTopBar = !isCurrentUserProfile && !isGroupOrChannel && user != null && user.type != UserTypeEnum.BOT - val canDeleteTopBar = !isCurrentUserProfile && (!isGroupOrChannel || chat.isMember) + val actionPolicy = remember( + chat?.isGroup, + chat?.isChannel, + chat?.isMember, + chat?.canBeDeletedOnlyForSelf, + chat?.canBeDeletedForAllUsers, + chat?.canBeReported, + user?.id, + isCurrentUserProfile + ) { + resolveChatActionPolicy( + isMainChat = true, + isGroup = chat?.isGroup == true, + isChannel = chat?.isChannel == true, + isMember = chat?.isMember == true, + canDeleteChat = chat?.let { it.canBeDeletedOnlyForSelf || it.canBeDeletedForAllUsers } == true, + canReport = chat?.canBeReported == true, + canJoin = (chat?.isGroup == true || chat?.isChannel == true) && chat?.isMember == false, + canBlockOrUnblock = !isCurrentUserProfile && !isGroupOrChannel && user?.type != UserTypeEnum.BOT, + canPin = false, + context = ChatActionScreenContext.Profile + ) + } + val exitAction = actionPolicy.exitAction + val canLeaveTopBar = !isCurrentUserProfile && exitAction == ChatExitAction.Leave + val canDeleteTopBar = !isCurrentUserProfile && exitAction == ChatExitAction.Delete + val isActionPending = state.actionState is ChatActionState.Pending var showLeaveSheet by remember { mutableStateOf(false) } var showDeleteChatSheet by remember { mutableStateOf(false) } var showBlockSheet by remember { mutableStateOf(false) } - var showEditContactDialog by remember { mutableStateOf(false) } - var editContactFirstName by remember { mutableStateOf("") } - var editContactLastName by remember { mutableStateOf("") } - Box(modifier = Modifier.fillMaxSize()) { Scaffold( modifier = Modifier.semantics { contentDescription = "ProfileContent" }, @@ -240,7 +254,8 @@ fun ProfileContent(component: ProfileComponent) { canReport = canReportTopBar, canBlock = canBlockTopBar, isBlocked = state.isBlocked, - canDelete = canDeleteTopBar, + canLeave = canLeaveTopBar, + canDeleteChat = canDeleteTopBar, onSearch = { Toast.makeText(context, searchNotImplemented, Toast.LENGTH_SHORT).show() }, onShare = { val valueToCopy = shareLink ?: fallbackShareText @@ -255,22 +270,12 @@ fun ProfileContent(component: ProfileComponent) { } }, onEdit = component::onEdit, - onEditContact = { - val currentUser = state.user ?: return@ProfileTopBar - editContactFirstName = currentUser.firstName - editContactLastName = currentUser.lastName.orEmpty() - showEditContactDialog = true - }, + onEditContact = component::onEditContact, onToggleContact = component::onToggleContact, - onReport = component::onShowReport, + onReport = if (!isActionPending) ({ component.onShowReport() }) else ({}), onBlock = { showBlockSheet = true }, - onDelete = { - if (isGroupOrChannel) { - showLeaveSheet = true - } else { - showDeleteChatSheet = true - } - } + onLeave = { showLeaveSheet = true }, + onDeleteChat = { showDeleteChatSheet = true } ) }, containerColor = dynamicContainerColor @@ -369,8 +374,9 @@ fun ProfileContent(component: ProfileComponent) { onToggleMute = component::onToggleMute, onShowQRCode = component::onShowQRCode, onEdit = component::onEdit, + exitAction = exitAction, onLeave = { showLeaveSheet = true }, - onJoin = component::onJoinChat, + onJoin = if (!isActionPending) ({ component.onJoinChat() }) else ({}), onShowLogs = component::onShowLogs, onShowStatistics = component::onShowStatistics, onShowRevenueStatistics = component::onShowRevenueStatistics, @@ -411,13 +417,14 @@ fun ProfileContent(component: ProfileComponent) { onDismiss = component::onDismissQRCode ) - ProfileReportDialog( - state = state, - onDismiss = component::onDismissReport, - onReport = component::onReport - ) + if (state.isReportVisible) { + ReportChatDialog( + onDismiss = component::onDismissReport, + onReasonSelected = component::onReport + ) + } - if (showLeaveSheet) { + if (showLeaveSheet && canLeaveTopBar) { ConfirmationSheet( icon = Icons.AutoMirrored.Rounded.ExitToApp, title = stringResource(R.string.leave_chat_title), @@ -425,13 +432,12 @@ fun ProfileContent(component: ProfileComponent) { confirmText = stringResource(R.string.action_leave), onConfirm = { component.onLeave() - showLeaveSheet = false }, onDismiss = { showLeaveSheet = false } ) } - if (showDeleteChatSheet && !isGroupOrChannel) { + if (showDeleteChatSheet && canDeleteTopBar) { ConfirmationSheet( icon = Icons.Rounded.Delete, title = stringResource(R.string.delete_chat_title), @@ -439,7 +445,6 @@ fun ProfileContent(component: ProfileComponent) { confirmText = stringResource(R.string.action_delete_chat), onConfirm = { component.onDeleteChat() - showDeleteChatSheet = false }, onDismiss = { showDeleteChatSheet = false } ) @@ -455,98 +460,12 @@ fun ProfileContent(component: ProfileComponent) { confirmText = if (state.isBlocked) stringResource(R.string.privacy_unblock_action) else stringResource(R.string.action_block), onConfirm = { component.onToggleBlockUser() - showBlockSheet = false }, onDismiss = { showBlockSheet = false }, isDestructive = !state.isBlocked ) } - if (showEditContactDialog && canEditContactTopBar && state.user != null) { - ModalBottomSheet( - onDismissRequest = { showEditContactDialog = false }, - dragHandle = { BottomSheetDefaults.DragHandle() }, - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onSurface, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 32.dp) - ) { - Text( - text = stringResource(R.string.edit_contact_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = androidx.compose.ui.text.font.FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(12.dp)) - - SettingsTextField( - value = editContactFirstName, - onValueChange = { editContactFirstName = it }, - placeholder = stringResource(R.string.first_name_label), - icon = Icons.Rounded.Person, - position = ItemPosition.TOP, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - SettingsTextField( - value = editContactLastName, - onValueChange = { editContactLastName = it }, - placeholder = stringResource(R.string.last_name_label), - icon = Icons.Rounded.Edit, - position = ItemPosition.BOTTOM, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = { showEditContactDialog = false }, - modifier = Modifier - .weight(1f) - .height(56.dp), - shape = RoundedCornerShape(16.dp) - ) { - Text( - stringResource(R.string.cancel_button), - fontSize = 16.sp, - fontWeight = androidx.compose.ui.text.font.FontWeight.Bold - ) - } - - Button( - onClick = { - component.onEditContact(editContactFirstName, editContactLastName) - showEditContactDialog = false - }, - enabled = editContactFirstName.isNotBlank(), - modifier = Modifier - .weight(1f) - .height(56.dp), - shape = RoundedCornerShape(16.dp) - ) { - Text( - stringResource(R.string.action_save), - fontSize = 16.sp, - fontWeight = androidx.compose.ui.text.font.FontWeight.Bold - ) - } - } - } - } - } - if (state.miniAppUrl == null) { ProfilePermissionsDialog( state = state, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt index b79374d1..912c29af 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt @@ -1,6 +1,7 @@ package org.monogram.presentation.features.profile import android.content.ClipData +import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -29,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp @@ -63,6 +65,9 @@ fun ProfileViewers( @Composable private fun InstantViewOverlay(state: ProfileComponent.State, component: ProfileComponent) { + val context = LocalContext.current + val openErrorMessage = context.getString(R.string.instant_view_open_error) + AnimatedVisibility( visible = state.instantViewUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), @@ -74,7 +79,11 @@ private fun InstantViewOverlay(state: ProfileComponent.State, component: Profile messageRepository = component.messageRepository, fileRepository = component.messageRepository, onDismiss = { component.onDismissInstantView() }, - onOpenWebView = { component.onOpenWebView(it) } + onOpenWebView = { component.onOpenWebView(it) }, + onOpenError = { + Toast.makeText(context, openErrorMessage, Toast.LENGTH_SHORT).show() + component.onDismissInstantView() + } ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditComponent.kt index f38ff60b..505cd8a1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditComponent.kt @@ -2,6 +2,8 @@ package org.monogram.presentation.features.profile.admin import com.arkivanov.decompose.value.Value import org.monogram.domain.models.ChatModel +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.presentation.features.editing.EditorScreenState interface ChatEditComponent { val state: Value @@ -12,15 +14,19 @@ interface ChatEditComponent { fun onUpdateUsername(username: String) fun onTogglePublic(isPublic: Boolean) fun onToggleTopics(enabled: Boolean) - fun onToggleAutoTranslate(enabled: Boolean) + fun onTogglePermission(permission: Permission) + fun onToggleProtectedContent(enabled: Boolean) + fun onToggleSignMessages(enabled: Boolean) + fun onToggleJoinToSendMessages(enabled: Boolean) fun onChangeAvatar(path: String) fun onSave() fun onDeleteChat() + fun onDismissDiscardChanges() + fun onConfirmDiscardChanges() fun onManageAdmins() fun onManageMembers() fun onManageBlacklist() - fun onManagePermissions() data class State( val chatId: Long, @@ -30,9 +36,19 @@ interface ChatEditComponent { val username: String = "", val isPublic: Boolean = false, val isForum: Boolean = false, - val isTranslatable: Boolean = false, + val linkedChatId: Long = 0L, + val hasProtectedContent: Boolean = false, + val signMessages: Boolean = false, + val joinToSendMessages: Boolean = false, + val permissions: ChatPermissionsModel = ChatPermissionsModel(), val avatarPath: String? = null, val isLoading: Boolean = false, - val canDelete: Boolean = false + val canDelete: Boolean = false, + val editor: EditorScreenState = EditorScreenState() ) + + enum class Permission { + SEND_MESSAGES, SEND_MEDIA, SEND_STICKERS, SEND_POLLS, + EMBED_LINKS, REACT_TO_MESSAGES, ADD_MEMBERS, PIN_MESSAGES, CHANGE_INFO, MANAGE_TOPICS + } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditContent.kt index 8550158e..b0a4f12a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditContent.kt @@ -5,14 +5,47 @@ import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* +import androidx.compose.material.icons.rounded.AdminPanelSettings +import androidx.compose.material.icons.rounded.AlternateEmail +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.CameraAlt +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.Draw +import androidx.compose.material.icons.rounded.Groups +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.Login +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Title +import androidx.compose.material.icons.rounded.Topic +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -31,17 +64,21 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ConfirmationSheet -import org.monogram.presentation.core.util.FileUtils -import org.monogram.presentation.core.ui.SettingsTextField import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsSwitchTile +import org.monogram.presentation.core.ui.SettingsTextField import org.monogram.presentation.core.ui.SettingsTile +import org.monogram.presentation.core.util.FileUtils @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatEditContent(component: ChatEditComponent) { val state by component.state.subscribeAsState() - val isChannel = state.chat?.isChannel == true + val chat = state.chat + val isChannel = chat?.isChannel == true + val isGroup = chat?.isGroup == true + val showJoinToSendMessages = isGroup && state.isPublic + val showTopics = isGroup && state.linkedChatId == 0L val context = LocalContext.current var showDeleteChatSheet by remember { mutableStateOf(false) } @@ -60,10 +97,9 @@ fun ChatEditContent(component: ChatEditComponent) { TopAppBar( title = { Text( - text = if (isChannel) stringResource(R.string.edit_channel) else stringResource(R.string.edit_group), + text = stringResource(R.string.edit), fontSize = 22.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + fontWeight = FontWeight.Bold ) }, navigationIcon = { @@ -72,60 +108,89 @@ fun ChatEditContent(component: ChatEditComponent) { } }, actions = { - IconButton(onClick = component::onSave, enabled = !state.isLoading) { + IconButton(onClick = component::onSave, enabled = state.editor.canSave) { Icon( Icons.Rounded.Check, - contentDescription = stringResource(R.string.save), - tint = MaterialTheme.colorScheme.primary + contentDescription = stringResource(R.string.save) ) } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background, - scrolledContainerColor = MaterialTheme.colorScheme.background - ) + } ) - }, - containerColor = MaterialTheme.colorScheme.background + } ) { padding -> + if (state.isLoading || chat == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + return@Scaffold + } + LazyColumn( modifier = Modifier .fillMaxSize() - .padding(top = padding.calculateTopPadding()), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 16.dp) + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { item { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - contentAlignment = Alignment.Center + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(28.dp), + modifier = Modifier.fillMaxWidth() ) { - Box( + Column( modifier = Modifier - .size(100.dp) - .clip(CircleShape) - .clickable { - photoPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - }, - contentAlignment = Alignment.Center + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Avatar(path = state.avatarPath, name = state.title, size = 100.dp) Box( modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f)), + .size(104.dp) + .clip(CircleShape) + .clickable { + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, contentAlignment = Alignment.Center ) { - Icon( - Icons.Rounded.CameraAlt, - null, - tint = Color.White, - modifier = Modifier.size(32.dp) - ) + Avatar(path = state.avatarPath, name = state.title, size = 104.dp) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Rounded.CameraAlt, + null, + tint = Color.White, + modifier = Modifier.size(30.dp) + ) + } } + + Spacer(Modifier.height(18.dp)) + Text( + text = state.title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(4.dp)) + Text( + text = state.description.ifEmpty { + state.chat?.username?.let { "@$it" } ?: "" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2 + ) } } } @@ -136,7 +201,8 @@ fun ChatEditContent(component: ChatEditComponent) { onValueChange = component::onUpdateTitle, placeholder = if (isChannel) stringResource(R.string.channel_name) else stringResource(R.string.group_name), icon = Icons.Rounded.Title, - position = ItemPosition.TOP + position = ItemPosition.TOP, + minLines = 1 ) SettingsTextField( value = state.description, @@ -144,7 +210,8 @@ fun ChatEditContent(component: ChatEditComponent) { placeholder = stringResource(R.string.description), icon = Icons.Rounded.Description, position = ItemPosition.BOTTOM, - singleLine = false + singleLine = false, + minLines = 2 ) } @@ -164,45 +231,75 @@ fun ChatEditContent(component: ChatEditComponent) { onValueChange = component::onUpdateUsername, placeholder = stringResource(R.string.username), icon = Icons.Rounded.AlternateEmail, - position = ItemPosition.MIDDLE + position = ItemPosition.BOTTOM ) - - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(4.dp), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = "https://t.me/${state.username.ifEmpty { "username" }}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) - ) - } - Spacer(Modifier.height(2.dp)) } + } - if (isChannel) { + if (isChannel) { + item { + SectionHeader(stringResource(R.string.channels_title)) SettingsSwitchTile( - title = stringResource(R.string.auto_translate), - icon = Icons.Rounded.Translate, - checked = state.isTranslatable, - onCheckedChange = component::onToggleAutoTranslate, + title = stringResource(R.string.protected_content_title), + subtitle = stringResource(R.string.protected_content_subtitle), + icon = Icons.Rounded.Lock, + checked = state.hasProtectedContent, + onCheckedChange = component::onToggleProtectedContent, iconColor = MaterialTheme.colorScheme.primary, - position = ItemPosition.BOTTOM + position = ItemPosition.TOP ) - } else { SettingsSwitchTile( - title = stringResource(R.string.topics), - icon = Icons.Rounded.Topic, - checked = state.isForum, - onCheckedChange = component::onToggleTopics, + title = stringResource(R.string.permission_sign_messages), + subtitle = stringResource(R.string.permission_sign_messages_subtitle), + icon = Icons.Rounded.Draw, + checked = state.signMessages, + onCheckedChange = component::onToggleSignMessages, iconColor = MaterialTheme.colorScheme.primary, position = ItemPosition.BOTTOM ) } } + if (isGroup) { + item { + SectionHeader(stringResource(R.string.groups_title)) + SettingsSwitchTile( + title = stringResource(R.string.protected_content_title), + subtitle = stringResource(R.string.protected_content_subtitle), + icon = Icons.Rounded.Lock, + checked = state.hasProtectedContent, + onCheckedChange = component::onToggleProtectedContent, + iconColor = MaterialTheme.colorScheme.primary, + position = when { + showJoinToSendMessages || showTopics -> ItemPosition.TOP + else -> ItemPosition.STANDALONE + } + ) + if (showJoinToSendMessages) { + SettingsSwitchTile( + title = stringResource(R.string.permission_join_to_send_messages), + subtitle = stringResource(R.string.permission_join_to_send_messages_subtitle), + icon = Icons.Rounded.Login, + checked = state.joinToSendMessages, + onCheckedChange = component::onToggleJoinToSendMessages, + iconColor = MaterialTheme.colorScheme.primary, + position = if (showTopics) ItemPosition.MIDDLE else ItemPosition.BOTTOM + ) + } + if (showTopics) { + SettingsSwitchTile( + title = stringResource(R.string.topics), + subtitle = stringResource(R.string.topics_subtitle), + icon = Icons.Rounded.Topic, + checked = state.isForum, + onCheckedChange = component::onToggleTopics, + iconColor = MaterialTheme.colorScheme.primary, + position = ItemPosition.BOTTOM + ) + } + } + } + item { SectionHeader(stringResource(R.string.management)) SettingsTile( @@ -219,13 +316,6 @@ fun ChatEditContent(component: ChatEditComponent) { position = ItemPosition.MIDDLE, onClick = component::onManageMembers ) - SettingsTile( - title = stringResource(R.string.permissions), - icon = Icons.Rounded.Lock, - iconColor = MaterialTheme.colorScheme.primary, - position = ItemPosition.MIDDLE, - onClick = component::onManagePermissions - ) SettingsTile( title = stringResource(R.string.blacklist), icon = Icons.Rounded.Block, @@ -236,7 +326,7 @@ fun ChatEditContent(component: ChatEditComponent) { } item { - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(8.dp)) Button( onClick = { showDeleteChatSheet = true }, colors = ButtonDefaults.buttonColors( @@ -271,13 +361,24 @@ fun ChatEditContent(component: ChatEditComponent) { onDismiss = { showDeleteChatSheet = false } ) } + + if (state.editor.showDiscardChangesDialog) { + ConfirmationSheet( + icon = Icons.Rounded.Delete, + title = stringResource(R.string.photo_editor_discard_title), + description = stringResource(R.string.photo_editor_discard_message), + confirmText = stringResource(R.string.photo_editor_discard_button), + onConfirm = component::onConfirmDiscardChanges, + onDismiss = component::onDismissDiscardChanges + ) + } } @Composable private fun SectionHeader(text: String) { Text( text = text, - modifier = Modifier.padding(start = 12.dp, top = 24.dp, bottom = 8.dp), + modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 8.dp), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditPermissionMutation.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditPermissionMutation.kt new file mode 100644 index 00000000..5c22fb0f --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/ChatEditPermissionMutation.kt @@ -0,0 +1,18 @@ +package org.monogram.presentation.features.profile.admin + +import org.monogram.domain.models.ChatPermissionsModel + +internal fun ChatPermissionsModel.toggle(permission: ChatEditComponent.Permission): ChatPermissionsModel { + return when (permission) { + ChatEditComponent.Permission.SEND_MESSAGES -> copy(canSendBasicMessages = !canSendBasicMessages) + ChatEditComponent.Permission.SEND_MEDIA -> copy(canSendPhotos = !canSendPhotos) + ChatEditComponent.Permission.SEND_STICKERS -> copy(canSendOtherMessages = !canSendOtherMessages) + ChatEditComponent.Permission.SEND_POLLS -> copy(canSendPolls = !canSendPolls) + ChatEditComponent.Permission.EMBED_LINKS -> copy(canAddLinkPreviews = !canAddLinkPreviews) + ChatEditComponent.Permission.REACT_TO_MESSAGES -> copy(canReactToMessages = !canReactToMessages) + ChatEditComponent.Permission.ADD_MEMBERS -> copy(canInviteUsers = !canInviteUsers) + ChatEditComponent.Permission.PIN_MESSAGES -> copy(canPinMessages = !canPinMessages) + ChatEditComponent.Permission.CHANGE_INFO -> copy(canChangeInfo = !canChangeInfo) + ChatEditComponent.Permission.MANAGE_TOPICS -> copy(canCreateTopics = !canCreateTopics) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultChatEditComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultChatEditComponent.kt index aaa0c083..78c25ca9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultChatEditComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/admin/DefaultChatEditComponent.kt @@ -4,11 +4,13 @@ import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.launch +import org.monogram.domain.models.ChatPermissionsModel import org.monogram.domain.repository.ChatInfoRepository import org.monogram.domain.repository.ChatListRepository import org.monogram.domain.repository.ChatOperationsRepository import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.presentation.core.util.componentScope +import org.monogram.presentation.features.editing.EditorPrimitives import org.monogram.presentation.root.AppComponentContext class DefaultChatEditComponent( @@ -17,8 +19,7 @@ class DefaultChatEditComponent( private val onBackClicked: () -> Unit, private val onManageAdminsClicked: (Long) -> Unit, private val onManageMembersClicked: (Long) -> Unit, - private val onManageBlacklistClicked: (Long) -> Unit, - private val onManagePermissionsClicked: (Long) -> Unit + private val onManageBlacklistClicked: (Long) -> Unit ) : ChatEditComponent, AppComponentContext by context { private val chatListRepository: ChatListRepository = container.repositories.chatListRepository @@ -30,6 +31,17 @@ class DefaultChatEditComponent( private val _state = MutableValue(ChatEditComponent.State(chatId = chatId)) override val state: Value = _state + private var initialDescription: String = "" + private var initialUsername: String = "" + private var initialIsPublic: Boolean = false + private var initialPermissions = ChatPermissionsModel() + private var initialAvatarPath: String? = null + private var initialHasProtectedContent: Boolean = false + private var initialSignMessages: Boolean = false + private var initialJoinToSendMessages: Boolean = false + private var initialIsForum: Boolean = false + private var initialLinkedChatId: Long = 0L + init { loadChatData() } @@ -40,6 +52,16 @@ class DefaultChatEditComponent( val chat = chatListRepository.getChatById(chatId) val fullInfo = chatInfoRepository.getChatFullInfo(chatId) if (chat != null) { + initialDescription = fullInfo?.description ?: "" + initialUsername = chat.username ?: "" + initialIsPublic = !chat.username.isNullOrEmpty() + initialPermissions = chat.permissions + initialAvatarPath = chat.avatarPath + initialHasProtectedContent = chat.hasProtectedContent + initialSignMessages = chat.signMessages + initialJoinToSendMessages = chat.joinToSendMessages + initialIsForum = chat.isForum + initialLinkedChatId = fullInfo?.linkedChatId ?: 0L _state.update { it.copy( chat = chat, @@ -48,50 +70,123 @@ class DefaultChatEditComponent( username = chat.username ?: "", isPublic = !chat.username.isNullOrEmpty(), isForum = chat.isForum, - isTranslatable = if (chat.isChannel) chat.hasAutomaticTranslation else chat.isTranslatable, + linkedChatId = fullInfo?.linkedChatId ?: 0L, + hasProtectedContent = chat.hasProtectedContent, + signMessages = chat.signMessages, + joinToSendMessages = chat.joinToSendMessages, + permissions = chat.permissions, avatarPath = chat.avatarPath, canDelete = chat.isAdmin, isLoading = false ) } + } else { + _state.update { it.copy(isLoading = false) } } } } - override fun onBack() = onBackClicked() + override fun onBack() { + if (_state.value.editor.isDirty) { + _state.update { it.copy(editor = it.editor.copy(showDiscardChangesDialog = true)) } + } else { + onBackClicked() + } + } + + override fun onDismissDiscardChanges() { + _state.update { it.copy(editor = it.editor.copy(showDiscardChangesDialog = false)) } + } + + override fun onConfirmDiscardChanges() { + _state.update { it.copy(editor = it.editor.copy(showDiscardChangesDialog = false)) } + onBackClicked() + } override fun onUpdateTitle(title: String) { - _state.update { it.copy(title = title) } + _state.update { + it.copy( + title = title, + editor = it.editor.copy(isDirty = hasChanges(title = title)) + ) + } } override fun onUpdateDescription(description: String) { - _state.update { it.copy(description = description) } + _state.update { + it.copy( + description = description, + editor = it.editor.copy(isDirty = hasChanges(description = description)) + ) + } } override fun onUpdateUsername(username: String) { - _state.update { it.copy(username = username) } + _state.update { + it.copy( + username = username, + editor = it.editor.copy(isDirty = hasChanges(username = username)) + ) + } } override fun onTogglePublic(isPublic: Boolean) { - _state.update { it.copy(isPublic = isPublic) } + _state.update { + it.copy( + isPublic = isPublic, + editor = it.editor.copy(isDirty = hasChanges(isPublic = isPublic)) + ) + } } override fun onToggleTopics(enabled: Boolean) { - _state.update { it.copy(isForum = enabled) } - scope.launch { - chatSettingsRepository.toggleChatIsForum(chatId, enabled) + _state.update { + it.copy( + isForum = enabled, + editor = it.editor.copy(isDirty = hasChanges(isForum = enabled)) + ) } } - override fun onToggleAutoTranslate(enabled: Boolean) { - _state.update { it.copy(isTranslatable = enabled) } - scope.launch { - chatSettingsRepository.toggleChatIsTranslatable(chatId, enabled) + override fun onTogglePermission(permission: ChatEditComponent.Permission) { + val updated = _state.value.permissions.toggle(permission) + _state.update { + it.copy( + permissions = updated, + editor = it.editor.copy(isDirty = hasChanges(permissions = updated)) + ) + } + } + + override fun onToggleProtectedContent(enabled: Boolean) { + _state.update { + it.copy( + hasProtectedContent = enabled, + editor = it.editor.copy(isDirty = hasChanges(hasProtectedContent = enabled)) + ) + } + } + + override fun onToggleSignMessages(enabled: Boolean) { + _state.update { + it.copy( + signMessages = enabled, + editor = it.editor.copy(isDirty = hasChanges(signMessages = enabled)) + ) + } + } + + override fun onToggleJoinToSendMessages(enabled: Boolean) { + _state.update { + it.copy( + joinToSendMessages = enabled, + editor = it.editor.copy(isDirty = hasChanges(joinToSendMessages = enabled)) + ) } } override fun onChangeAvatar(path: String) { - _state.update { it.copy(avatarPath = path) } + _state.update { it.copy(avatarPath = path, editor = it.editor.copy(isDirty = true)) } scope.launch { chatSettingsRepository.setChatPhoto(chatId, path) } @@ -99,7 +194,12 @@ class DefaultChatEditComponent( override fun onSave() { scope.launch { - _state.update { it.copy(isLoading = true) } + _state.update { + it.copy( + isLoading = true, + editor = it.editor.copy(isSaving = true, error = null) + ) + } val currentState = _state.value if (currentState.title != currentState.chat?.title) { chatSettingsRepository.setChatTitle(chatId, currentState.title) @@ -111,6 +211,44 @@ class DefaultChatEditComponent( if (currentState.username != (currentState.chat?.username ?: "")) { chatSettingsRepository.setChatUsername(chatId, currentState.username) } + if (currentState.permissions != initialPermissions && currentState.chat?.isChannel != true) { + chatSettingsRepository.setChatPermissions(chatId, currentState.permissions) + } + if (currentState.hasProtectedContent != initialHasProtectedContent) { + chatSettingsRepository.setChatHasProtectedContent( + chatId, + currentState.hasProtectedContent + ) + } + if (currentState.signMessages != initialSignMessages && currentState.chat?.isChannel == true) { + chatSettingsRepository.setChatSignMessages(chatId, currentState.signMessages) + } + if (currentState.joinToSendMessages != initialJoinToSendMessages && currentState.chat?.isChannel != true) { + chatSettingsRepository.setChatJoinToSendMessages( + chatId, + currentState.joinToSendMessages + ) + } + if (currentState.isForum != initialIsForum && currentState.chat?.isChannel != true) { + chatSettingsRepository.toggleChatIsForum(chatId, currentState.isForum) + } + + initialDescription = currentState.description + initialUsername = currentState.username + initialIsPublic = currentState.isPublic + initialPermissions = currentState.permissions + initialAvatarPath = currentState.avatarPath + initialHasProtectedContent = currentState.hasProtectedContent + initialSignMessages = currentState.signMessages + initialJoinToSendMessages = currentState.joinToSendMessages + initialIsForum = currentState.isForum + + _state.update { + it.copy( + isLoading = false, + editor = EditorPrimitives.updateState(isDirty = false) + ) + } onBackClicked() } } @@ -125,5 +263,28 @@ class DefaultChatEditComponent( override fun onManageAdmins() = onManageAdminsClicked(chatId) override fun onManageMembers() = onManageMembersClicked(chatId) override fun onManageBlacklist() = onManageBlacklistClicked(chatId) - override fun onManagePermissions() = onManagePermissionsClicked(chatId) + + private fun hasChanges( + title: String = _state.value.title, + description: String = _state.value.description, + username: String = _state.value.username, + isPublic: Boolean = _state.value.isPublic, + isForum: Boolean = _state.value.isForum, + hasProtectedContent: Boolean = _state.value.hasProtectedContent, + signMessages: Boolean = _state.value.signMessages, + joinToSendMessages: Boolean = _state.value.joinToSendMessages, + permissions: ChatPermissionsModel = _state.value.permissions + ): Boolean { + val chat = _state.value.chat ?: return false + return title != chat.title || + description != initialDescription || + username != initialUsername || + isPublic != initialIsPublic || + isForum != initialIsForum || + hasProtectedContent != initialHasProtectedContent || + signMessages != initialSignMessages || + joinToSendMessages != initialJoinToSendMessages || + permissions != initialPermissions || + _state.value.avatarPath != initialAvatarPath + } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt index fcbcec8c..06c0599f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt @@ -110,6 +110,7 @@ import org.monogram.presentation.core.ui.saveBitmapToGallery import org.monogram.presentation.core.ui.shareBitmap import org.monogram.presentation.core.util.CountryManager import org.monogram.presentation.core.util.OperatorManager +import org.monogram.presentation.features.chats.common.ChatExitAction import org.monogram.presentation.features.profile.ProfileComponent import java.util.Calendar @@ -336,6 +337,7 @@ fun ProfileInfoSection( onToggleMute: () -> Unit = {}, onShowQRCode: () -> Unit = {}, onEdit: () -> Unit = {}, + exitAction: ChatExitAction = ChatExitAction.None, onLeave: () -> Unit = {}, onJoin: () -> Unit = {}, onShowLogs: () -> Unit = {}, @@ -428,8 +430,8 @@ fun ProfileInfoSection( if (!isCurrentUser) { ProfileQuickActions( state = state, - isGroupOrChannel = isGroupOrChannel, isCurrentUser = isCurrentUser, + exitAction = exitAction, onSendMessage = onSendMessage, onLeave = onLeave, onJoin = onJoin, @@ -1092,8 +1094,8 @@ private fun compactTileShape(position: ItemPosition): RoundedCornerShape { @Composable private fun ProfileQuickActions( state: ProfileComponent.State, - isGroupOrChannel: Boolean, isCurrentUser: Boolean, + exitAction: ChatExitAction, onSendMessage: () -> Unit, onLeave: () -> Unit, onJoin: () -> Unit, @@ -1118,14 +1120,14 @@ private fun ProfileQuickActions( ) } - if (isGroupOrChannel) { - if (chat?.isMember == true) { + if (chat?.isGroup == true || chat?.isChannel == true) { + if (exitAction == ChatExitAction.Leave) { items += QuickActionConfig( icon = Icons.AutoMirrored.Rounded.Logout, label = stringResource(R.string.menu_leave), onClick = onLeave ) - } else { + } else if (chat.isMember == false) { items += QuickActionConfig( icon = Icons.AutoMirrored.Rounded.Login, label = stringResource(R.string.action_join_chat), diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt index 95057c69..e6132db8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.ExitToApp import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.Delete @@ -92,7 +93,8 @@ fun ProfileTopBar( canReport: Boolean = false, canBlock: Boolean = false, isBlocked: Boolean = false, - canDelete: Boolean = false, + canLeave: Boolean = false, + canDeleteChat: Boolean = false, onSearch: () -> Unit = {}, onShare: () -> Unit = {}, onEdit: () -> Unit = {}, @@ -100,11 +102,12 @@ fun ProfileTopBar( onToggleContact: () -> Unit = {}, onReport: () -> Unit = {}, onBlock: () -> Unit = {}, - onDelete: () -> Unit = {} + onLeave: () -> Unit = {}, + onDeleteChat: () -> Unit = {} ) { var showMenu by remember { mutableStateOf(false) } val hasMenuActions = - canShare || canEdit || canEditContact || canToggleContact || canReport || canBlock || canDelete + canShare || canEdit || canEditContact || canToggleContact || canReport || canBlock || canLeave || canDeleteChat val iconButtonShapes = ExpressiveDefaults.iconButtonShapes() val hasTextStatusBadges = isBot || isScam || isFake val shouldCompactTopBar = hasTextStatusBadges && (title.length >= 18 || canSearch || hasMenuActions) @@ -353,18 +356,25 @@ fun ProfileTopBar( } ) } - if (canDelete) { + if (canLeave) { + MenuOptionRow( + icon = Icons.AutoMirrored.Rounded.ExitToApp, + title = stringResource(R.string.menu_leave), + destructive = true, + onClick = { + showMenu = false + onLeave() + } + ) + } + if (canDeleteChat) { MenuOptionRow( icon = Icons.Rounded.Delete, - title = if (chatModel?.isGroup == true || chatModel?.isChannel == true) stringResource( - R.string.menu_leave - ) else stringResource( - R.string.menu_delete_chat - ), + title = stringResource(R.string.menu_delete_chat), destructive = true, onClick = { showMenu = false - onDelete() + onDeleteChat() } ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/contact/ContactEditComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/contact/ContactEditComponent.kt new file mode 100644 index 00000000..4d60c618 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/contact/ContactEditComponent.kt @@ -0,0 +1,32 @@ +package org.monogram.presentation.features.profile.contact + +import com.arkivanov.decompose.value.Value +import org.monogram.domain.models.UserModel +import org.monogram.presentation.features.editing.EditorScreenState + +interface ContactEditComponent { + val state: Value + + fun onBack() + fun onDismissDiscardChanges() + fun onConfirmDiscardChanges() + fun onDismissError() + fun onUpdateFirstName(firstName: String) + fun onUpdateLastName(lastName: String) + fun onToggleSharePhoneNumber(sharePhoneNumber: Boolean) + fun onSave() + fun onRemoveContact() + + data class State( + val userId: Long, + val user: UserModel? = null, + val firstName: String = "", + val lastName: String = "", + val phoneNumber: String? = null, + val sharePhoneNumber: Boolean = false, + val needPhoneNumberPrivacyException: Boolean = false, + val canEditPersonalPhoto: Boolean = false, + val editor: EditorScreenState = EditorScreenState(), + val isLoading: Boolean = false + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/contact/ContactEditContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/contact/ContactEditContent.kt new file mode 100644 index 00000000..39a6721b --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/contact/ContactEditContent.kt @@ -0,0 +1,346 @@ +package org.monogram.presentation.features.profile.contact + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import org.monogram.presentation.R +import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.ui.ConfirmationSheet +import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SettingsTextField +import org.monogram.presentation.core.ui.shimmerBackground + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactEditContent(component: ContactEditComponent) { + val state by component.state.subscribeAsState() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.editor.error) { + val message = state.editor.error ?: return@LaunchedEffect + snackbarHostState.showSnackbar(message) + component.onDismissError() + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + stringResource(R.string.edit_contact_title_short), + fontWeight = FontWeight.SemiBold + ) + }, + navigationIcon = { + IconButton(onClick = component::onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.cd_back) + ) + } + }, + actions = { + TextButton( + onClick = component::onSave, + enabled = state.editor.canSave && state.firstName.isNotBlank() + ) { + Text(stringResource(R.string.action_save)) + } + } + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + containerColor = MaterialTheme.colorScheme.background + ) { padding -> + if (state.isLoading) { + ContactEditLoading( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + ContactEditHeader(state = state) + } + + item { + Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { + SettingsTextField( + value = state.firstName, + onValueChange = component::onUpdateFirstName, + placeholder = stringResource(R.string.first_name_label), + icon = Icons.Rounded.Person, + position = ItemPosition.TOP, + itemSpacing = 0.dp + ) + SettingsTextField( + value = state.lastName, + onValueChange = component::onUpdateLastName, + placeholder = stringResource(R.string.last_name_label), + icon = Icons.Rounded.Person, + position = ItemPosition.BOTTOM, + itemSpacing = 0.dp + ) + } + } + + if (!state.phoneNumber.isNullOrBlank()) { + item { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + shape = RoundedCornerShape(24.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.contact_edit_share_phone_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = if (state.needPhoneNumberPrivacyException) { + stringResource(R.string.contact_edit_share_phone_required) + } else { + stringResource(R.string.contact_edit_share_phone_subtitle) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + modifier = Modifier.padding(start = 12.dp), + checked = state.sharePhoneNumber, + onCheckedChange = component::onToggleSharePhoneNumber, + enabled = !state.editor.isSaving + ) + } + } + } + } + item { + Button( + onClick = component::onRemoveContact, + enabled = state.user?.isContact == true && !state.editor.isSaving, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Rounded.Delete, contentDescription = null) + Text( + text = stringResource(R.string.action_remove_contact), + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } + } + } + + if (state.editor.showDiscardChangesDialog) { + ConfirmationSheet( + icon = Icons.Rounded.Delete, + title = stringResource(R.string.photo_editor_discard_title), + description = stringResource(R.string.photo_editor_discard_message), + confirmText = stringResource(R.string.photo_editor_discard_button), + onConfirm = component::onConfirmDiscardChanges, + onDismiss = component::onDismissDiscardChanges + ) + } +} + +@Composable +private fun ContactEditHeader(state: ContactEditComponent.State) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + shape = RoundedCornerShape(32.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Avatar( + path = state.user?.avatarPath, + fallbackPath = state.user?.personalAvatarPath, + name = contactDisplayName( + state.user?.firstName, + state.user?.lastName, + state.phoneNumber + ), + size = 96.dp, + fontSize = 22 + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = listOfNotNull( + state.user?.firstName?.takeIf { it.isNotBlank() }, + state.user?.lastName?.takeIf { it.isNotBlank() } + ).joinToString(" "), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + if (!state.phoneNumber.isNullOrBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = state.phoneNumber.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } +} + +private fun contactDisplayName( + firstName: String?, + lastName: String?, + phoneNumber: String? +): String { + val fullName = listOfNotNull( + firstName?.takeIf { it.isNotBlank() }, + lastName?.takeIf { it.isNotBlank() } + ).joinToString(" ") + return fullName.ifBlank { phoneNumber.orEmpty() } +} + +@Composable +private fun ContactEditLoading(modifier: Modifier = Modifier) { + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + shape = RoundedCornerShape(32.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(96.dp) + .clip(CircleShape) + .shimmerBackground(shape = CircleShape) + ) + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = Modifier + .fillMaxWidth(0.6f) + .height(24.dp) + .shimmerBackground(shape = RoundedCornerShape(12.dp)) + ) + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth(0.4f) + .height(16.dp) + .shimmerBackground(shape = RoundedCornerShape(12.dp)) + ) + } + } + } + + items((0 until 3).toList()) { index -> + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + shape = RoundedCornerShape(24.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.35f) + .height(16.dp) + .shimmerBackground(shape = RoundedCornerShape(12.dp)) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(if (index == 2) 52.dp else 64.dp) + .shimmerBackground(shape = RoundedCornerShape(20.dp)) + ) + } + } + } + + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + .shimmerBackground(shape = RoundedCornerShape(16.dp)) + ) + } + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/contact/ContactEditDraft.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/contact/ContactEditDraft.kt new file mode 100644 index 00000000..31747e42 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/contact/ContactEditDraft.kt @@ -0,0 +1,15 @@ +package org.monogram.presentation.features.profile.contact + +internal data class ContactEditDraft( + val firstName: String = "", + val lastName: String = "", + val sharePhoneNumber: Boolean = false +) { + fun isValid(): Boolean = firstName.trim().isNotBlank() + + fun hasChanges(initial: ContactEditDraft): Boolean { + return firstName.trim() != initial.firstName.trim() || + lastName.trim() != initial.lastName.trim() || + sharePhoneNumber != initial.sharePhoneNumber + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/contact/DefaultContactEditComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/contact/DefaultContactEditComponent.kt new file mode 100644 index 00000000..f30e03af --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/contact/DefaultContactEditComponent.kt @@ -0,0 +1,219 @@ +package org.monogram.presentation.features.profile.contact + +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.update +import kotlinx.coroutines.launch +import org.monogram.domain.repository.ContactEditRepository +import org.monogram.presentation.core.util.componentScope +import org.monogram.presentation.features.editing.EditorPrimitives +import org.monogram.presentation.root.AppComponentContext + +class DefaultContactEditComponent( + context: AppComponentContext, + private val userId: Long, + private val onBackClicked: () -> Unit +) : ContactEditComponent, AppComponentContext by context { + + private val contactEditRepository: ContactEditRepository = + container.repositories.contactEditRepository + private val stringProvider = container.utils.stringProvider() + private val scope = componentScope + private var initialDraft = ContactEditDraft() + + private val _state = MutableValue(ContactEditComponent.State(userId = userId, isLoading = true)) + override val state: Value = _state + + init { + scope.launch { + val user = contactEditRepository.getContact(userId) + val needPhoneNumberPrivacyException = + contactEditRepository.getNeedPhoneNumberPrivacyException(userId) + initialDraft = ContactEditDraft( + firstName = user?.firstName.orEmpty(), + lastName = user?.lastName.orEmpty(), + sharePhoneNumber = !needPhoneNumberPrivacyException + ) + _state.update { + it.copy( + user = user, + firstName = initialDraft.firstName, + lastName = initialDraft.lastName, + phoneNumber = user?.phoneNumber, + sharePhoneNumber = initialDraft.sharePhoneNumber, + needPhoneNumberPrivacyException = needPhoneNumberPrivacyException, + canEditPersonalPhoto = contactEditRepository.supportsPersonalPhotoEditing, + isLoading = false, + editor = EditorPrimitives.cleanState() + ) + } + } + } + + override fun onBack() { + if (_state.value.editor.isDirty) { + _state.update { + it.copy(editor = EditorPrimitives.showDiscardChanges(it.editor)) + } + } else { + onBackClicked() + } + } + + override fun onDismissDiscardChanges() { + _state.update { + it.copy(editor = EditorPrimitives.hideDiscardChanges(it.editor)) + } + } + + override fun onConfirmDiscardChanges() { + _state.update { + it.copy(editor = EditorPrimitives.hideDiscardChanges(it.editor)) + } + onBackClicked() + } + + override fun onDismissError() { + _state.update { + it.copy(editor = EditorPrimitives.endSave(it.editor.copy(error = null))) + } + } + + override fun onUpdateFirstName(firstName: String) { + _state.update { current -> + current.copy( + firstName = firstName, + editor = EditorPrimitives.markDirty( + current.editor, + currentDraft(firstName = firstName).hasChanges(initialDraft) + ) + ) + } + } + + override fun onUpdateLastName(lastName: String) { + _state.update { current -> + current.copy( + lastName = lastName, + editor = EditorPrimitives.markDirty( + current.editor, + currentDraft(lastName = lastName).hasChanges(initialDraft) + ) + ) + } + } + + override fun onToggleSharePhoneNumber(sharePhoneNumber: Boolean) { + _state.update { current -> + current.copy( + sharePhoneNumber = sharePhoneNumber, + editor = EditorPrimitives.markDirty( + current.editor, + currentDraft(sharePhoneNumber = sharePhoneNumber).hasChanges(initialDraft) + ) + ) + } + } + + override fun onSave() { + val snapshot = _state.value + val user = snapshot.user ?: return + val draft = currentDraft() + if (!draft.isValid()) { + _state.update { + it.copy( + editor = EditorPrimitives.fail( + it.editor, + stringProvider.getString("contact_edit_first_name_required") + ) + ) + } + return + } + val firstName = draft.firstName.trim() + val lastName = draft.lastName.trim() + + scope.launch { + _state.update { + it.copy(editor = EditorPrimitives.beginSave(it.editor)) + } + runCatching { + val updatedUser = user.copy( + firstName = firstName, + lastName = lastName.ifBlank { null }, + isContact = true + ) + val savedUser = contactEditRepository.upsertContact( + user = updatedUser, + sharePhoneNumber = snapshot.sharePhoneNumber + ) ?: updatedUser + savedUser.copy( + firstName = firstName, + lastName = lastName.ifBlank { null }, + isContact = true + ) + }.onSuccess { updatedUser -> + initialDraft = draft.copy(firstName = firstName, lastName = lastName) + _state.update { + it.copy( + user = updatedUser, + firstName = firstName, + lastName = lastName, + sharePhoneNumber = draft.sharePhoneNumber, + editor = EditorPrimitives.cleanState(), + needPhoneNumberPrivacyException = !draft.sharePhoneNumber + ) + } + onBackClicked() + }.onFailure { error -> + _state.update { + it.copy( + editor = EditorPrimitives.fail( + it.editor, + error.message ?: stringProvider.getString("contact_edit_save_failed") + ) + ) + } + } + } + } + + override fun onRemoveContact() { + val currentUserId = _state.value.user?.id ?: return + scope.launch { + _state.update { + it.copy(editor = EditorPrimitives.beginSave(it.editor)) + } + runCatching { + contactEditRepository.removeContact(currentUserId) + }.onSuccess { + _state.update { + it.copy( + user = it.user?.copy(isContact = false), + editor = EditorPrimitives.cleanState() + ) + } + onBackClicked() + }.onFailure { error -> + _state.update { + it.copy( + editor = EditorPrimitives.fail( + it.editor, + error.message ?: stringProvider.getString("contact_edit_remove_failed") + ) + ) + } + } + } + } + + private fun currentDraft( + firstName: String = _state.value.firstName, + lastName: String = _state.value.lastName, + sharePhoneNumber: Boolean = _state.value.sharePhoneNumber + ): ContactEditDraft = ContactEditDraft( + firstName = firstName, + lastName = lastName, + sharePhoneNumber = sharePhoneNumber + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/share/IncomingShareModels.kt b/presentation/src/main/java/org/monogram/presentation/features/share/IncomingShareModels.kt new file mode 100644 index 00000000..1218415e --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/share/IncomingShareModels.kt @@ -0,0 +1,81 @@ +package org.monogram.presentation.features.share + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Parcelize +@Serializable +enum class PendingAttachmentKind : Parcelable { + PHOTO, + VIDEO, + GIF, + DOCUMENT; + + val isAlbumMedia: Boolean + get() = this == PHOTO || this == VIDEO + + val isVisualMedia: Boolean + get() = this == PHOTO || this == VIDEO || this == GIF +} + +@Parcelize +@Serializable +data class PendingAttachment( + val localPath: String, + val kind: PendingAttachmentKind, + val deleteAfterUse: Boolean = false +) : Parcelable + +@Parcelize +@Serializable +data class IncomingShareRequest( + val requestId: Long, + val text: String = "", + val attachments: List = emptyList() +) : Parcelable + +@Parcelize +@Serializable +data class ShareTarget( + val chatId: Long, + val topicId: Long? = null +) : Parcelable + +val PendingAttachment.isDocument: Boolean + get() = kind == PendingAttachmentKind.DOCUMENT + +val PendingAttachment.isMedia: Boolean + get() = !isDocument + +private val VIDEO_ATTACHMENT_EXTENSIONS = setOf( + "mp4", + "mov", + "m4v", + "webm", + "mkv", + "3gp", + "avi" +) + +fun inferPendingAttachmentKind( + localPath: String, + mimeType: String? = null +): PendingAttachmentKind { + val normalizedMimeType = mimeType + ?.substringBefore(';') + ?.trim() + ?.lowercase() + if (normalizedMimeType == "image/gif") return PendingAttachmentKind.GIF + if (normalizedMimeType?.startsWith("video/") == true) return PendingAttachmentKind.VIDEO + + val normalizedPath = localPath.lowercase() + return when { + normalizedPath.endsWith(".gif") -> PendingAttachmentKind.GIF + VIDEO_ATTACHMENT_EXTENSIONS.any { extension -> + normalizedPath.endsWith(".$extension") + } -> PendingAttachmentKind.VIDEO + + else -> PendingAttachmentKind.PHOTO + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/YouTubeViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/YouTubeViewer.kt index 6fbb2034..4b34f395 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/YouTubeViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/YouTubeViewer.kt @@ -93,6 +93,7 @@ import org.monogram.presentation.features.viewers.components.SeekFeedback import org.monogram.presentation.features.viewers.components.YouTubePlayerControlsUI import org.monogram.presentation.features.viewers.components.YouTubeSettingsMenu import org.monogram.presentation.features.viewers.components.baseQualityLabel +import org.monogram.presentation.features.viewers.components.captureViewerDeviceSettings import org.monogram.presentation.features.viewers.components.enterPipMode import org.monogram.presentation.features.viewers.components.findActivity import org.monogram.presentation.features.viewers.components.normalizeYouTubeQualityCode @@ -142,6 +143,7 @@ fun YouTubeViewer( val timeFormat = dateFormatManager.getHourMinuteFormat() val lifecycleOwner = LocalLifecycleOwner.current val playerState = remember { YouTubePlayerState() } + val initialDeviceSettings = remember(context) { captureViewerDeviceSettings(context) } var isInPipMode by remember { mutableStateOf(false) } var showControls by remember { mutableStateOf(true) } var showSettingsMenu by remember { mutableStateOf(false) } @@ -369,6 +371,7 @@ fun YouTubeViewer( show(WindowInsetsCompat.Type.systemBars()) } } + initialDeviceSettings.restore(context) webView.stopLoading() webView.onPause() webView.destroy() @@ -576,9 +579,48 @@ fun YouTubeViewer( ) } .pointerInput(playerState.isLocked) { + var dragOnLeft = false + var dragOnRight = false + var startBrightness = 0.5f + var startVolume = 0 + var maxVolume = 1 + var accumulatedDragY = 0f + var lastAppliedVolume = -1 detectVerticalDragGestures( - onDragStart = { if (!playerState.isLocked) showGestureOverlay = true }, + onDragStart = { change -> + accumulatedDragY = 0f + if (!playerState.isLocked) { + showGestureOverlay = true + val width = size.width + dragOnLeft = change.x < width / 2 + dragOnRight = !dragOnLeft + + if (dragOnLeft) { + val brightness = + context.findActivity()?.window?.attributes?.screenBrightness + startBrightness = + (brightness?.takeIf { it != -1f } ?: 0.5f).coerceIn( + 0f, + 1f + ) + } + + if (dragOnRight) { + val audioManager = + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + maxVolume = + audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + .coerceAtLeast(1) + startVolume = + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + lastAppliedVolume = startVolume + } + } + }, onDragEnd = { + dragOnLeft = false + dragOnRight = false + accumulatedDragY = 0f if (isLongPressing) { playerState.playbackSpeed = originalSpeed.floatValue isLongPressing = false @@ -586,6 +628,9 @@ fun YouTubeViewer( showGestureOverlay = false }, onDragCancel = { + dragOnLeft = false + dragOnRight = false + accumulatedDragY = 0f if (isLongPressing) { playerState.playbackSpeed = originalSpeed.floatValue isLongPressing = false @@ -594,36 +639,33 @@ fun YouTubeViewer( } ) { change, dragAmount -> if (!playerState.isLocked && !isLongPressing) { - val width = size.width - val isLeft = change.position.x < width / 2 + accumulatedDragY += dragAmount val activity = context.findActivity() - if (isLeft && activity != null) { + if (dragOnLeft && activity != null) { val lp = activity.window.attributes - var newBrightness = - (lp.screenBrightness.takeIf { it != -1f } - ?: 0.5f) - (dragAmount / 1000f) + var newBrightness = startBrightness - (accumulatedDragY / 1000f) newBrightness = newBrightness.coerceIn(0f, 1f) lp.screenBrightness = newBrightness activity.window.attributes = lp gestureIcon = Icons.Rounded.BrightnessMedium gestureText = "${(newBrightness * 100).toInt()}%" - } else { + } else if (dragOnRight) { val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val maxVol = - audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - val currentVol = - audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val delta = -(dragAmount / 50f) - val newVol = (currentVol + delta).coerceIn(0f, maxVol.toFloat()) - audioManager.setStreamVolume( - AudioManager.STREAM_MUSIC, - newVol.toInt(), - 0 - ) + val delta = (-accumulatedDragY / 50f).toInt() + val newVol = (startVolume + delta).coerceIn(0, maxVolume) + if (newVol != lastAppliedVolume) { + audioManager.setStreamVolume( + AudioManager.STREAM_MUSIC, + newVol, + 0 + ) + lastAppliedVolume = newVol + } gestureIcon = Icons.AutoMirrored.Rounded.VolumeUp - gestureText = "${((newVol / maxVol) * 100).toInt()}%" + gestureText = + "${((newVol.toFloat() / maxVolume) * 100).toInt()}%" } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt index 4af2da07..58c64579 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt @@ -176,6 +176,7 @@ fun VideoPage( val dateFormatManager: DateFormatManager = koinInject() val timeFormat = dateFormatManager.getHourMinuteFormat() + val initialDeviceSettings = remember(context) { captureViewerDeviceSettings(context) } val currentOnDismiss by rememberUpdatedState(onDismiss) val currentOnToggleControls by rememberUpdatedState(onToggleControls) @@ -297,6 +298,7 @@ fun VideoPage( lifecycleOwner.lifecycle.removeObserver(observer) exoPlayer.removeListener(listener) exoPlayer.release() + initialDeviceSettings.restore(context) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt index 0b2f581d..04311031 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt @@ -2,6 +2,7 @@ package org.monogram.presentation.features.viewers.components import android.content.Context import android.content.ContextWrapper +import android.media.AudioManager import androidx.activity.ComponentActivity import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize @@ -242,6 +243,36 @@ fun formatDuration(durationMs: Long): String { fun currentTime(timeFormat: String): String = SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date()) +data class ViewerDeviceSettingsSnapshot( + val screenBrightness: Float?, + val musicVolume: Int? +) { + fun restore(context: Context) { + screenBrightness?.let { brightness -> + context.findActivity()?.window?.let { window -> + val attributes = window.attributes + attributes.screenBrightness = brightness + window.attributes = attributes + } + } + + musicVolume?.let { volume -> + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + audioManager?.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0) + } + } +} + +fun captureViewerDeviceSettings(context: Context): ViewerDeviceSettingsSnapshot { + val activity = context.findActivity() + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + + return ViewerDeviceSettingsSnapshot( + screenBrightness = activity?.window?.attributes?.screenBrightness, + musicVolume = audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) + ) +} + fun Context.findActivity(): ComponentActivity? = when (this) { is ComponentActivity -> this is ContextWrapper -> baseContext.findActivity() diff --git a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt index f32ecc97..a92dcf10 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -69,7 +69,9 @@ import org.monogram.presentation.features.profile.admin.DefaultChatEditComponent import org.monogram.presentation.features.profile.admin.DefaultChatPermissionsComponent import org.monogram.presentation.features.profile.admin.DefaultMemberListComponent import org.monogram.presentation.features.profile.admin.MemberListComponent +import org.monogram.presentation.features.profile.contact.DefaultContactEditComponent import org.monogram.presentation.features.profile.logs.DefaultProfileLogsComponent +import org.monogram.presentation.features.share.IncomingShareRequest import org.monogram.presentation.features.stickers.core.toUi import org.monogram.presentation.features.webview.DefaultWebViewComponent import org.monogram.presentation.settings.about.DefaultAboutComponent @@ -142,6 +144,8 @@ class DefaultRootComponent( private val _activeChatId = MutableValue(0L) private val activeChatId: Value = _activeChatId + private val _pendingShare = MutableStateFlow(null) + private var lastConsumedShareRequestId: Long? = null override val childStack: Value> = childStack( source = navigation, @@ -170,8 +174,15 @@ class DefaultRootComponent( val activeConfig = childStack.value.active.configuration when (state) { is AuthStep.Ready -> { + tryResumePendingShare() if (activeConfig is Config.Auth || activeConfig is Config.Startup) { - navigation.replaceAll(Config.Chats()) + navigation.replaceAll( + if (_pendingShare.value != null) { + Config.Chats(isShareTargetMode = true) + } else { + Config.Chats() + } + ) } } @@ -474,6 +485,7 @@ class DefaultRootComponent( val savedPasscode = appPreferences.passcode.value return if (savedPasscode == passcode) { _isLocked.update { false } + tryResumePendingShare() true } else { false @@ -482,6 +494,7 @@ class DefaultRootComponent( override fun unlockWithBiometrics() { _isLocked.update { false } + tryResumePendingShare() } override fun logout() { @@ -502,9 +515,55 @@ class DefaultRootComponent( } override fun navigateToChat(chatId: Long, messageId: Long?) { + navigateToChat( + chatId = chatId, + messageId = messageId, + initialTopicId = null, + initialShare = null + ) + } + + override fun handleIncomingShare(request: IncomingShareRequest) { + _pendingShare.value = request + lastConsumedShareRequestId = null + tryResumePendingShare() + } + + private fun tryResumePendingShare() { + val pendingShare = _pendingShare.value ?: return + if (_isLocked.value) return + if (authRepository.authState.value !is AuthStep.Ready) return + + val activeConfig = childStack.value.active.configuration + if (activeConfig is Config.Chats && activeConfig.isShareTargetMode) { + return + } + + if (activeConfig is Config.ChatDetail && activeConfig.initialShare?.requestId == pendingShare.requestId) { + return + } + + navigation.bringToFront(Config.Chats(isShareTargetMode = true)) + } + + private fun navigateToChat( + chatId: Long, + messageId: Long?, + initialTopicId: Long?, + initialShare: IncomingShareRequest? + ) { navigation.navigate { stack -> - val newStack = stack.filterNot { it is Config.ChatDetail && it.chatId == chatId } - newStack + Config.ChatDetail(chatId, messageId) + val newStack = stack.filterNot { + it is Config.ChatDetail && + it.chatId == chatId && + it.initialTopicId == initialTopicId + } + newStack + Config.ChatDetail( + chatId = chatId, + messageId = messageId, + initialTopicId = initialTopicId, + initialShare = initialShare + ) } } @@ -561,7 +620,22 @@ class DefaultRootComponent( } } }, + onShareTargetSelected = { target -> + val pendingShare = _pendingShare.value ?: return@DefaultChatListComponent + navigateToChat( + chatId = target.chatId, + messageId = null, + initialTopicId = target.topicId, + initialShare = pendingShare + ) + }, + onShareCancelled = { + _pendingShare.value = null + lastConsumedShareRequestId = null + navigation.popWhile { it !is Config.Chats } + }, isForwarding = config.forwardingMessageIds != null, + isShareTargetMode = config.isShareTargetMode, forwardingFromChatId = config.fromChatId, forwardingMessageIds = config.forwardingMessageIds.orEmpty(), onNewChatClick = { navigation.bringToFront(Config.NewChat) }, @@ -594,6 +668,14 @@ class DefaultRootComponent( }, onLink = { handleLink(it) }, initialMessageId = config.messageId, + initialShare = config.initialShare, + initialTopicId = config.initialTopicId, + onInitialShareConsumed = { requestId -> + if (_pendingShare.value?.requestId == requestId) { + _pendingShare.value = null + lastConsumedShareRequestId = requestId + } + }, toProfiles = { id -> navigation.bringToFront(Config.Profile(chatId = id)) } ) ) @@ -675,6 +757,7 @@ class DefaultRootComponent( }, onSendMessageClicked = { navigateToChat(it) }, onShowLogsClicked = { navigation.push(Config.ProfileLogs(it)) }, + onEditContactClicked = { navigation.push(Config.ContactEdit(it)) }, onMemberLongClicked = { chatId, userId -> navigation.push(Config.AdminManage(chatId, userId)) } ) ) @@ -746,8 +829,15 @@ class DefaultRootComponent( MemberListComponent.MemberListType.BLACKLIST ) ) - }, - onManagePermissionsClicked = { navigation.push(Config.ChatPermissions(it)) } + } + ) + ) + + is Config.ContactEdit -> RootComponent.Child.ContactEditChild( + DefaultContactEditComponent( + context = context, + userId = config.userId, + onBackClicked = { navigation.pop() } ) ) @@ -818,11 +908,20 @@ class DefaultRootComponent( @Parcelize @Serializable data class Chats( val fromChatId: Long? = null, val forwardingMessageIds: List? = null, - val activeChatId: Long? = null + val activeChatId: Long? = null, + val isShareTargetMode: Boolean = false ) : Config() @Parcelize @Serializable object NewChat : Config() @Parcelize @Serializable object SessionsConfig : Config() - @Parcelize @Serializable data class ChatDetail(val chatId: Long, val messageId: Long? = null) : Config() + + @Parcelize + @Serializable + data class ChatDetail( + val chatId: Long, + val messageId: Long? = null, + val initialTopicId: Long? = null, + val initialShare: IncomingShareRequest? = null + ) : Config() @Parcelize @Serializable object Settings : Config() @Parcelize @Serializable object EditProfile : Config() @Parcelize @Serializable object Folders : Config() @@ -840,6 +939,9 @@ class DefaultRootComponent( @Parcelize @Serializable data class ProfileLogs(val chatId: Long) : Config() @Parcelize @Serializable data class AdminManage(val chatId: Long, val userId: Long) : Config() @Parcelize @Serializable data class ChatEdit(val chatId: Long) : Config() + @Parcelize + @Serializable + data class ContactEdit(val userId: Long) : Config() @Parcelize @Serializable data class MemberList(val chatId: Long, val type: MemberListComponent.MemberListType) : Config() @Parcelize @Serializable data class ChatPermissions(val chatId: Long) : Config() @Parcelize @Serializable object PasscodeConfig : Config() diff --git a/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt index 5bc92ad8..5ebd75f2 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt @@ -8,19 +8,20 @@ import kotlinx.serialization.Serializable import org.monogram.domain.models.ChatFullInfoModel import org.monogram.domain.models.ChatModel import org.monogram.domain.models.ProxyTypeModel +import org.monogram.presentation.core.media.VideoPlayerPool import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.features.auth.AuthComponent -import org.monogram.presentation.features.chats.list.ChatListComponent import org.monogram.presentation.features.chats.conversation.ChatComponent -import org.monogram.presentation.core.media.VideoPlayerPool import org.monogram.presentation.features.chats.creation.NewChatComponent -import org.monogram.presentation.settings.folders.FoldersComponent +import org.monogram.presentation.features.chats.list.ChatListComponent import org.monogram.presentation.features.profile.ProfileComponent import org.monogram.presentation.features.profile.admin.AdminManageComponent import org.monogram.presentation.features.profile.admin.ChatEditComponent import org.monogram.presentation.features.profile.admin.ChatPermissionsComponent import org.monogram.presentation.features.profile.admin.MemberListComponent +import org.monogram.presentation.features.profile.contact.ContactEditComponent import org.monogram.presentation.features.profile.logs.ProfileLogsComponent +import org.monogram.presentation.features.share.IncomingShareRequest import org.monogram.presentation.features.stickers.core.StickerSetUiModel import org.monogram.presentation.features.webview.WebViewComponent import org.monogram.presentation.settings.about.AboutComponent @@ -28,6 +29,7 @@ import org.monogram.presentation.settings.adblock.AdBlockComponent import org.monogram.presentation.settings.chatSettings.ChatSettingsComponent import org.monogram.presentation.settings.dataStorage.DataStorageComponent import org.monogram.presentation.settings.debug.DebugComponent +import org.monogram.presentation.settings.folders.FoldersComponent import org.monogram.presentation.settings.networkUsage.NetworkUsageComponent import org.monogram.presentation.settings.notifications.NotificationsComponent import org.monogram.presentation.settings.powersaving.PowerSavingComponent @@ -67,6 +69,7 @@ interface RootComponent { fun unlockWithBiometrics() fun logout() fun navigateToChat(chatId: Long, messageId: Long? = null) + fun handleIncomingShare(request: IncomingShareRequest) sealed class Child { class StartupChild(val component: StartupComponent) : Child() @@ -92,6 +95,7 @@ interface RootComponent { class ProfileLogsChild(val component: ProfileLogsComponent) : Child() class AdminManageChild(val component: AdminManageComponent) : Child() class ChatEditChild(val component: ChatEditComponent) : Child() + class ContactEditChild(val component: ContactEditComponent) : Child() class MemberListChild(val component: MemberListComponent) : Child() class ChatPermissionsChild(val component: ChatPermissionsComponent) : Child() class PasscodeChild(val component: PasscodeComponent) : Child() diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt index 5d64f7cd..996dd356 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt @@ -9,7 +9,6 @@ interface DebugComponent { fun onBackClicked() fun onCrashClicked() - fun onShowSponsorSheetClicked() fun onForceSponsorSyncClicked() fun onTestPushClicked() fun onDropDatabasesClicked() @@ -19,7 +18,7 @@ interface DebugComponent { data class State( val pushProvider: PushProvider = PushProvider.FCM, - val backgroundServiceEnabled: Boolean = true, + val backgroundServiceEnabled: Boolean = false, val hideForegroundNotification: Boolean = false, val isPowerSavingMode: Boolean = false, val isWakeLockEnabled: Boolean = false, @@ -35,6 +34,9 @@ interface DebugComponent { val unifiedPushLastPushAt: Long = 0L, val isGmsAvailable: Boolean = false, val isFcmAvailable: Boolean = false, - val isUnifiedPushDistributorAvailable: Boolean = false + val isUnifiedPushDistributorAvailable: Boolean = false, + val supportersCount: Int = 0, + val isSponsorsLoading: Boolean = true, + val sponsorLastSyncAt: Long = 0L ) } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt index f40b6f3f..7c2390a9 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt @@ -1,16 +1,12 @@ package org.monogram.presentation.settings.debug import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.BatteryAlert @@ -34,103 +30,35 @@ import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Storage import androidx.compose.material.icons.rounded.Sync import androidx.compose.material.icons.rounded.VisibilityOff -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.arkivanov.decompose.extensions.compose.subscribeAsState -import java.text.DateFormat -import java.util.Date import org.monogram.domain.repository.PushProvider import org.monogram.domain.repository.UnifiedPushDebugStatus import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SectionHeader import org.monogram.presentation.core.ui.SettingsItem +import java.text.DateFormat +import java.util.Date @OptIn(ExperimentalMaterial3Api::class) @Composable fun DebugContent(component: DebugComponent) { val state by component.state.subscribeAsState() - var isSponsorSheetVisible by remember { mutableStateOf(false) } - - if (isSponsorSheetVisible) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = { isSponsorSheetVisible = false }, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 48.dp), - horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Rounded.Favorite, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = Color(0xFFFF6D66) - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.support_monogram_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.sponsor_sheet_description), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(32.dp)) - Button( - onClick = { - component.onShowSponsorSheetClicked() - isSponsorSheetVisible = false - }, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - shape = RoundedCornerShape(16.dp) - ) { - Text(stringResource(R.string.action_support_boosty)) - } - Spacer(modifier = Modifier.height(12.dp)) - TextButton( - onClick = { isSponsorSheetVisible = false }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.action_maybe_later)) - } - } - } - } Scaffold( topBar = { @@ -340,16 +268,24 @@ fun DebugContent(component: DebugComponent) { SectionHeader(stringResource(R.string.debug_section_sponsor)) SettingsItem( icon = Icons.Rounded.Favorite, - title = stringResource(R.string.debug_sponsor_sheet_title), - subtitle = stringResource(R.string.debug_sponsor_sheet_subtitle), + title = stringResource(R.string.debug_sponsor_count_title), + subtitle = if (state.isSponsorsLoading) { + stringResource(R.string.supporters_count_loading) + } else { + state.supportersCount.toString() + }, iconBackgroundColor = Color(0xFFFF6D66), position = ItemPosition.TOP, - onClick = { isSponsorSheetVisible = true } + onClick = { } ) SettingsItem( icon = Icons.Rounded.Sync, title = stringResource(R.string.debug_force_sponsor_sync_title), - subtitle = stringResource(R.string.debug_force_sponsor_sync_subtitle), + subtitle = when { + state.isSponsorsLoading -> stringResource(R.string.debug_sponsor_sync_loading) + state.sponsorLastSyncAt > 0L -> state.sponsorLastSyncAt.toDebugTimestamp() + else -> stringResource(R.string.debug_sponsor_sync_never) + }, iconBackgroundColor = Color(0xFF00ACC1), position = ItemPosition.BOTTOM, onClick = component::onForceSponsorSyncClicked diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt index c64e9081..d016afa6 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt @@ -3,8 +3,10 @@ package org.monogram.presentation.settings.debug import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext import java.io.File @@ -16,7 +18,6 @@ class DefaultDebugComponent( private val messageDisplayer = container.utils.messageDisplayer() private val assetsManager = container.utils.assetsManager() - private val externalNavigator = container.utils.externalNavigator() private val distrManager = container.utils.distrManager() private val pushDebugRepository = container.repositories.pushDebugRepository private val sponsorRepository = container.repositories.sponsorRepository @@ -53,6 +54,18 @@ class DefaultDebugComponent( ) } }.launchIn(scope) + + scope.launch { + sponsorRepository.sponsorState.collectLatest { sponsorState -> + _state.update { + it.copy( + supportersCount = sponsorState.supportersCount, + isSponsorsLoading = !sponsorState.isLoaded && sponsorState.supportersCount == 0, + sponsorLastSyncAt = sponsorState.lastSyncAt + ) + } + } + } } override fun onBackClicked() { @@ -63,10 +76,6 @@ class DefaultDebugComponent( throw RuntimeException("Test crash") } - override fun onShowSponsorSheetClicked() { - externalNavigator.openUrl("https://boosty.to/monogram") - } - override fun onForceSponsorSyncClicked() { sponsorRepository.forceSponsorSync() messageDisplayer.show("Sponsor sync started") diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt index 126d03cf..a95f5107 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt @@ -57,7 +57,7 @@ interface NotificationsComponent { val inAppPreview: Boolean = true, val contactJoined: Boolean = true, val pinnedMessages: Boolean = true, - val backgroundServiceEnabled: Boolean = true, + val backgroundServiceEnabled: Boolean = false, val hideForegroundNotification: Boolean = false, val vibrationPattern: String = "default", val priority: Int = 1, @@ -300,7 +300,7 @@ class DefaultNotificationsComponent( onInAppPreviewToggled(true) onContactJoinedToggled(true) onPinnedMessagesToggled(true) - onBackgroundServiceToggled(true) + onBackgroundServiceToggled(false) onHideForegroundNotificationToggled(false) onVibrationPatternChanged("default") onPriorityChanged(1) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/powersaving/PowerSavingComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/powersaving/PowerSavingComponent.kt index d34a288f..f4272099 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/powersaving/PowerSavingComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/powersaving/PowerSavingComponent.kt @@ -21,7 +21,7 @@ interface PowerSavingComponent { data class State( val isChatAnimationsEnabled: Boolean = true, - val backgroundServiceEnabled: Boolean = true, + val backgroundServiceEnabled: Boolean = false, val isPowerSavingModeEnabled: Boolean = false, val isWakeLockEnabled: Boolean = false, val batteryOptimizationEnabled: Boolean = false diff --git a/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumComponent.kt index 9dc3e60c..37610371 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumComponent.kt @@ -11,6 +11,7 @@ import org.monogram.domain.models.PremiumLimitType import org.monogram.domain.models.PremiumSource import org.monogram.domain.repository.PremiumRepository import org.monogram.domain.repository.UserRepository +import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -18,12 +19,14 @@ interface PremiumComponent { val state: Value fun onBackClicked() fun onSubscribeClicked() + fun onShowSponsoredMessagesChanged(enabled: Boolean) data class State( val features: List = emptyList(), val isLoading: Boolean = false, val isPremium: Boolean = false, - val statusText: String? = null + val statusText: String? = null, + val showSponsoredMessagesForPremium: Boolean = false ) data class PremiumFeature( @@ -41,6 +44,7 @@ class DefaultPremiumComponent( private val userRepository: UserRepository = container.repositories.userRepository private val premiumRepository: PremiumRepository = container.repositories.premiumRepository + private val appPreferences: AppPreferences = container.preferences.appPreferences private val stringProvider = container.utils.stringProvider() private val scope = componentScope @@ -54,6 +58,12 @@ class DefaultPremiumComponent( } }.launchIn(scope) + appPreferences.showSponsoredMessagesForPremium + .onEach { enabled -> + _state.update { it.copy(showSponsoredMessagesForPremium = enabled) } + } + .launchIn(scope) + loadPremiumState() } @@ -179,4 +189,13 @@ class DefaultPremiumComponent( override fun onSubscribeClicked() { } -} \ No newline at end of file + + override fun onShowSponsoredMessagesChanged(enabled: Boolean) { + appPreferences.setShowSponsoredMessagesForPremium(enabled) + scope.launch { + runCatching { + premiumRepository.setSponsoredMessagesEnabled(enabled) + } + } + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumContent.kt index 156f6a40..00accaac 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumContent.kt @@ -3,14 +3,49 @@ package org.monogram.presentation.settings.premium import android.content.Intent import android.net.Uri import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.Campaign +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Face +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.Translate +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -32,6 +67,8 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.presentation.R import org.monogram.presentation.core.ui.CollapsingToolbarScaffold import org.monogram.presentation.core.ui.ExpressiveDefaults +import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SettingsSwitchTile import org.monogram.presentation.core.ui.TelegramStarInteractive import org.monogram.presentation.core.ui.rememberCollapsingToolbarScaffoldState import org.monogram.presentation.core.util.ScrollStrategy @@ -174,6 +211,24 @@ fun PremiumContent(component: PremiumComponent) { items(state.features) { feature -> PremiumFeatureItem(feature) } + + if (state.isPremium) { + item { + Spacer(modifier = Modifier.height(12.dp)) + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + SettingsSwitchTile( + icon = Icons.Rounded.Campaign, + title = stringResource(R.string.premium_show_sponsored_messages_title), + subtitle = stringResource(R.string.premium_show_sponsored_messages_subtitle), + checked = state.showSponsoredMessagesForPremium, + iconColor = Color(0xFF00BFA5), + position = ItemPosition.STANDALONE, + onCheckedChange = component::onShowSponsoredMessagesChanged + ) + } + Spacer(modifier = Modifier.height(24.dp)) + } + } } } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/profile/DefaultEditProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/profile/DefaultEditProfileComponent.kt index 93dafe45..678c99bd 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/profile/DefaultEditProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/profile/DefaultEditProfileComponent.kt @@ -6,8 +6,13 @@ import com.arkivanov.decompose.value.update import kotlinx.coroutines.launch import org.monogram.domain.models.BirthdateModel import org.monogram.domain.models.BusinessOpeningHoursModel -import org.monogram.domain.repository.* +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository +import org.monogram.domain.repository.LocationRepository +import org.monogram.domain.repository.UserProfileEditRepository +import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.componentScope +import org.monogram.presentation.features.editing.EditorPrimitives import org.monogram.presentation.root.AppComponentContext class DefaultEditProfileComponent( @@ -24,6 +29,15 @@ class DefaultEditProfileComponent( private val _state = MutableValue(EditProfileComponent.State()) override val state: Value = _state private val scope = componentScope + private var initialBio: String = "" + private var initialBirthdate: BirthdateModel? = null + private var initialPersonalChatId: Long = 0L + private var initialBusinessBio: String = "" + private var initialBusinessAddress: String = "" + private var initialBusinessLatitude: Double = 0.0 + private var initialBusinessLongitude: Double = 0.0 + private var initialBusinessOpeningHours: BusinessOpeningHoursModel? = null + private var initialAvatarPath: String? = null init { scope.launch { @@ -33,6 +47,15 @@ class DefaultEditProfileComponent( val fullInfo = chatInfoRepository.getChatFullInfo(me.id) val linkedChat = fullInfo?.linkedChatId?.let { if (it != 0L) chatListRepository.getChatById(it) else null } + initialBio = fullInfo?.description ?: "" + initialBirthdate = fullInfo?.birthdate + initialPersonalChatId = fullInfo?.linkedChatId ?: 0L + initialBusinessBio = fullInfo?.businessInfo?.startPage?.message ?: "" + initialBusinessAddress = fullInfo?.businessInfo?.location?.address ?: "" + initialBusinessLatitude = fullInfo?.businessInfo?.location?.latitude ?: 0.0 + initialBusinessLongitude = fullInfo?.businessInfo?.location?.longitude ?: 0.0 + initialBusinessOpeningHours = fullInfo?.businessInfo?.openingHours + initialAvatarPath = me.avatarPath _state.update { it.copy( @@ -60,31 +83,63 @@ class DefaultEditProfileComponent( } override fun onBack() { - onBackClicked() + if (_state.value.editor.isDirty) { + _state.update { + it.copy(editor = it.editor.copy(showDiscardChangesDialog = true)) + } + } else { + onBackClicked() + } } override fun onUpdateFirstName(firstName: String) { - _state.update { it.copy(firstName = firstName) } + _state.update { + it.copy( + firstName = firstName, + editor = it.editor.copy(isDirty = hasChanges(firstName = firstName)) + ) + } } override fun onUpdateLastName(lastName: String) { - _state.update { it.copy(lastName = lastName) } + _state.update { + it.copy( + lastName = lastName, + editor = it.editor.copy(isDirty = hasChanges(lastName = lastName)) + ) + } } override fun onUpdateBio(bio: String) { - _state.update { it.copy(bio = bio) } + _state.update { + it.copy( + bio = bio, + editor = it.editor.copy(isDirty = hasChanges(bio = bio)) + ) + } } override fun onUpdateUsername(username: String) { - _state.update { it.copy(username = username) } + _state.update { + it.copy( + username = username, + editor = it.editor.copy(isDirty = hasChanges(username = username)) + ) + } } override fun onUpdateBirthdate(birthdate: BirthdateModel?) { - _state.update { it.copy(birthdate = birthdate) } + _state.update { + it.copy( + birthdate = birthdate, + editor = it.editor.copy(isDirty = hasChanges(birthdate = birthdate)) + ) + } } override fun onUpdatePersonalChatId(chatId: Long) { _state.update { it.copy(personalChatId = chatId) } + _state.update { it.copy(editor = it.editor.copy(isDirty = hasChanges(personalChatId = chatId))) } if (chatId != 0L) { scope.launch { val chat = chatListRepository.getChatById(chatId) @@ -96,7 +151,12 @@ class DefaultEditProfileComponent( } override fun onUpdateBusinessBio(bio: String) { - _state.update { it.copy(businessBio = bio) } + _state.update { + it.copy( + businessBio = bio, + editor = it.editor.copy(isDirty = hasChanges(businessBio = bio)) + ) + } } override fun onUpdateBusinessAddress(address: String, latitude: Double, longitude: Double) { @@ -104,17 +164,29 @@ class DefaultEditProfileComponent( it.copy( businessAddress = address, businessLatitude = latitude, - businessLongitude = longitude + businessLongitude = longitude, + editor = it.editor.copy( + isDirty = hasChanges( + businessAddress = address, + businessLatitude = latitude, + businessLongitude = longitude + ) + ) ) } } override fun onUpdateBusinessOpeningHours(openingHours: BusinessOpeningHoursModel?) { - _state.update { it.copy(businessOpeningHours = openingHours) } + _state.update { + it.copy( + businessOpeningHours = openingHours, + editor = it.editor.copy(isDirty = hasChanges(businessOpeningHours = openingHours)) + ) + } } override fun onChangeAvatar(path: String) { - _state.update { it.copy(avatarPath = path) } + _state.update { it.copy(avatarPath = path, editor = it.editor.copy(isDirty = true)) } scope.launch { try { userProfileEditRepository.setProfilePhoto(path) @@ -128,16 +200,38 @@ class DefaultEditProfileComponent( _state.update { it.copy(showAvatarPicker = show) } } + override fun onDismissDiscardChanges() { + _state.update { it.copy(editor = it.editor.copy(showDiscardChangesDialog = false)) } + } + + override fun onConfirmDiscardChanges() { + _state.update { it.copy(editor = it.editor.copy(showDiscardChangesDialog = false)) } + onBackClicked() + } + + override fun onDismissError() { + _state.update { it.copy(editor = it.editor.copy(error = null), error = null) } + } + override fun onReverseGeocode(lat: Double, lon: Double) { scope.launch { try { val response = locationRepository.reverseGeocode(lat, lon) response?.let { address -> + val resolvedAddress = + (address.address?.fullAddress ?: address.display_name).toString() _state.update { it.copy( - businessAddress = (address.address?.fullAddress ?: address.display_name).toString(), + businessAddress = resolvedAddress, businessLatitude = lat, - businessLongitude = lon + businessLongitude = lon, + editor = it.editor.copy( + isDirty = hasChanges( + businessAddress = resolvedAddress, + businessLatitude = lat, + businessLongitude = lon + ) + ) ) } } @@ -152,9 +246,19 @@ class DefaultEditProfileComponent( try { userProfileEditRepository.toggleUsernameIsActive(username, active) val me = userRepository.getMe() - _state.update { it.copy(user = me) } + _state.update { + it.copy( + user = me, + editor = it.editor.copy(isDirty = true, error = null) + ) + } } catch (e: Exception) { - _state.update { it.copy(error = e.message) } + _state.update { + it.copy( + error = e.message, + editor = it.editor.copy(error = e.message) + ) + } } } } @@ -164,16 +268,32 @@ class DefaultEditProfileComponent( try { userProfileEditRepository.reorderActiveUsernames(usernames) val me = userRepository.getMe() - _state.update { it.copy(user = me, username = me.username ?: "") } + _state.update { + it.copy( + user = me, + username = me.username ?: "", + editor = it.editor.copy(isDirty = true, error = null) + ) + } } catch (e: Exception) { - _state.update { it.copy(error = e.message) } + _state.update { + it.copy( + error = e.message, + editor = it.editor.copy(error = e.message) + ) + } } } } override fun onSave() { scope.launch { - _state.update { it.copy(isLoading = true) } + _state.update { + it.copy( + isLoading = true, + editor = it.editor.copy(isSaving = true, error = null) + ) + } try { val currentState = _state.value val user = currentState.user ?: return@launch @@ -219,10 +339,61 @@ class DefaultEditProfileComponent( } userRepository.refreshUserFullInfo(user.id) - onBack() + initialBio = currentState.bio + initialBirthdate = currentState.birthdate + initialPersonalChatId = currentState.personalChatId + initialBusinessBio = currentState.businessBio + initialBusinessAddress = currentState.businessAddress + initialBusinessLatitude = currentState.businessLatitude + initialBusinessLongitude = currentState.businessLongitude + initialBusinessOpeningHours = currentState.businessOpeningHours + initialAvatarPath = currentState.avatarPath + _state.update { + it.copy( + isLoading = false, + error = null, + editor = EditorPrimitives.updateState(isDirty = false) + ) + } + onBackClicked() } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.message) } + _state.update { + it.copy( + isLoading = false, + error = e.message, + editor = it.editor.copy(isSaving = false, error = e.message) + ) + } } } } + + private fun hasChanges( + firstName: String = _state.value.firstName, + lastName: String = _state.value.lastName, + bio: String = _state.value.bio, + username: String = _state.value.username, + birthdate: BirthdateModel? = _state.value.birthdate, + personalChatId: Long = _state.value.personalChatId, + businessBio: String = _state.value.businessBio, + businessAddress: String = _state.value.businessAddress, + businessLatitude: Double = _state.value.businessLatitude, + businessLongitude: Double = _state.value.businessLongitude, + businessOpeningHours: BusinessOpeningHoursModel? = _state.value.businessOpeningHours + ): Boolean { + val state = _state.value + val user = state.user ?: return false + return firstName != user.firstName || + lastName != (user.lastName ?: "") || + bio != initialBio || + username != (user.username ?: "") || + birthdate != initialBirthdate || + personalChatId != initialPersonalChatId || + businessBio != initialBusinessBio || + businessAddress != initialBusinessAddress || + businessLatitude != initialBusinessLatitude || + businessLongitude != initialBusinessLongitude || + businessOpeningHours != initialBusinessOpeningHours || + state.avatarPath != initialAvatarPath + } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileComponent.kt index 29b79383..2adf2fcd 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileComponent.kt @@ -5,6 +5,7 @@ import org.monogram.domain.models.BirthdateModel import org.monogram.domain.models.BusinessOpeningHoursModel import org.monogram.domain.models.ChatModel import org.monogram.domain.models.UserModel +import org.monogram.presentation.features.editing.EditorScreenState interface EditProfileComponent { val state: Value @@ -24,6 +25,9 @@ interface EditProfileComponent { fun onReverseGeocode(lat: Double, lon: Double) fun onToggleUsername(username: String, active: Boolean) fun onReorderUsernames(usernames: List) + fun onDismissDiscardChanges() + fun onConfirmDiscardChanges() + fun onDismissError() data class State( val user: UserModel? = null, @@ -42,6 +46,7 @@ interface EditProfileComponent { val avatarPath: String? = null, val isLoading: Boolean = false, val error: String? = null, + val editor: EditorScreenState = EditorScreenState(), val showAvatarPicker: Boolean = false ) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileContent.kt index 283c7330..3fe959d2 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/profile/EditProfileContent.kt @@ -41,6 +41,7 @@ import androidx.compose.material.icons.rounded.Business import androidx.compose.material.icons.rounded.Cake import androidx.compose.material.icons.rounded.CameraAlt import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp @@ -68,6 +69,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -113,6 +116,7 @@ import org.monogram.domain.models.BusinessOpeningHoursIntervalModel import org.monogram.domain.models.BusinessOpeningHoursModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SectionHeader import org.monogram.presentation.core.ui.SettingsTextField @@ -130,6 +134,13 @@ fun EditProfileContent(component: EditProfileComponent) { val state by component.state.subscribeAsState() val context = LocalContext.current val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.editor.error) { + val message = state.editor.error ?: return@LaunchedEffect + snackbarHostState.showSnackbar(message) + component.onDismissError() + } val photoPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia(), @@ -821,6 +832,7 @@ fun EditProfileContent(component: EditProfileComponent) { Scaffold( modifier = Modifier.semantics { contentDescription = "EditProfileContent" }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { TopAppBar( title = { @@ -847,7 +859,7 @@ fun EditProfileContent(component: EditProfileComponent) { .padding(end = 16.dp), ) } else if (state.user != null) { - IconButton(onClick = component::onSave) { + IconButton(onClick = component::onSave, enabled = state.editor.canSave) { Icon( Icons.Rounded.Check, contentDescription = stringResource(R.string.action_save), @@ -1162,6 +1174,17 @@ fun EditProfileContent(component: EditProfileComponent) { } } } + + if (state.editor.showDiscardChangesDialog) { + ConfirmationSheet( + icon = Icons.Rounded.Delete, + title = stringResource(R.string.photo_editor_discard_title), + description = stringResource(R.string.photo_editor_discard_message), + confirmText = stringResource(R.string.photo_editor_discard_button), + onConfirm = component::onConfirmDiscardChanges, + onDismiss = component::onDismissDiscardChanges + ) + } } @SuppressLint("MissingPermission") diff --git a/presentation/src/main/java/org/monogram/presentation/settings/settings/DefaultSettingsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/settings/DefaultSettingsComponent.kt index 6ca091cf..bda7c09c 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/settings/DefaultSettingsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/settings/DefaultSettingsComponent.kt @@ -8,10 +8,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.monogram.domain.managers.DomainManager -import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.ExternalNavigator +import org.monogram.domain.repository.SponsorRepository import org.monogram.domain.repository.UserProfileEditRepository import org.monogram.domain.repository.UserRepository +import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope @@ -37,9 +38,10 @@ class DefaultSettingsComponent( private val repository: UserRepository = container.repositories.userRepository private val userProfileEditRepository: UserProfileEditRepository = container.repositories.userProfileEditRepository + private val sponsorRepository: SponsorRepository = container.repositories.sponsorRepository private val externalNavigator: ExternalNavigator = container.utils.externalNavigator() private val domainManager: DomainManager = container.utils.domainManager() - private val preferences: AppPreferencesProvider = container.preferences.appPreferences + private val appPreferences: AppPreferences = container.preferences.appPreferences override val downloadUtils: IDownloadUtils = container.utils.downloadUtils() private val _state = MutableValue(SettingsComponent.State()) @@ -49,27 +51,44 @@ class DefaultSettingsComponent( init { scope.launch { try { - val me = repository.getMe() - val link = if (me.username?.isNotEmpty() == true) "https://t.me/${me.username}" else "" + repository.getMe() + } catch (_: Exception) { + _state.update { it.copy(currentUser = null) } + } + } + scope.launch { + repository.currentUserFlow.collectLatest { me -> + val link = + if (me?.username?.isNotEmpty() == true) "https://t.me/${me.username}" else "" _state.update { it.copy( currentUser = me, - qrContent = link + qrContent = link, + isCurrentUserSponsor = me?.id?.let(sponsorRepository.sponsorState.value.supporterIds::contains) == true ) } - } catch (e: Exception) { - _state.update { it.copy(currentUser = null) } } } scope.launch { - preferences.isSupportViewed.collectLatest { viewed -> - if (!viewed) { - _state.update { it.copy(isSupportVisible = true) } + sponsorRepository.sponsorState.collectLatest { sponsorState -> + _state.update { state -> + state.copy( + isCurrentUserSponsor = state.currentUser?.id?.let(sponsorState.supporterIds::contains) == true, + supportersCount = sponsorState.supportersCount, + isSupportersLoading = !sponsorState.isLoaded && sponsorState.supportersCount == 0 + ) } } } + + scope.launch { + appPreferences.isProjectChannelSubscribed.collectLatest { subscribed -> + _state.update { it.copy(isProjectChannelSubscribed = subscribed) } + } + } + checkLinkStatus() } @@ -155,18 +174,16 @@ class DefaultSettingsComponent( onDebugClick() } - override fun onSupportClicked() { + override fun onBoostyClicked() { externalNavigator.openUrl("https://boosty.to/monogram") - onSupportDismissed() } - override fun onSupportDismissed() { - preferences.setSupportViewed(true) - _state.update { it.copy(isSupportVisible = false) } + override fun onCryptoDonateClicked() { + externalNavigator.openUrl("https://t.me/send?start=IVm03D7GNky7") } - override fun onShowSupportClicked() { - _state.update { it.copy(isSupportVisible = true) } + override fun onGithubClicked() { + externalNavigator.openUrl("https://github.com/monogram-android/monogram") } override fun onMoreOptionsClicked() { diff --git a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsComponent.kt index 1ca27ece..51fcf626 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsComponent.kt @@ -28,9 +28,9 @@ interface SettingsComponent { fun onStickersClicked() fun onAboutClicked() fun onDebugClicked() - fun onSupportClicked() - fun onSupportDismissed() - fun onShowSupportClicked() + fun onBoostyClicked() + fun onCryptoDonateClicked() + fun onGithubClicked() fun onMoreOptionsClicked() fun onMoreOptionsDismissed() fun onSetEmojiStatus(customEmojiId: Long, statusPath: String?) @@ -43,7 +43,10 @@ interface SettingsComponent { val isTMeLinkEnabled: Boolean = true, val isQrVisible: Boolean = false, val qrContent: String = "", - val isSupportVisible: Boolean = false, + val isCurrentUserSponsor: Boolean = false, + val supportersCount: Int = 0, + val isSupportersLoading: Boolean = true, + val isProjectChannelSubscribed: Boolean = false, val isMoreOptionsVisible: Boolean = false, val fullScreenImages: List? = null, val fullScreenVideoPath: String? = null diff --git a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt index 89fa30d2..38604748 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt @@ -76,7 +76,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -109,7 +108,6 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp @@ -333,62 +331,6 @@ fun SettingsContent(component: SettingsComponent) { } } - if (state.isSupportVisible) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = component::onSupportDismissed, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 48.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Rounded.Favorite, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = pinkColor - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.support_monogram_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.sponsor_sheet_description), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(32.dp)) - Button( - onClick = component::onSupportClicked, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - shape = RoundedCornerShape(16.dp) - ) { - Text(stringResource(R.string.action_support_boosty)) - } - Spacer(modifier = Modifier.height(12.dp)) - TextButton( - onClick = component::onSupportDismissed, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.action_maybe_later)) - } - } - } - } - if (state.isMoreOptionsVisible && profileUsername != null && profileLink != null) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( @@ -780,6 +722,67 @@ fun SettingsContent(component: SettingsComponent) { } } + item { + SectionHeader(stringResource(R.string.section_support)) + if (state.isCurrentUserSponsor) { + SettingsItem( + icon = Icons.Rounded.Favorite, + title = stringResource(R.string.support_thank_you_title), + subtitle = if (state.isSupportersLoading) { + stringResource(R.string.supporters_count_loading) + } else { + stringResource( + R.string.support_thank_you_subtitle_with_count, + state.supportersCount + ) + }, + iconBackgroundColor = pinkColor, + position = ItemPosition.STANDALONE, + onClick = component::onGithubClicked + ) + } else { + SettingsItem( + icon = Icons.Rounded.Favorite, + title = stringResource(R.string.support_monogram_title), + subtitle = if (state.isSupportersLoading) { + stringResource(R.string.supporters_count_loading) + } else { + stringResource( + R.string.support_block_subtitle_with_count, + state.supportersCount + ) + }, + iconBackgroundColor = pinkColor, + position = ItemPosition.TOP, + onClick = component::onGithubClicked + ) + SettingsItem( + icon = Icons.Rounded.Star, + title = stringResource(R.string.support_mission_title), + subtitle = stringResource(R.string.support_monogram_description), + iconBackgroundColor = orangeColor, + position = ItemPosition.MIDDLE, + onClick = component::onGithubClicked + ) + SettingsItem( + icon = Icons.Rounded.Link, + title = stringResource(R.string.support_boosty_link_title), + subtitle = stringResource(R.string.support_boosty_link_value), + iconBackgroundColor = blueColor, + position = ItemPosition.MIDDLE, + onClick = component::onBoostyClicked + ) + SettingsItem( + icon = Icons.Rounded.Link, + title = stringResource(R.string.support_crypto_link_title), + subtitle = stringResource(R.string.support_crypto_link_value), + iconBackgroundColor = tealColor, + position = ItemPosition.BOTTOM, + onClick = component::onCryptoDonateClicked + ) + } + } + item { SectionHeader(stringResource(R.string.section_general)) SettingsItem( @@ -890,18 +893,10 @@ fun SettingsContent(component: SettingsComponent) { title = stringResource(R.string.telegram_premium_title), subtitle = stringResource(R.string.telegram_premium_subtitle), iconBackgroundColor = purpleColor, - position = ItemPosition.TOP, + position = ItemPosition.STANDALONE, onClick = component::onPremiumClicked, modifier = Modifier.semantics { contentDescription = "SettingsPremium" } ) - SettingsItem( - icon = Icons.Rounded.Favorite, - title = stringResource(R.string.support_monogram_title), - subtitle = stringResource(R.string.support_monogram_subtitle_settings), - iconBackgroundColor = pinkColor, - position = ItemPosition.BOTTOM, - onClick = component::onShowSupportClicked - ) } item { @@ -913,14 +908,16 @@ fun SettingsContent(component: SettingsComponent) { position = ItemPosition.TOP, onClick = component::onAboutClicked ) - SettingsItem( - icon = Icons.Rounded.BugReport, - title = stringResource(R.string.debug_title), - subtitle = stringResource(R.string.debug_subtitle), - iconBackgroundColor = Color.Gray, - position = ItemPosition.MIDDLE, - onClick = component::onDebugClicked - ) + if (state.isProjectChannelSubscribed) { + SettingsItem( + icon = Icons.Rounded.BugReport, + title = stringResource(R.string.debug_title), + subtitle = stringResource(R.string.debug_subtitle), + iconBackgroundColor = Color.Gray, + position = ItemPosition.MIDDLE, + onClick = component::onDebugClicked + ) + } SettingsItem( icon = Icons.AutoMirrored.Rounded.ExitToApp, title = stringResource(R.string.log_out_title), diff --git a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsStore.kt b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsStore.kt index 31c0bdfc..dedddfd0 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsStore.kt @@ -25,9 +25,9 @@ interface SettingsStore : Store component.onStickersClicked() Intent.AboutClicked -> component.onAboutClicked() Intent.DebugClicked -> component.onDebugClicked() - Intent.SupportClicked -> component.onSupportClicked() - Intent.SupportDismissed -> component.onSupportDismissed() - Intent.ShowSupportClicked -> component.onShowSupportClicked() + Intent.BoostyClicked -> component.onBoostyClicked() + Intent.CryptoDonateClicked -> component.onCryptoDonateClicked() + Intent.GithubClicked -> component.onGithubClicked() is Intent.UpdateState -> dispatch(Message.UpdateState(intent.state)) } } diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index cbfc18a3..39f797c1 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -366,16 +366,26 @@ MTProto, SOCKS5, HTTP Telegram Premium Desbloquea funciones exclusivas - Ayúdanos a desarrollar el proyecto + Proyecto de entusiastas + Gracias por apoyar MonoGram + Los autores te agradecen, %1$d personas ya ayudaron al proyecto + %1$d personas ya ayudaron al proyecto + Cargando lista de colaboradores… + Apoyo en Boosty + boosty.to/monogram + Apoyo con cripto + t.me/send?start=IVm03D7GNky7 + github.com/monogram-android/monogram + Apoyo Apoya MonoGram Este usuario apoya el proyecto y nos ayuda a seguir mejorando Versión e información de MonoGram Depuración Opciones de depuración - Mostrar hoja de patrocinador - Abrir panel inferior de información de patrocinador + Cantidad de colaboradores Forzar sincronización de patrocinador - Obtener IDs de patrocinador del canal ahora + La lista de colaboradores se está cargando + Nunca se sincronizó Cerrar Sesión Desconectar de la cuenta diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index b119efcf..bb6f2825 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -355,16 +355,26 @@ MTProto, SOCKS5, HTTP Telegram Premium Բացահայտեք բացառիկ հնարավորություններ - Օգնեք մեզ զարգացնել նախագիծը + Էնտուզիաստների նախագիծ + Շնորհակալություն MonoGram-ին աջակցելու համար + Հեղինակները շնորհակալ են Ձեզ, նախագծին արդեն օգնել է %1$d մարդ + Նախագծին արդեն օգնել է %1$d մարդ + Աջակցողների ցուցակը բեռնվում է… + Boosty աջակցություն + boosty.to/monogram + Crypto աջակցություն + t.me/send?start=IVm03D7GNky7 + github.com/monogram-android/monogram + Աջակցություն Աջակցում է MonoGram-ին Այս օգտատերը աջակցում է նախագծին և օգնում մեզ կատարելագործվել MonoGram-ի տարբերակը և տվյալները Debug Կարգաբերման տարբերակներ - Ցույց տալ հովանավորի պատուհանը - Բացել հովանավորների մասին տեղեկությունը + Աջակցողների քանակը Ստիպողաբար համաժամեցնել հովանավորներին - Այժմ ներբեռնել հովանավորների ID-ները ալիքից + Աջակցողների ցուցակը բեռնվում է + Համաժամեցում դեռ չի եղել Դուրս գալ Անջատվել հաշվից Օգտանուններ diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index b369fe3a..00515c0c 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -367,16 +367,26 @@ MTProto, SOCKS5, HTTP Telegram Premium Desbloqueie recursos exclusivos - Ajude a desenvolver o projeto + Projeto de entusiastas + Obrigado por apoiar o MonoGram + Os autores agradecem a você, %1$d pessoas já ajudaram o projeto + %1$d pessoas já ajudaram o projeto + Carregando lista de apoiadores… + Apoio no Boosty + boosty.to/monogram + Apoio com cripto + t.me/send?start=IVm03D7GNky7 + github.com/monogram-android/monogram + Apoio Apoia o MonoGram Este usuário apoia o projeto e nos ajuda a melhorar Versão e informações do MonoGram Depuração Opções de depuração - Mostrar ficha do patrocinador - Abrir ficha com informações do patrocinador + Quantidade de apoiadores Forçar sincronização de patrocinadores - Buscar IDs de patrocinadores do canal agora + A lista de apoiadores está carregando + Nunca sincronizado Sair Desconectar da conta diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 52e4118e..c3f58abf 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -38,6 +38,16 @@ Новости и обновления проекта Поддержать разработку Помочь проекту развиваться + Проект энтузиастов + Спасибо за поддержку MonoGram + Авторы благодарны Вам, проекту уже помогли %1$d человек + Проекту уже помогли %1$d человек + Загружается список поддержавших… + Поддержка Boosty + boosty.to/monogram + Поддержка Crypto + t.me/send?start=IVm03D7GNky7 + github.com/monogram-android/monogram Разработчики Разработчик Дизайнер @@ -326,6 +336,11 @@ Участники могут отправлять одно сообщение каждые %1$s Защищенный контент Пересылка и сохранение ограничены + Подписывать сообщения + Показывать имя автора под постами канала + Только для участников + Писать смогут только вступившие участники + Разрешить темы в этом чате Приватная пересылка Пересланные сообщения скрывают ссылку на профиль Статистика @@ -349,7 +364,7 @@ QR-код Поделиться - MonoGram — бесплатный проект с открытым исходным кодом. Ваша поддержка помогает нам развивать приложение и добавлять новые функции. + MonoGram делают энтузиасты, денежная поддержка помогает развитию проекта Бейдж спонсора с сердцем выдаётся с уровня поддержки Advanced или при эквивалентной поддержке от 150 ₽ (примерно $1.96). @@ -362,6 +377,7 @@ Имя, информация о себе и фото профиля Включить ссылки t.me Открывать ссылки Telegram внутри приложения + Поддержка проекта Общие настройки Настройки чатов Темы, размер текста, видеоплеер @@ -384,16 +400,15 @@ MTProto, SOCKS5, HTTP Telegram Premium Эксклюзивные функции - Помощь в развитии проекта Поддержал MonoGram Этот пользователь поддерживает проект и помогает нам развиваться Версия и информация о MonoGram Отладка Параметры для разработчиков - Показать окно спонсора - Открыть нижнюю панель с информацией о спонсоре - Принудительная синхронизация спонсоров - Сейчас загрузить ID спонсоров из канала + Количество поддержавших + Обновить список поддержавших + Список поддержавших загружается + Синхронизации ещё не было Выйти Завершить сеанс @@ -966,10 +981,14 @@ Изменить имя Удалить контакт Редактирование контакта + Изменить Имя Фамилия Удалить контакт? Вы уверены, что хотите удалить %1$s из контактов? + Имя обязательно + Не удалось сохранить контакт + Не удалось удалить контакт Автоматически удалять новые сообщения в этой группе через определенное время. Добавить фото Изменить фото @@ -1186,6 +1205,7 @@ Закреплённые сообщения Закреплённое сообщение Показать все закреплённые + Показать пин Открепить Закрыть @@ -1919,6 +1939,9 @@ Очистить эмодзи Добавить в контакты Удалить из контактов + Поделиться номером + Этот контакт будет видеть ваш номер. + Для этого контакта нужно открыть ваш номер. Пометить прочитанным Пометить непрочитанным Изменить @@ -2333,6 +2356,16 @@ Бывший разработчик Участники проекта И огромное спасибо всем остальным, кто внес свой вклад в развитие + Показывать рекламу + Снова показывать рекламные сообщения в ленте каналов + Реклама + Рекомендованный + Новости Monogram + Оставайтесь на связи с Monogram + Подпишитесь на наш канал, чтобы получать новости о релизах, исправлениях и полезных возможностях приложения! + Потом + Подписаться + Подписываем… Чек-лист Чек-лист Название diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 9c360c94..24e9b20a 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -379,17 +379,27 @@ MTProto, SOCKS5, HTTP Telegram Premium Odomknúť exkluzívne funkcie - Pomôžte nám rozvíjať projekt + Projekt nadšencov + Ďakujeme za podporu MonoGramu + Autori Vám ďakujú, projektu už pomohlo %1$d ľudí + Projektu už pomohlo %1$d ľudí + Načítava sa zoznam podporovateľov… + Podpora cez Boosty + boosty.to/monogram + Podpora cez Crypto + t.me/send?start=IVm03D7GNky7 + github.com/monogram-android/monogram + Podpora Podporuje MonoGram Tento používateľ podporuje projekt a pomáha nám ho neustále zlepšovať Verzia a informácie o MonoGrame Ladenie Možnosti ladenia - Zobraziť sponzorský panel - Otvoriť spodný panel s informáciami o sponzorovi + Počet podporovateľov Vynútiť synchronizáciu sponzorov - Načítať ID sponzorov z kanála teraz + Zoznam podporovateľov sa načítava + Synchronizácia ešte neprebehla Odhlásiť sa Odpojiť sa od účtu diff --git a/presentation/src/main/res/values-tr/string.xml b/presentation/src/main/res/values-tr/string.xml index cea09af6..73de007f 100644 --- a/presentation/src/main/res/values-tr/string.xml +++ b/presentation/src/main/res/values-tr/string.xml @@ -363,16 +363,26 @@ MTProto, SOCKS5, HTTP Telegram Premium Özel özelliklerin kilidini açın - Projeyi geliştirmemize yardım edin + Gönüllülerin projesi + MonoGram\'ı desteklediğiniz için teşekkürler + Yazarlar size minnettar, projeye şimdiden %1$d kişi yardım etti + Projeye şimdiden %1$d kişi yardım etti + Destekçi listesi yükleniyor… + Boosty desteği + boosty.to/monogram + Kripto desteği + t.me/send?start=IVm03D7GNky7 + github.com/monogram-android/monogram + Destek MonoGram Destekçisi Bu kullanıcı projeyi destekliyor ve gelişmesine yardımcı oluyor MonoGram sürümü ve bilgisi Hata Ayıklama Hata ayıklama seçenekleri - Sponsor sayfasını göster - Sponsor bilgi sayfasını aç + Destekçi sayısı Sponsor senkronizasyonunu zorla - Sponsor ID\'lerini şimdi kanaldan çek + Destekçi listesi yükleniyor + Henüz senkronize edilmedi Push Tanılama Çalışma Zamanı Bayrakları Push Ortamı diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 046ba6c3..23d974d4 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -366,16 +366,26 @@ MTProto, SOCKS5, HTTP Telegram Premium Ексклюзивні функції - Допомога в розвитку проєкту + Проєкт ентузіастів + Дякуємо за підтримку MonoGram + Автори вдячні Вам, проєкту вже допомогли %1$d людей + Проєкту вже допомогли %1$d людей + Завантажується список тих, хто підтримав… + Підтримка на Boosty + boosty.to/monogram + Підтримка Crypto + t.me/send?start=IVm03D7GNky7 + github.com/monogram-android/monogram + Підтримка Підтримав MonoGram Цей користувач підтримує проєкт і допомагає нам розвиватися Версія та інформація про MonoGram Налагодження Параметри для розробників - Показати вікно спонсора - Відкрити нижню панель з інформацією про спонсора + Кількість тих, хто підтримав Примусова синхронізація спонсорів - Завантажити ID спонсорів із каналу зараз + Список тих, хто підтримав, завантажується + Синхронізації ще не було Вийти Завершити сеанс diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 8c40e075..6ed9d8f1 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -363,16 +363,26 @@ MTProto, SOCKS5, HTTP Telegram Premium 解锁专属功能 - 帮助我们开发项目 + 爱好者项目 + 感谢你支持 MonoGram + 作者感谢你的支持,已有 %1$d 人帮助了项目 + 已有 %1$d 人帮助了项目 + 正在加载支持者列表… + Boosty 支持 + boosty.to/monogram + 加密货币支持 + t.me/send?start=IVm03D7GNky7 + github.com/monogram-android/monogram + 支持项目 支持了 MonoGram 该用户正在支持项目,帮助我们持续改进 MonoGram 版本和信息 调试 调试选项 - 显示赞助者弹窗 - 打开赞助者信息底部弹窗 + 支持者数量 强制同步赞助者 - 立即从频道拉取赞助者 ID + 支持者列表正在加载 + 尚未同步 退出登录 断开账号连接 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 05485356..76786e01 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -49,6 +49,16 @@ Stay updated with the latest news and announcements Support MonoGram Support the development and help us keep the project alive + Enthusiast project + Thank you for supporting MonoGram + The authors are grateful to you, %1$d people already helped the project + %1$d people already helped the project + Loading supporters list… + Boosty support + boosty.to/monogram + Crypto support + t.me/send?start=IVm03D7GNky7 + github.com/monogram-android/monogram Maintainers Developer Icon & Logo Designer @@ -563,6 +573,11 @@ Members can send one message every %1$s Protected content Forwarding and saving are restricted + Sign messages + Show the sender name on channel posts + Join before sending + Require members to join before they can post + Let members create and manage topics Private forwards Forwarded messages hide profile link Chat Stats @@ -589,8 +604,7 @@ QR Code Share - MonoGram is a free and open-source project. Your support helps us to - keep it alive and develop new features. + Open source client by enthusiasts, your help keeps development going The sponsor badge with a heart is available from Advanced support level or an equivalent contribution of 150 RUB (about $1.96). @@ -604,6 +618,7 @@ Change your name, bio, and profile photo Enable t.me links Open Telegram links to open them in-app + Support General Chat Settings Themes, text size, video player @@ -626,16 +641,15 @@ MTProto, SOCKS5, HTTP Telegram Premium Unlock exclusive features - Help us develop the project Supports MonoGram This user supports the project and helps us keep improving it MonoGram version and info Debug Debug options - Show sponsor sheet - Open sponsor info bottom sheet - Force sponsor sync - Fetch sponsor IDs from channel now + Supporters count + Refresh supporters list + Supporters list is loading + Never synced Push Diagnostics Runtime Flags Push Environment @@ -1288,10 +1302,14 @@ Edit Name Remove Contact Edit Contact + Edit First Name Last Name Remove contact? Are you sure you want to remove %1$s from your contacts? + First name is required + Failed to save contact + Failed to remove contact Automatically delete new messages sent in this group after a certain period of time. Add photo Change photo @@ -1649,6 +1667,7 @@ Pinned Messages Pinned Message Show all pinned + Show pin Unpin Close @@ -2314,6 +2333,7 @@ %d views Copy Link Open in Browser + Unable to open article Search Text Size Play Video @@ -2492,6 +2512,9 @@ Clear Emojis Add to contacts Remove from contacts + Share phone number + Let this contact see your number. + This contact needs your number to be shared. Mark as read Mark as unread Edit @@ -2509,4 +2532,14 @@ Ex-Developer Contributors And a big thank you to everyone else who contributed to the development + Show ads + Show sponsored messages again in channel feeds + Sponsored + Recommended + Monogram updates + Stay in the loop with Monogram + Subscribe to our channel to receive news about releases, fixes, and useful app features! + Later + Subscribe + Subscribing... diff --git a/presentation/src/test/java/org/monogram/presentation/core/util/UriTempFileUtilsTest.kt b/presentation/src/test/java/org/monogram/presentation/core/util/UriTempFileUtilsTest.kt new file mode 100644 index 00000000..5d41016b --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/core/util/UriTempFileUtilsTest.kt @@ -0,0 +1,55 @@ +package org.monogram.presentation.core.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.monogram.presentation.features.share.PendingAttachmentKind +import org.monogram.presentation.features.share.inferPendingAttachmentKind + +class UriTempFileUtilsTest { + @Test + fun `resolveMediaExtension keeps heic for heic images`() { + assertEquals("heic", resolveMediaExtension("image/heic")) + } + + @Test + fun `resolveMediaExtension keeps png for png images`() { + assertEquals("png", resolveMediaExtension("image/png")) + } + + @Test + fun `resolveMediaExtension keeps webp for webp images`() { + assertEquals("webp", resolveMediaExtension("image/webp")) + } + + @Test + fun `resolveMediaExtension falls back to jpg for unknown image mime`() { + assertEquals("jpg", resolveMediaExtension("image/unknown")) + } + + @Test + fun `inferPendingAttachmentKind detects video by mime without mp4 extension`() { + assertEquals( + PendingAttachmentKind.VIDEO, + inferPendingAttachmentKind( + localPath = "/tmp/clip.bin", + mimeType = "video/quicktime" + ) + ) + } + + @Test + fun `inferPendingAttachmentKind detects video by common extension`() { + assertEquals( + PendingAttachmentKind.VIDEO, + inferPendingAttachmentKind("/tmp/clip.MOV") + ) + } + + @Test + fun `inferPendingAttachmentKind detects gif before photo fallback`() { + assertEquals( + PendingAttachmentKind.GIF, + inferPendingAttachmentKind("/tmp/animation.gif") + ) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/common/ChatActionPolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/common/ChatActionPolicyTest.kt new file mode 100644 index 00000000..a76c47bd --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/common/ChatActionPolicyTest.kt @@ -0,0 +1,90 @@ +package org.monogram.presentation.features.chats.common + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatActionPolicyTest { + + @Test + fun `private main chat resolves delete only`() { + val policy = resolveChatActionPolicy( + isMainChat = true, + isGroup = false, + isChannel = false, + isMember = true, + canDeleteChat = true, + canReport = true, + canJoin = false, + canBlockOrUnblock = true, + canPin = false, + context = ChatActionScreenContext.Chat + ) + + assertEquals(ChatExitAction.Delete, policy.exitAction) + assertTrue(policy.canClearHistory) + assertTrue(policy.canReport) + assertFalse(policy.canJoin) + } + + @Test + fun `group member resolves leave only`() { + val policy = resolveChatActionPolicy( + isMainChat = true, + isGroup = true, + isChannel = false, + isMember = true, + canDeleteChat = false, + canReport = true, + canJoin = false, + canBlockOrUnblock = false, + canPin = true, + context = ChatActionScreenContext.Profile + ) + + assertEquals(ChatExitAction.Leave, policy.exitAction) + assertTrue(policy.canClearHistory) + assertFalse(policy.canJoin) + } + + @Test + fun `non member channel resolves join and no destructive action`() { + val policy = resolveChatActionPolicy( + isMainChat = true, + isGroup = false, + isChannel = true, + isMember = false, + canDeleteChat = false, + canReport = true, + canJoin = true, + canBlockOrUnblock = false, + canPin = true, + context = ChatActionScreenContext.ListSelection + ) + + assertEquals(ChatExitAction.None, policy.exitAction) + assertTrue(policy.canJoin) + assertFalse(policy.closeOnExitSuccess) + } + + @Test + fun `thread disables destructive actions`() { + val policy = resolveChatActionPolicy( + isMainChat = false, + isGroup = true, + isChannel = false, + isMember = true, + canDeleteChat = true, + canReport = true, + canJoin = false, + canBlockOrUnblock = false, + canPin = false, + context = ChatActionScreenContext.Chat + ) + + assertEquals(ChatExitAction.None, policy.exitAction) + assertFalse(policy.canClearHistory) + assertFalse(policy.canJoin) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/common/ChatExitActionTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/common/ChatExitActionTest.kt new file mode 100644 index 00000000..3959161a --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/common/ChatExitActionTest.kt @@ -0,0 +1,76 @@ +package org.monogram.presentation.features.chats.common + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatExitActionTest { + @Test + fun `returns delete for private chat with delete permission`() { + assertEquals( + ChatExitAction.Delete, + resolveChatExitAction( + isMainChat = true, + isGroup = false, + isChannel = false, + isMember = true, + canDeleteChat = true + ) + ) + } + + @Test + fun `returns leave for member group`() { + assertEquals( + ChatExitAction.Leave, + resolveChatExitAction( + isMainChat = true, + isGroup = true, + isChannel = false, + isMember = true, + canDeleteChat = false + ) + ) + } + + @Test + fun `returns leave for member channel`() { + assertEquals( + ChatExitAction.Leave, + resolveChatExitAction( + isMainChat = true, + isGroup = false, + isChannel = true, + isMember = true, + canDeleteChat = false + ) + ) + } + + @Test + fun `returns none for non member group`() { + assertEquals( + ChatExitAction.None, + resolveChatExitAction( + isMainChat = true, + isGroup = true, + isChannel = false, + isMember = false, + canDeleteChat = false + ) + ) + } + + @Test + fun `returns none for non main chat`() { + assertEquals( + ChatExitAction.None, + resolveChatExitAction( + isMainChat = false, + isGroup = false, + isChannel = false, + isMember = true, + canDeleteChat = true + ) + ) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ChatRuntimeScrollBehaviorTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ChatRuntimeScrollBehaviorTest.kt index 12534b43..67bb69cd 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ChatRuntimeScrollBehaviorTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ChatRuntimeScrollBehaviorTest.kt @@ -13,14 +13,7 @@ class ChatRuntimeScrollBehaviorTest { fun `enqueueRuntimeScrollToBottom keeps settled viewport while queuing runtime scroll`() { val initial = ChatComponent.State( messages = listOf( - MessageModel( - id = 42L, - date = 0, - isOutgoing = true, - senderName = "me", - chatId = 1L, - content = MessageContent.Text("hello") - ) + message(id = 42L, text = "hello", isOutgoing = true, senderName = "me") ), isLatestLoaded = true, viewportPhase = ChatViewportPhase.Settled @@ -37,24 +30,122 @@ class ChatRuntimeScrollBehaviorTest { } @Test - fun `shouldHideChatContentForViewportTransition only hides active non-settled viewport`() { + fun `updateChatContentVisibilityLatch keeps active content hidden until first settle`() { assertFalse( - shouldHideChatContentForViewportTransition( + updateChatContentVisibilityLatch( + previousVisible = false, + renderMode = ChatRenderMode.Active, + viewportPhase = ChatViewportPhase.Initializing + ) + ) + } + + @Test + fun `updateChatContentVisibilityLatch shows active content on first settled viewport`() { + assertTrue( + updateChatContentVisibilityLatch( + previousVisible = false, renderMode = ChatRenderMode.Active, viewportPhase = ChatViewportPhase.Settled ) ) + } + + @Test + fun `updateChatContentVisibilityLatch does not hide active content again after settle`() { assertTrue( - shouldHideChatContentForViewportTransition( + updateChatContentVisibilityLatch( + previousVisible = true, renderMode = ChatRenderMode.Active, viewportPhase = ChatViewportPhase.Restoring ) ) - assertFalse( - shouldHideChatContentForViewportTransition( + assertTrue( + updateChatContentVisibilityLatch( + previousVisible = true, + renderMode = ChatRenderMode.Active, + viewportPhase = ChatViewportPhase.Initializing + ) + ) + } + + @Test + fun `updateChatContentVisibilityLatch keeps preview modes visible`() { + assertTrue( + updateChatContentVisibilityLatch( + previousVisible = false, renderMode = ChatRenderMode.SwipePreview, viewportPhase = ChatViewportPhase.Restoring ) ) + assertTrue( + updateChatContentVisibilityLatch( + previousVisible = false, + renderMode = ChatRenderMode.ForumTopicSwipePreview, + viewportPhase = ChatViewportPhase.Initializing + ) + ) + } + + @Test + fun `shouldRepairUnexpectedChatReset detects unexpected main-chat reset`() { + val previous = ChatComponent.State( + messages = listOf(message()), + viewportPhase = ChatViewportPhase.Settled, + isLoading = false, + isAtBottom = false + ) + val next = previous.copy( + messages = emptyList(), + viewportPhase = ChatViewportPhase.Initializing, + isLoading = true, + isAtBottom = true + ) + + assertTrue(shouldRepairUnexpectedChatReset(previous, next)) + } + + @Test + fun `repairUnexpectedChatReset preserves previous content and viewport`() { + val previous = ChatComponent.State( + messages = listOf(message()), + viewportPhase = ChatViewportPhase.Settled, + isLoading = false, + isLatestLoaded = true, + isOldestLoaded = false, + isAtBottom = false + ) + val reset = previous.copy( + messages = emptyList(), + viewportPhase = ChatViewportPhase.Initializing, + isLoading = true, + isLatestLoaded = false, + isOldestLoaded = false, + isAtBottom = true + ) + + val repaired = repairUnexpectedChatReset(previous, reset) + + assertEquals(previous.messages, repaired.messages) + assertEquals(ChatViewportPhase.Settled, repaired.viewportPhase) + assertTrue(repaired.isLoading) + assertFalse(repaired.isAtBottom) + assertFalse(repaired.isLatestLoaded) + } + + private fun message( + id: Long = 1L, + text: String = "cached", + isOutgoing: Boolean = false, + senderName: String = "user" + ): MessageModel { + return MessageModel( + id = id, + date = 0, + isOutgoing = isOutgoing, + senderName = senderName, + chatId = 1L, + content = MessageContent.Text(text) + ) } } diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ChatSwipeBackStateTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ChatSwipeBackStateTest.kt new file mode 100644 index 00000000..57794b6d --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ChatSwipeBackStateTest.kt @@ -0,0 +1,59 @@ +package org.monogram.presentation.features.chats.conversation + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.presentation.core.ui.ScreenSwipeBackAction +import org.monogram.presentation.core.ui.ScreenSwipeBackPreview + +class ChatSwipeBackStateTest { + @Test + fun `current topic exposes local topic close swipe`() { + val swipeState = resolveChatSwipeBackState( + state = ChatComponent.State( + viewAsTopics = true, + currentTopicId = 42L, + ) + ) + + assertEquals(ScreenSwipeBackAction.LocalChatTopicClose, swipeState.action) + assertEquals(ScreenSwipeBackPreview.ChatForumList, swipeState.preview) + assertFalse(swipeState.isBlocked) + assertTrue(swipeState.isSupported) + } + + @Test + fun `no topic exposes normal chat back behavior`() { + val swipeState = resolveChatSwipeBackState( + state = ChatComponent.State( + viewAsTopics = true, + currentTopicId = null, + ) + ) + + assertEquals(ScreenSwipeBackAction.StackPop, swipeState.action) + assertEquals(ScreenSwipeBackPreview.PreviousStackEntry, swipeState.preview) + } + + @Test + fun `viewer state still blocks swipe`() { + val swipeState = resolveChatSwipeBackState( + state = ChatComponent.State( + fullScreenImages = listOf("file://preview.jpg"), + ) + ) + + assertTrue(swipeState.isBlocked) + } + + @Test + fun `transient overlay blocks swipe`() { + val swipeState = resolveChatSwipeBackState( + state = ChatComponent.State(), + hasTransientBlockingOverlay = true, + ) + + assertTrue(swipeState.isBlocked) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicyTest.kt new file mode 100644 index 00000000..b49fad66 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicyTest.kt @@ -0,0 +1,58 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.ChatViewportCacheEntry + +class ChatInitialLoadPolicyTest { + @Test + fun `initial load starts when context has not started yet`() { + val key = buildChatInitialLoadKey( + chatId = 1L, + effectiveThreadId = null, + initialMessageId = null, + savedViewport = null, + firstUnreadMessageId = null, + rootMessageId = null + ) + + assertTrue(shouldStartInitialLoad(null, key, hasStartedForCurrentContext = false)) + } + + @Test + fun `same context does not start initial load twice`() { + val key = buildChatInitialLoadKey( + chatId = 1L, + effectiveThreadId = 2L, + initialMessageId = 10L, + savedViewport = ChatViewportCacheEntry(20L, 4, false), + firstUnreadMessageId = 30L, + rootMessageId = 40L + ) + + assertFalse(shouldStartInitialLoad(key, key, hasStartedForCurrentContext = true)) + } + + @Test + fun `changed viewport anchor starts a new initial load`() { + val previous = buildChatInitialLoadKey( + chatId = 1L, + effectiveThreadId = 2L, + initialMessageId = null, + savedViewport = ChatViewportCacheEntry(20L, 4, false), + firstUnreadMessageId = null, + rootMessageId = null + ) + val next = buildChatInitialLoadKey( + chatId = 1L, + effectiveThreadId = 2L, + initialMessageId = null, + savedViewport = ChatViewportCacheEntry(21L, 4, false), + firstUnreadMessageId = null, + rootMessageId = null + ) + + assertTrue(shouldStartInitialLoad(previous, next, hasStartedForCurrentContext = true)) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/PendingAttachmentSendPlanTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/PendingAttachmentSendPlanTest.kt new file mode 100644 index 00000000..1d567007 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/PendingAttachmentSendPlanTest.kt @@ -0,0 +1,49 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.presentation.features.share.PendingAttachment +import org.monogram.presentation.features.share.PendingAttachmentKind + +class PendingAttachmentSendPlanTest { + @Test + fun `single attachment uses single plan`() { + val plan = resolvePendingAttachmentSendPlan( + listOf(attachment("/tmp/photo.jpg", PendingAttachmentKind.PHOTO)) + ) + + assertTrue(plan is PendingAttachmentSendPlan.Single) + } + + @Test + fun `multiple photo and video attachments use album plan in original order`() { + val attachments = listOf( + attachment("/tmp/1.jpg", PendingAttachmentKind.PHOTO), + attachment("/tmp/2.mp4", PendingAttachmentKind.VIDEO), + attachment("/tmp/3.jpg", PendingAttachmentKind.PHOTO) + ) + + val plan = resolvePendingAttachmentSendPlan(attachments) + + assertTrue(plan is PendingAttachmentSendPlan.Album) + assertEquals(attachments, (plan as PendingAttachmentSendPlan.Album).attachments) + } + + @Test + fun `mixed documents and gifs use individual plan in original order`() { + val attachments = listOf( + attachment("/tmp/1.jpg", PendingAttachmentKind.PHOTO), + attachment("/tmp/2.pdf", PendingAttachmentKind.DOCUMENT), + attachment("/tmp/3.gif", PendingAttachmentKind.GIF) + ) + + val plan = resolvePendingAttachmentSendPlan(attachments) + + assertTrue(plan is PendingAttachmentSendPlan.Individual) + assertEquals(attachments, (plan as PendingAttachmentSendPlan.Individual).attachments) + } + + private fun attachment(path: String, kind: PendingAttachmentKind) = + PendingAttachment(localPath = path, kind = kind) +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/PinnedMessagesPolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/PinnedMessagesPolicyTest.kt new file mode 100644 index 00000000..17edf59d --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/PinnedMessagesPolicyTest.kt @@ -0,0 +1,154 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.presentation.features.chats.conversation.ui.content.ChatContentTopBarUiState +import org.monogram.presentation.features.chats.conversation.ui.content.shouldShowPinnedMessageAction +import org.monogram.presentation.features.chats.conversation.ui.content.shouldShowPinnedMessageBar + +class PinnedMessagesPolicyTest { + @Test + fun `hiding pinned message only flips hidden flag`() { + val initial = state(pinned = pinnedMessage(10L)) + + val hidden = initial.hidePinnedMessage() + + assertTrue(hidden.isPinnedMessageHidden) + assertEquals(10L, hidden.pinnedMessage?.id) + } + + @Test + fun `showing pinned message clears hidden flag`() { + val initial = state(pinned = pinnedMessage(10L), hidden = true) + + val shown = initial.showPinnedMessage() + + assertFalse(shown.isPinnedMessageHidden) + assertEquals(10L, shown.pinnedMessage?.id) + } + + @Test + fun `refresh keeps hidden state for the same pinned id`() { + val initial = state(pinned = pinnedMessage(10L), hidden = true) + + val refreshed = initial.applyPinnedMessageRefresh( + pinnedMessage = pinnedMessage(10L), + pinnedMessageCount = 3 + ) + + assertTrue(refreshed.isPinnedMessageHidden) + assertEquals(10L, refreshed.pinnedMessage?.id) + assertEquals(3, refreshed.pinnedMessageCount) + } + + @Test + fun `refresh keeps hidden state for different pinned id and null`() { + val hidden = state(pinned = pinnedMessage(10L), hidden = true) + + val nextPinned = hidden.applyPinnedMessageRefresh( + pinnedMessage = pinnedMessage(11L), + pinnedMessageCount = 2 + ) + val cleared = hidden.applyPinnedMessageRefresh( + pinnedMessage = null, + pinnedMessageCount = 0 + ) + + assertTrue(nextPinned.isPinnedMessageHidden) + assertTrue(cleared.isPinnedMessageHidden) + } + + @Test + fun `show pin action is only available for hidden pinned messages in normal chat`() { + val visible = topBarState(pinned = pinnedMessage(10L)) + val hidden = topBarState(pinned = pinnedMessage(10L), hidden = true) + val thread = topBarState( + pinned = pinnedMessage(10L), + hidden = true, + rootMessage = pinnedMessage(20L) + ) + val search = topBarState( + pinned = null, + hidden = true, + searchActive = true + ) + + assertFalse(shouldShowPinnedMessageAction(visible, isSelectionMode = false)) + assertTrue(shouldShowPinnedMessageAction(hidden, isSelectionMode = false)) + assertFalse(shouldShowPinnedMessageAction(hidden, isSelectionMode = true)) + assertFalse(shouldShowPinnedMessageAction(thread, isSelectionMode = false)) + assertFalse(shouldShowPinnedMessageAction(search, isSelectionMode = false)) + } + + @Test + fun `pinned bar visibility respects hidden selection and thread state`() { + val visible = topBarState(pinned = pinnedMessage(10L)) + val hidden = topBarState(pinned = pinnedMessage(10L), hidden = true) + val thread = topBarState(pinned = pinnedMessage(10L), rootMessage = pinnedMessage(20L)) + + assertTrue(shouldShowPinnedMessageBar(visible, isSelectionMode = false)) + assertFalse(shouldShowPinnedMessageBar(hidden, isSelectionMode = false)) + assertFalse(shouldShowPinnedMessageBar(visible, isSelectionMode = true)) + assertFalse(shouldShowPinnedMessageBar(thread, isSelectionMode = false)) + } + + private fun state( + pinned: MessageModel?, + hidden: Boolean = false + ) = org.monogram.presentation.features.chats.conversation.ChatComponent.State( + pinnedMessage = pinned, + pinnedMessageCount = if (pinned != null) 1 else 0, + isPinnedMessageHidden = hidden + ) + + private fun topBarState( + pinned: MessageModel?, + hidden: Boolean = false, + rootMessage: MessageModel? = null, + searchActive: Boolean = false + ) = ChatContentTopBarUiState( + currentTopicId = null, + rootMessage = rootMessage, + isGroup = false, + isChannel = false, + isAdmin = false, + permissions = ChatPermissionsModel(), + otherUser = null, + currentUser = null, + typingAction = null, + memberCount = 0, + onlineCount = 0, + topics = emptyList(), + chatTitle = "Chat", + chatAvatar = null, + chatPersonalAvatar = null, + chatEmojiStatus = null, + isOnline = false, + isVerified = false, + isSponsor = false, + isWhitelistedInAdBlock = false, + isInstalledFromGooglePlay = true, + isMuted = false, + isSearchActive = searchActive, + searchQuery = "", + isMember = true, + canDeleteChat = false, + pinnedMessage = pinned, + pinnedMessageCount = if (pinned != null) 1 else 0, + isPinnedMessageHidden = hidden + ) + + private fun pinnedMessage(id: Long) = MessageModel( + id = id, + date = id.toInt(), + isOutgoing = false, + senderName = "sender", + chatId = 1L, + content = MessageContent.Text("text") + ) +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/UnreadMessagesPolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/UnreadMessagesPolicyTest.kt index 2f897430..b4010fa4 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/UnreadMessagesPolicyTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/UnreadMessagesPolicyTest.kt @@ -81,6 +81,34 @@ class UnreadMessagesPolicyTest { assertEquals(0, state.unreadSeparatorCount) } + @Test + fun `visible messages read decrements unread once reporting is enabled`() { + val initial = ChatComponent.State( + chatId = CHAT_ID, + isAtBottom = false, + messages = listOf( + message(id = 101L), + message(id = 102L), + message(id = 103L) + ), + unreadCount = 3, + unreadSeparatorCount = 3, + lastReadInboxMessageId = 100L, + unreadSeparatorLastReadInboxMessageId = 100L + ) + + val updated = initial.withVisibleMessagesRead( + readChatId = CHAT_ID, + visibleMessageIds = listOf(101L, 102L) + ) + + assertEquals(1, updated.unreadCount) + assertEquals(1, updated.unreadSeparatorCount) + assertEquals(102L, updated.lastReadInboxMessageId) + assertTrue(updated.messages.first { it.id == 101L }.isRead) + assertTrue(updated.messages.first { it.id == 102L }.isRead) + } + private fun message(id: Long): MessageModel = MessageModel( id = id, diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChannelSponsoredPolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChannelSponsoredPolicyTest.kt new file mode 100644 index 00000000..06089d0f --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChannelSponsoredPolicyTest.kt @@ -0,0 +1,73 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.presentation.features.chats.conversation.logic.ChannelSponsoredRequestContext +import org.monogram.presentation.features.chats.conversation.logic.shouldRequestChannelSponsoredMessage + +class ChannelSponsoredPolicyTest { + @Test + fun `requests sponsored message only for main channel feed by default`() { + assertTrue( + shouldRequestChannelSponsoredMessage( + ChannelSponsoredRequestContext( + isChannel = true, + isGroup = false, + isBot = false, + currentTopicId = null, + rootMessageId = null, + viewAsTopics = false, + isPremium = false, + showSponsoredMessagesForPremium = false + ) + ) + ) + } + + @Test + fun `blocks sponsored message for premium users unless opt-in is enabled`() { + assertFalse( + shouldRequestChannelSponsoredMessage( + baseContext(isPremium = true, showSponsoredMessagesForPremium = false) + ) + ) + assertTrue( + shouldRequestChannelSponsoredMessage( + baseContext(isPremium = true, showSponsoredMessagesForPremium = true) + ) + ) + } + + @Test + fun `blocks sponsored message for private group bot comments and topics`() { + assertFalse(shouldRequestChannelSponsoredMessage(baseContext(isChannel = false))) + assertFalse(shouldRequestChannelSponsoredMessage(baseContext(isGroup = true))) + assertFalse(shouldRequestChannelSponsoredMessage(baseContext(isBot = true))) + assertFalse(shouldRequestChannelSponsoredMessage(baseContext(rootMessageId = 10L))) + assertFalse(shouldRequestChannelSponsoredMessage(baseContext(currentTopicId = 1L))) + assertFalse(shouldRequestChannelSponsoredMessage(baseContext(viewAsTopics = true))) + } + + private fun baseContext( + isChannel: Boolean = true, + isGroup: Boolean = false, + isBot: Boolean = false, + currentTopicId: Long? = null, + rootMessageId: Long? = null, + viewAsTopics: Boolean = false, + isPremium: Boolean = false, + showSponsoredMessagesForPremium: Boolean = false + ): ChannelSponsoredRequestContext { + return ChannelSponsoredRequestContext( + isChannel = isChannel, + isGroup = isGroup, + isBot = isBot, + currentTopicId = currentTopicId, + rootMessageId = rootMessageId, + viewAsTopics = viewAsTopics, + isPremium = isPremium, + showSponsoredMessagesForPremium = showSponsoredMessagesForPremium + ) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEdgeLoadPolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEdgeLoadPolicyTest.kt new file mode 100644 index 00000000..cdd15f94 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEdgeLoadPolicyTest.kt @@ -0,0 +1,83 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatContentEdgeLoadPolicyTest { + @Test + fun `same older anchor does not trigger duplicate load`() { + val decision = decideEdgeLoad( + isComments = false, + isOldestLoaded = false, + isLatestLoaded = false, + isAtBottom = false, + nearOlderEdge = true, + nearNewerEdge = false, + olderAnchorId = 100L, + newerAnchorId = 500L, + lastOlderAnchorId = 100L, + lastNewerAnchorId = null + ) + + assertFalse(decision.shouldLoadOlder) + assertEquals(null, decision.nextOlderAnchorId) + } + + @Test + fun `new older anchor triggers load`() { + val decision = decideEdgeLoad( + isComments = false, + isOldestLoaded = false, + isLatestLoaded = false, + isAtBottom = false, + nearOlderEdge = true, + nearNewerEdge = false, + olderAnchorId = 99L, + newerAnchorId = 500L, + lastOlderAnchorId = 100L, + lastNewerAnchorId = null + ) + + assertTrue(decision.shouldLoadOlder) + assertEquals(99L, decision.nextOlderAnchorId) + } + + @Test + fun `root chat newer load is gated while at bottom`() { + val decision = decideEdgeLoad( + isComments = false, + isOldestLoaded = false, + isLatestLoaded = false, + isAtBottom = true, + nearOlderEdge = false, + nearNewerEdge = true, + olderAnchorId = 99L, + newerAnchorId = 500L, + lastOlderAnchorId = null, + lastNewerAnchorId = null + ) + + assertFalse(decision.shouldLoadNewer) + } + + @Test + fun `comments newer load stays enabled away from latest`() { + val decision = decideEdgeLoad( + isComments = true, + isOldestLoaded = false, + isLatestLoaded = false, + isAtBottom = true, + nearOlderEdge = false, + nearNewerEdge = true, + olderAnchorId = 99L, + newerAnchorId = 500L, + lastOlderAnchorId = null, + lastNewerAnchorId = 499L + ) + + assertTrue(decision.shouldLoadNewer) + assertEquals(500L, decision.nextNewerAnchorId) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentRuntimePolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentRuntimePolicyTest.kt index e42029c2..d7d9c596 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentRuntimePolicyTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentRuntimePolicyTest.kt @@ -3,10 +3,77 @@ package org.monogram.presentation.features.chats.conversation.ui.content import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.monogram.presentation.features.chats.conversation.ChatRenderMode import org.monogram.presentation.features.chats.conversation.ChatScrollCommand import org.monogram.presentation.features.chats.conversation.ChatViewportPhase +import org.monogram.presentation.features.chats.conversation.updateChatContentVisibilityLatch class ChatContentRuntimePolicyTest { + @Test + fun `updateChatContentVisibilityLatch keeps active chat visible after first settle through reopen sequence`() { + val afterFirstSettle = updateChatContentVisibilityLatch( + previousVisible = false, + renderMode = ChatRenderMode.Active, + viewportPhase = ChatViewportPhase.Settled + ) + val afterCloseInfo = updateChatContentVisibilityLatch( + previousVisible = afterFirstSettle, + renderMode = ChatRenderMode.Active, + viewportPhase = ChatViewportPhase.Restoring + ) + val afterReopenInfo = updateChatContentVisibilityLatch( + previousVisible = afterCloseInfo, + renderMode = ChatRenderMode.Active, + viewportPhase = ChatViewportPhase.Initializing + ) + + assertTrue(afterFirstSettle) + assertTrue(afterCloseInfo) + assertTrue(afterReopenInfo) + } + + @Test + fun `shouldAutoSettleViewportAfterContentReady settles cached chat content while remote load continues`() { + assertTrue( + shouldAutoSettleViewportAfterContentReady( + viewportPhase = ChatViewportPhase.Initializing, + pendingScrollCommand = null, + hasMessages = true, + viewAsTopics = false, + currentTopicId = null, + topicsCount = 0 + ) + ) + } + + @Test + fun `shouldAutoSettleViewportAfterContentReady waits for pending scroll command`() { + assertFalse( + shouldAutoSettleViewportAfterContentReady( + viewportPhase = ChatViewportPhase.Restoring, + pendingScrollCommand = ChatScrollCommand.ScrollToBottom(animated = false), + hasMessages = true, + viewAsTopics = false, + currentTopicId = null, + topicsCount = 0 + ) + ) + } + + @Test + fun `shouldAutoSettleViewportAfterContentReady ignores empty initializing state`() { + assertFalse( + shouldAutoSettleViewportAfterContentReady( + viewportPhase = ChatViewportPhase.Initializing, + pendingScrollCommand = null, + hasMessages = false, + viewAsTopics = false, + currentTopicId = null, + topicsCount = 0 + ) + ) + } + @Test fun `shouldSuppressMessageEntryAnimations only depends on initial loading and viewport lifecycle`() { assertFalse( @@ -79,4 +146,327 @@ class ChatContentRuntimePolicyTest { ) ) } + + @Test + fun `shouldAutoFollowLatestAfterContentChange follows when last grouped message changes and follow is armed`() { + assertTrue( + shouldAutoFollowLatestAfterContentChange( + previousLastGroupedMessageId = 10L, + currentLastGroupedMessageId = 11L, + followLatestArmed = true, + viewportPhase = ChatViewportPhase.Settled, + pendingScrollCommand = null, + isLoading = false, + isLoadingOlder = false, + isLoadingNewer = false, + isScrollInProgress = false, + showInitialLoading = false, + isLatestLoaded = true + ) + ) + } + + @Test + fun `updateFollowLatestArmed stays armed through passive layout shifts after new content`() { + assertTrue( + updateFollowLatestArmed( + previousArmed = true, + isNearBottom = false, + hasUserScrolledAwayFromBottom = false, + isDragged = false + ) + ) + } + + @Test + fun `updateFollowLatestArmed disarms after real user scroll away`() { + assertFalse( + updateFollowLatestArmed( + previousArmed = true, + isNearBottom = false, + hasUserScrolledAwayFromBottom = true, + isDragged = false + ) + ) + assertFalse( + updateFollowLatestArmed( + previousArmed = true, + isNearBottom = false, + hasUserScrolledAwayFromBottom = false, + isDragged = true + ) + ) + } + + @Test + fun `updateFollowLatestArmed rearms when viewport returns near bottom`() { + assertTrue( + updateFollowLatestArmed( + previousArmed = false, + isNearBottom = true, + hasUserScrolledAwayFromBottom = false, + isDragged = false + ) + ) + } + + @Test + fun `shouldAutoFollowLatestAfterContentChange does not follow when user is outside near-bottom zone`() { + assertFalse( + shouldAutoFollowLatestAfterContentChange( + previousLastGroupedMessageId = 10L, + currentLastGroupedMessageId = 11L, + followLatestArmed = false, + viewportPhase = ChatViewportPhase.Settled, + pendingScrollCommand = null, + isLoading = false, + isLoadingOlder = false, + isLoadingNewer = false, + isScrollInProgress = false, + showInitialLoading = false, + isLatestLoaded = true + ) + ) + } + + @Test + fun `shouldAutoFollowLatestAfterContentChange does not follow during pending command loading or dragging`() { + assertFalse( + shouldAutoFollowLatestAfterContentChange( + previousLastGroupedMessageId = 10L, + currentLastGroupedMessageId = 11L, + followLatestArmed = true, + viewportPhase = ChatViewportPhase.Settled, + pendingScrollCommand = ChatScrollCommand.ScrollToBottom(animated = true), + isLoading = false, + isLoadingOlder = false, + isLoadingNewer = false, + isScrollInProgress = false, + showInitialLoading = false, + isLatestLoaded = true + ) + ) + assertFalse( + shouldAutoFollowLatestAfterContentChange( + previousLastGroupedMessageId = 10L, + currentLastGroupedMessageId = 11L, + followLatestArmed = true, + viewportPhase = ChatViewportPhase.Settled, + pendingScrollCommand = null, + isLoading = false, + isLoadingOlder = false, + isLoadingNewer = true, + isScrollInProgress = false, + showInitialLoading = false, + isLatestLoaded = true + ) + ) + assertFalse( + shouldAutoFollowLatestAfterContentChange( + previousLastGroupedMessageId = 10L, + currentLastGroupedMessageId = 11L, + followLatestArmed = true, + viewportPhase = ChatViewportPhase.Settled, + pendingScrollCommand = null, + isLoading = false, + isLoadingOlder = false, + isLoadingNewer = false, + isScrollInProgress = true, + showInitialLoading = false, + isLatestLoaded = true + ) + ) + } + + @Test + fun `shouldAutoFollowLatestAfterContentChange ignores content updates when last grouped message is unchanged`() { + assertFalse( + shouldAutoFollowLatestAfterContentChange( + previousLastGroupedMessageId = 10L, + currentLastGroupedMessageId = 10L, + followLatestArmed = true, + viewportPhase = ChatViewportPhase.Settled, + pendingScrollCommand = null, + isLoading = false, + isLoadingOlder = false, + isLoadingNewer = false, + isScrollInProgress = false, + showInitialLoading = false, + isLatestLoaded = true + ) + ) + } + + @Test + fun `near-bottom follow is independent from exact-bottom correction`() { + assertTrue( + shouldAutoFollowLatestAfterContentChange( + previousLastGroupedMessageId = 10L, + currentLastGroupedMessageId = 11L, + followLatestArmed = true, + viewportPhase = ChatViewportPhase.Settled, + pendingScrollCommand = null, + isLoading = false, + isLoadingOlder = false, + isLoadingNewer = false, + isScrollInProgress = false, + showInitialLoading = false, + isLatestLoaded = true + ) + ) + assertFalse( + shouldRetainBottomAlignmentAfterContentChange( + viewportPhase = ChatViewportPhase.Settled, + pendingScrollCommand = null, + stateIsAtBottom = false, + measuredIsAtBottom = false, + isLoading = false, + isLoadingOlder = false, + isLoadingNewer = false, + isScrollInProgress = false, + bottomAlignmentDeltaPx = 18f + ) + ) + } + + @Test + fun `shouldDisarmFollowLatest only for history-oriented commands`() { + assertTrue( + shouldDisarmFollowLatest( + ChatScrollCommand.JumpToMessage( + messageId = 1L, + highlight = false + ) + ) + ) + assertTrue(shouldDisarmFollowLatest(ChatScrollCommand.ScrollToStart(animated = true))) + assertTrue( + shouldDisarmFollowLatest( + ChatScrollCommand.RestoreViewport( + anchorMessageId = 1L, + anchorOffsetPx = 10, + atBottom = false + ) + ) + ) + assertFalse(shouldDisarmFollowLatest(ChatScrollCommand.ScrollToBottom(animated = true))) + assertFalse( + shouldDisarmFollowLatest( + ChatScrollCommand.RestoreViewport( + anchorMessageId = null, + anchorOffsetPx = 0, + atBottom = true + ) + ) + ) + } + + @Test + fun `visible message read reporting starts disabled on initial open`() { + val context = VisibleMessageReadReportingContext( + chatId = 1L, + currentTopicId = null, + rootMessageId = null + ) + + val state = updateVisibleMessageReadReportingState( + previousState = null, + nextContext = context, + hasUserDraggedList = false + ) + + assertFalse( + shouldReportVisibleMessageAsRead( + reportingState = state, + context = context + ) + ) + } + + @Test + fun `visible message read reporting enables after first real user scroll`() { + val context = VisibleMessageReadReportingContext( + chatId = 1L, + currentTopicId = 2L, + rootMessageId = null + ) + + val state = updateVisibleMessageReadReportingState( + previousState = null, + nextContext = context, + hasUserDraggedList = true + ) + + assertTrue( + shouldReportVisibleMessageAsRead( + reportingState = state, + context = context + ) + ) + } + + @Test + fun `visible message read reporting stays disabled through passive restore without user scroll`() { + val context = VisibleMessageReadReportingContext( + chatId = 1L, + currentTopicId = null, + rootMessageId = 99L + ) + + val initial = updateVisibleMessageReadReportingState( + previousState = null, + nextContext = context, + hasUserDraggedList = false + ) + val afterPassiveSettle = updateVisibleMessageReadReportingState( + previousState = initial, + nextContext = context, + hasUserDraggedList = false + ) + + assertFalse( + shouldReportVisibleMessageAsRead( + reportingState = afterPassiveSettle, + context = context + ) + ) + } + + @Test + fun `visible message read reporting resets when switching conversation context`() { + val firstContext = VisibleMessageReadReportingContext( + chatId = 1L, + currentTopicId = null, + rootMessageId = null + ) + val secondContext = VisibleMessageReadReportingContext( + chatId = 1L, + currentTopicId = 10L, + rootMessageId = null + ) + + val enabledInFirstContext = updateVisibleMessageReadReportingState( + previousState = null, + nextContext = firstContext, + hasUserDraggedList = true + ) + val resetForSecondContext = updateVisibleMessageReadReportingState( + previousState = enabledInFirstContext, + nextContext = secondContext, + hasUserDraggedList = false + ) + + assertTrue( + shouldReportVisibleMessageAsRead( + reportingState = enabledInFirstContext, + context = firstContext + ) + ) + assertFalse( + shouldReportVisibleMessageAsRead( + reportingState = resetForSecondContext, + context = secondContext + ) + ) + } } diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatMediaHelpersTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatMediaHelpersTest.kt index 8c36546a..925ec73a 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatMediaHelpersTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatMediaHelpersTest.kt @@ -1,10 +1,16 @@ package org.monogram.presentation.features.chats.conversation.ui.content +import androidx.compose.ui.unit.dp import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.presentation.features.chats.conversation.ui.message.prefersExpandedBubbleWidth +import org.monogram.presentation.features.chats.conversation.ui.message.resolveMediaBubbleAspectRatio +import org.monogram.presentation.features.chats.conversation.ui.message.resolveMediaBubbleHeight +import org.monogram.presentation.features.chats.conversation.ui.message.resolveMediaBubbleWidth class ChatMediaHelpersTest { @Test @@ -128,6 +134,37 @@ class ChatMediaHelpersTest { buildAlbumMediaEntries(listOf(audio, document)) { true }) } + @Test + fun `chat media bubbles prefer expanded width for photos videos and gifs`() { + assertTrue( + MessageContent.Photo(path = null, width = 100, height = 200) + .prefersExpandedBubbleWidth() + ) + assertTrue( + MessageContent.Video(path = null, width = 100, height = 200, duration = 1) + .prefersExpandedBubbleWidth() + ) + assertTrue( + MessageContent.Gif(path = null, width = 100, height = 200).prefersExpandedBubbleWidth() + ) + } + + @Test + fun `portrait media uses full bubble width and clamps by max height`() { + val aspectRatio = resolveMediaBubbleAspectRatio(mediaWidth = 600, mediaHeight = 1600) + val resolvedWidth = resolveMediaBubbleWidth(320.dp) + val resolvedHeight = resolveMediaBubbleHeight( + containerWidth = 320.dp, + aspectRatio = aspectRatio, + minHeight = 160.dp, + maxHeight = 320.dp + ) + + assertEquals(0.5f, aspectRatio, 0.0001f) + assertEquals(294.4.dp, resolvedWidth) + assertEquals(320.dp, resolvedHeight) + } + private fun message(content: MessageContent, id: Long = 1L): MessageModel = MessageModel( id = id, diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ConversationSponsoredPlacementTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ConversationSponsoredPlacementTest.kt new file mode 100644 index 00000000..0ba6b54a --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ConversationSponsoredPlacementTest.kt @@ -0,0 +1,133 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.AdvertisementSponsorModel +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.SponsoredMessageModel +import org.monogram.domain.models.SponsoredMessagesFeedModel + +class ConversationSponsoredPlacementTest { + @Test + fun `shows only first sponsored item until enough messages are loaded`() { + val items = buildConversationListItems( + groupedMessages = (1L..5L).map { GroupedMessageItem.Single(message(it)) }, + sponsoredFeed = SponsoredMessagesFeedModel( + messages = listOf(sponsored(900L), sponsored(901L)), + messagesBetween = 10 + ) + ) + + val sponsoredIds = items.mapNotNull { item -> + (item as? ConversationListItem.Sponsored)?.sponsoredMessage?.messageId + } + + assertEquals(listOf(900L), sponsoredIds) + } + + @Test + fun `inserts next sponsored item after messagesBetween counting album messages`() { + val items = buildConversationListItems( + groupedMessages = listOf( + GroupedMessageItem.Single(message(1L)), + GroupedMessageItem.Album( + albumId = 77L, + messages = listOf( + message(2L, albumId = 77L), + message(3L, albumId = 77L), + message(4L, albumId = 77L) + ) + ), + GroupedMessageItem.Single(message(5L)) + ), + sponsoredFeed = SponsoredMessagesFeedModel( + messages = listOf(sponsored(900L), sponsored(901L), sponsored(902L)), + messagesBetween = 4 + ) + ) + + assertTrue(items[0] is ConversationListItem.Sponsored) + assertTrue(items[1] is ConversationListItem.Grouped) + assertTrue(items[2] is ConversationListItem.Grouped) + assertTrue(items[3] is ConversationListItem.Sponsored) + assertTrue(items[4] is ConversationListItem.Grouped) + assertEquals( + listOf(900L, 901L), + items.mapNotNull { (it as? ConversationListItem.Sponsored)?.sponsoredMessage?.messageId } + ) + } + + @Test + fun `stacks sponsored items first when messagesBetween is zero`() { + val items = buildConversationListItems( + groupedMessages = listOf( + GroupedMessageItem.Single(message(1L)), + GroupedMessageItem.Single(message(2L)) + ), + sponsoredFeed = SponsoredMessagesFeedModel( + messages = listOf(sponsored(900L), sponsored(901L)), + messagesBetween = 0 + ) + ) + + assertEquals( + listOf( + "channel_sponsored_message_900", + "channel_sponsored_message_901", + "msg_1", + "msg_2" + ), + items.map(ConversationListItem::lazyItemKey) + ) + } + + @Test + fun `buildGroupedLazyIndexByFirstMessageId skips inline sponsored items`() { + val items = buildConversationListItems( + groupedMessages = listOf( + GroupedMessageItem.Single(message(1L)), + GroupedMessageItem.Single(message(2L)), + GroupedMessageItem.Single(message(3L)) + ), + sponsoredFeed = SponsoredMessagesFeedModel( + messages = listOf(sponsored(900L), sponsored(901L)), + messagesBetween = 2 + ) + ) + + val lazyIndexes = buildGroupedLazyIndexByFirstMessageId( + conversationItems = items, + leadingItemsCount = 1 + ) + + assertEquals(2, lazyIndexes[1L]) + assertEquals(3, lazyIndexes[2L]) + assertEquals(5, lazyIndexes[3L]) + } + + private fun message(id: Long, albumId: Long = 0L): MessageModel { + return MessageModel( + id = id, + date = id.toInt(), + isOutgoing = false, + senderName = "sender", + chatId = -100L, + content = MessageContent.Text("message_$id"), + mediaAlbumId = albumId + ) + } + + private fun sponsored(id: Long): SponsoredMessageModel { + return SponsoredMessageModel( + messageId = id, + isRecommended = false, + canBeReported = false, + title = "Sponsored $id", + buttonText = "Open", + content = MessageContent.Text("Sponsored content $id"), + sponsor = AdvertisementSponsorModel(url = "https://example.com/$id") + ) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/UnreadSeparatorVisibilityTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/UnreadSeparatorVisibilityTest.kt new file mode 100644 index 00000000..c50fa235 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/UnreadSeparatorVisibilityTest.kt @@ -0,0 +1,51 @@ +package org.monogram.presentation.features.chats.conversation.ui.content + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UnreadSeparatorVisibilityTest { + @Test + fun `separator is shown when unread boundary matches item and unread session exists`() { + assertTrue( + shouldShowUnreadSeparator( + isComments = false, + unreadBoundaryIndex = 3, + unreadSeparatorCount = 5, + itemIndex = 3 + ) + ) + } + + @Test + fun `separator is hidden when unread session is cleared`() { + assertFalse( + shouldShowUnreadSeparator( + isComments = false, + unreadBoundaryIndex = 3, + unreadSeparatorCount = 0, + itemIndex = 3 + ) + ) + } + + @Test + fun `separator is hidden for non-boundary items and comments mode`() { + assertFalse( + shouldShowUnreadSeparator( + isComments = false, + unreadBoundaryIndex = 3, + unreadSeparatorCount = 2, + itemIndex = 2 + ) + ) + assertFalse( + shouldShowUnreadSeparator( + isComments = true, + unreadBoundaryIndex = 3, + unreadSeparatorCount = 2, + itemIndex = 3 + ) + ) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaTapActionsTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaTapActionsTest.kt new file mode 100644 index 00000000..76c9dabe --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaTapActionsTest.kt @@ -0,0 +1,44 @@ +package org.monogram.presentation.features.chats.conversation.ui.message + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.monogram.presentation.features.chats.conversation.ui.content.VideoTapAction +import org.monogram.presentation.features.chats.conversation.ui.content.resolveVideoTapAction + +class MediaTapActionsTest { + + @Test + fun `sticker without path resolves to download`() { + val action = resolveStickerTapAction(path = null) + + assertEquals(StickerTapAction.Download, action) + } + + @Test + fun `sticker with path resolves to open set`() { + val action = resolveStickerTapAction(path = "/tmp/sticker.webp") { true } + + assertEquals(StickerTapAction.OpenSet, action) + } + + @Test + fun `non streamable video without path resolves to download`() { + val action = resolveVideoTapAction(path = null, supportsStreaming = false) + + assertEquals(VideoTapAction.Download, action) + } + + @Test + fun `streamable video without path resolves to open`() { + val action = resolveVideoTapAction(path = null, supportsStreaming = true) + + assertEquals(VideoTapAction.Open, action) + } + + @Test + fun `video with local path resolves to open`() { + val action = resolveVideoTapAction(path = "/tmp/video.mp4", supportsStreaming = false) + + assertEquals(VideoTapAction.Open, action) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/list/ChatListExitActionTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/list/ChatListExitActionTest.kt new file mode 100644 index 00000000..06a484c2 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/list/ChatListExitActionTest.kt @@ -0,0 +1,47 @@ +package org.monogram.presentation.features.chats.list + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.monogram.domain.models.ChatModel +import org.monogram.presentation.features.chats.common.ChatExitAction + +class ChatListExitActionTest { + @Test + fun `single private chat resolves to delete`() { + assertEquals( + ChatExitAction.Delete, + resolveChatListExitAction(chat(canDelete = true)) + ) + } + + @Test + fun `member group resolves to leave`() { + assertEquals( + ChatExitAction.Leave, + resolveChatListExitAction(chat(isGroup = true, isMember = true)) + ) + } + + @Test + fun `non member channel resolves to none`() { + assertEquals( + ChatExitAction.None, + resolveChatListExitAction(chat(isChannel = true, isMember = false)) + ) + } + + private fun chat( + isGroup: Boolean = false, + isChannel: Boolean = false, + isMember: Boolean = true, + canDelete: Boolean = false + ): ChatModel = ChatModel( + id = 1L, + title = "chat", + unreadCount = 0, + isGroup = isGroup, + isChannel = isChannel, + isMember = isMember, + canBeDeletedOnlyForSelf = canDelete + ) +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/list/ChatListSwipeBackStateTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/list/ChatListSwipeBackStateTest.kt new file mode 100644 index 00000000..40c4d8a1 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/list/ChatListSwipeBackStateTest.kt @@ -0,0 +1,132 @@ +package org.monogram.presentation.features.chats.list + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.FolderModel +import org.monogram.presentation.core.ui.ScreenSwipeBackAction +import org.monogram.presentation.core.ui.ScreenSwipeBackPreview + +class ChatListSwipeBackStateTest { + @Test + fun `archived folder exposes local archive return swipe to remembered folder`() { + val swipeState = resolveChatListSwipeBackState( + state = ChatListComponent.State( + folders = listOf( + FolderModel(id = -1, title = "All chats"), + FolderModel(id = 3, title = "Work"), + FolderModel(id = 7, title = "Family"), + ), + selectedFolderId = -2, + lastNonArchiveFolderId = 7, + ), + showAllChatsFolder = true, + ) + + assertTrue(swipeState.isSupported) + assertFalse(swipeState.isBlocked) + assertEquals(ScreenSwipeBackAction.LocalChatListArchiveReturn, swipeState.action) + assertEquals(ScreenSwipeBackPreview.ChatListFolder, swipeState.preview) + } + + @Test + fun `normal folder does not expose archive return swipe`() { + val swipeState = resolveChatListSwipeBackState( + state = ChatListComponent.State( + folders = listOf( + FolderModel(id = -1, title = "All chats"), + FolderModel(id = 3, title = "Work"), + ), + selectedFolderId = 3, + ), + showAllChatsFolder = true, + ) + + assertFalse(swipeState.isSupported) + } + + @Test + fun `archive return resolves to all chats when it was last selected`() { + val targetFolderId = resolveArchiveReturnFolderId( + folders = listOf( + FolderModel(id = -1, title = "All chats"), + FolderModel(id = 3, title = "Work"), + ), + showAllChatsFolder = true, + lastNonArchiveFolderId = -1, + ) + + assertEquals(-1, targetFolderId) + } + + @Test + fun `archive return falls back when remembered folder is hidden`() { + val targetFolderId = resolveArchiveReturnFolderId( + folders = listOf( + FolderModel(id = -1, title = "All chats"), + FolderModel(id = 3, title = "Work"), + FolderModel(id = 7, title = "Family"), + ), + showAllChatsFolder = false, + lastNonArchiveFolderId = -1, + ) + + assertEquals(3, targetFolderId) + } + + @Test + fun `archive folder selection does not overwrite remembered non archive folder`() { + val state = ChatListComponent.State( + folders = listOf( + FolderModel(id = -1, title = "All chats"), + FolderModel(id = 3, title = "Work"), + ), + selectedFolderId = -2, + lastNonArchiveFolderId = 3, + ) + + val targetFolderId = resolveArchiveReturnFolderId( + folders = state.folders, + showAllChatsFolder = true, + lastNonArchiveFolderId = state.lastNonArchiveFolderId, + ) + + assertEquals(3, targetFolderId) + } + + @Test + fun `custom back state blocks archive swipe`() { + val swipeState = resolveChatListSwipeBackState( + state = ChatListComponent.State( + folders = listOf( + FolderModel(id = -1, title = "All chats"), + FolderModel(id = 3, title = "Work"), + ), + selectedFolderId = -2, + isSearchActive = true, + ), + showAllChatsFolder = true, + ) + + assertTrue(swipeState.isSupported) + assertTrue(swipeState.isBlocked) + } + + @Test + fun `transient overlay blocks archive swipe`() { + val swipeState = resolveChatListSwipeBackState( + state = ChatListComponent.State( + folders = listOf( + FolderModel(id = -1, title = "All chats"), + FolderModel(id = 3, title = "Work"), + ), + selectedFolderId = -2, + ), + showAllChatsFolder = true, + hasTransientBlockingUi = true, + ) + + assertTrue(swipeState.isBlocked) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/editing/EditorPrimitivesTest.kt b/presentation/src/test/java/org/monogram/presentation/features/editing/EditorPrimitivesTest.kt new file mode 100644 index 00000000..deb940d7 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/editing/EditorPrimitivesTest.kt @@ -0,0 +1,49 @@ +package org.monogram.presentation.features.editing + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class EditorPrimitivesTest { + + @Test + fun `canSave is true only when dirty and not saving`() { + assertTrue(EditorPrimitives.updateState(isDirty = true).canSave) + assertFalse(EditorPrimitives.updateState(isDirty = false).canSave) + assertFalse(EditorPrimitives.updateState(isDirty = true, isSaving = true).canSave) + } + + @Test + fun `beginSave clears error and disables save`() { + val state = EditorPrimitives.beginSave( + EditorPrimitives.updateState(isDirty = true, error = "boom") + ) + + assertTrue(state.isSaving) + assertNull(state.error) + assertFalse(state.canSave) + } + + @Test + fun `fail stops saving and keeps error`() { + val state = EditorPrimitives.fail( + EditorPrimitives.beginSave(EditorPrimitives.updateState(isDirty = true)), + "boom" + ) + + assertFalse(state.isSaving) + assertEquals("boom", state.error) + assertTrue(state.canSave) + } + + @Test + fun `discard dialog helpers toggle dialog flag`() { + val shown = EditorPrimitives.showDiscardChanges(EditorPrimitives.cleanState()) + val hidden = EditorPrimitives.hideDiscardChanges(shown) + + assertTrue(shown.showDiscardChangesDialog) + assertFalse(hidden.showDiscardChangesDialog) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/profile/admin/ChatEditPermissionMutationTest.kt b/presentation/src/test/java/org/monogram/presentation/features/profile/admin/ChatEditPermissionMutationTest.kt new file mode 100644 index 00000000..814cf017 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/profile/admin/ChatEditPermissionMutationTest.kt @@ -0,0 +1,27 @@ +package org.monogram.presentation.features.profile.admin + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.ChatPermissionsModel + +class ChatEditPermissionMutationTest { + + @Test + fun `send messages toggle flips basic message permission`() { + val initial = ChatPermissionsModel(canSendBasicMessages = true) + + val updated = initial.toggle(ChatEditComponent.Permission.SEND_MESSAGES) + + assertFalse(updated.canSendBasicMessages) + } + + @Test + fun `manage topics toggle flips create topics permission`() { + val initial = ChatPermissionsModel(canCreateTopics = false) + + val updated = initial.toggle(ChatEditComponent.Permission.MANAGE_TOPICS) + + assertTrue(updated.canCreateTopics) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/profile/contact/ContactEditDraftTest.kt b/presentation/src/test/java/org/monogram/presentation/features/profile/contact/ContactEditDraftTest.kt new file mode 100644 index 00000000..3fcdfeb6 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/profile/contact/ContactEditDraftTest.kt @@ -0,0 +1,40 @@ +package org.monogram.presentation.features.profile.contact + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContactEditDraftTest { + + @Test + fun `hasChanges detects share phone disable`() { + val initial = ContactEditDraft( + firstName = "Ada", + lastName = "Lovelace", + sharePhoneNumber = true + ) + + val updated = initial.copy(sharePhoneNumber = false) + + assertTrue(updated.hasChanges(initial)) + } + + @Test + fun `hasChanges ignores surrounding whitespace only`() { + val initial = ContactEditDraft( + firstName = "Ada", + lastName = "Lovelace", + sharePhoneNumber = true + ) + + val updated = initial.copy(firstName = " Ada ", lastName = " Lovelace ") + + assertFalse(updated.hasChanges(initial)) + } + + @Test + fun `isValid requires non blank first name`() { + assertFalse(ContactEditDraft(firstName = " ").isValid()) + assertTrue(ContactEditDraft(firstName = "Ada").isValid()) + } +}