diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 90c96b7ffc..ecddafa9ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -403,31 +403,39 @@ fun ClickableText( shouldConsumeEvent: (Int) -> Boolean ) { val layoutResult = remember { mutableStateOf(null) } - val pressIndicator = Modifier.pointerInput(onClick, onLongClick) { + // pointerInput keyed on these lambdas restarts on every recomposition (they are new + // instances each time), and a restart mid-gesture swallows the click/hover in flight; + // key on Unit and read the latest handlers via rememberUpdatedState instead + val currentOnClick = rememberUpdatedState(onClick) + val currentOnLongClick = rememberUpdatedState(onLongClick) + val currentOnHover = rememberUpdatedState(onHover) + val currentOnHoverExit = rememberUpdatedState(onHoverExit) + val currentShouldConsumeEvent = rememberUpdatedState(shouldConsumeEvent) + val pressIndicator = Modifier.pointerInput(Unit) { detectGesture(onLongPress = { pos -> layoutResult.value?.let { layoutResult -> - onLongClick(layoutResult.getOffsetForPosition(pos)) + currentOnLongClick.value(layoutResult.getOffsetForPosition(pos)) } }, onPress = { pos -> layoutResult.value?.let { layoutResult -> val res = tryAwaitRelease() if (res) { - onClick(layoutResult.getOffsetForPosition(pos)) + currentOnClick.value(layoutResult.getOffsetForPosition(pos)) } } }, shouldConsumeEvent = { pos -> var consume = false layoutResult.value?.let { layoutResult -> - consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos)) + consume = currentShouldConsumeEvent.value(layoutResult.getOffsetForPosition(pos)) } consume } ) - }.pointerInput(onHover, onHoverExit) { + }.pointerInput(Unit) { if (appPlatform.isDesktop) { - detectCursorMove(onExit = onHoverExit) { pos -> + detectCursorMove(onExit = { currentOnHoverExit.value() }) { pos -> layoutResult.value?.let { layoutResult -> - onHover(layoutResult.getOffsetForPosition(pos)) + currentOnHover.value(layoutResult.getOffsetForPosition(pos)) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt index a73f1b383e..706c716538 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt @@ -57,8 +57,13 @@ suspend fun PointerInputScope.detectGesture( val shouldConsume = shouldConsumeEvent(down.position) if (shouldConsume) down.consumeDownChange() - pressScope.reset() + // reset() suspends until the previous gesture's press handler released the mutex, + // and release/cancel join resetJob, so flag writes can't be reordered across gestures + // (a fast second click would otherwise drop the first click's onClick). + // Mirrors detectTapGestures in Compose 1.8.2. + val resetJob = launch { pressScope.reset() } if (onPress !== NoPressGesture) launch { + resetJob.join() pressScope.onPress(down.position) } val longPressTimeout = onLongPress?.let { @@ -70,22 +75,22 @@ suspend fun PointerInputScope.detectGesture( waitForUpOrCancellation() } if (upOrCancel == null) { - pressScope.cancel() + launch { resetJob.join(); pressScope.cancel() } } else { if (shouldConsume) upOrCancel.consumeDownChange() - pressScope.release() + launch { resetJob.join(); pressScope.release() } } } catch (_: PointerEventTimeoutCancellationException) { if (onLongPress != null) { onLongPress(down.position) if (shouldConsume) consumeUntilUp() - pressScope.cancel() + launch { resetJob.join(); pressScope.cancel() } } else { if (shouldConsume) consumeUntilUp() - pressScope.release() + launch { resetJob.join(); pressScope.release() } } } } @@ -167,16 +172,18 @@ private class PressGestureScopeImpl( fun cancel() { isCanceled = true - mutex.unlock() + if (mutex.isLocked) mutex.unlock() } fun release() { isReleased = true - mutex.unlock() + if (mutex.isLocked) mutex.unlock() } - fun reset() { - mutex.tryLock() + // suspends until the previous gesture's tryAwaitRelease finished (or was never started): + // the mutex is the serialization token between consecutive gestures + suspend fun reset() { + mutex.lock() isReleased = false isCanceled = false } @@ -184,6 +191,7 @@ private class PressGestureScopeImpl( override suspend fun tryAwaitRelease(): Boolean { if (!isReleased && !isCanceled) { mutex.lock() + mutex.unlock() } return isReleased && !isCanceled }