diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 2370f03292..0c7ea36eca 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -80,6 +80,7 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2' implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2' implementation "androidx.compose.material:material-icons-extended:$compose_version" + implementation "androidx.compose.ui:ui-util:$compose_version" implementation "androidx.navigation:navigation-compose:2.4.1" implementation "com.google.accompanist:accompanist-insets:0.23.0" implementation 'androidx.webkit:webkit:1.4.0' diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index a343bb6cf8..19732416a4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -1,6 +1,6 @@ package chat.simplex.app.views.chat.item -import android.content.Context +import android.content.* import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -14,8 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.platform.* import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -62,7 +61,8 @@ fun ChatItemView( if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) { EmojiItemView(cItem) } else { - FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile) + val onLinkLongClick = { _: String -> showMenu.value = true } + FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu, receiveFile, onLinkLongClick) } DropdownMenu( expanded = showMenu.value, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 9654107e08..ff69501d0c 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -39,7 +39,8 @@ fun FramedItemView( uriHandler: UriHandler? = null, showMember: Boolean = false, showMenu: MutableState, - receiveFile: (Long) -> Unit + receiveFile: (Long) -> Unit, + onLinkLongClick: (link: String) -> Unit = {} ) { val sent = ci.chatDir.sent @@ -136,9 +137,9 @@ fun FramedItemView( } is MsgContent.MCLink -> { ChatItemLinkView(mc.preview) - CIMarkdownText(ci, showMember, uriHandler) + CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick) } - else -> CIMarkdownText(ci, showMember, uriHandler) + else -> CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick) } } } @@ -151,11 +152,17 @@ fun FramedItemView( } @Composable -fun CIMarkdownText(ci: ChatItem, showMember: Boolean, uriHandler: UriHandler?) { +fun CIMarkdownText( + ci: ChatItem, + showMember: Boolean, + uriHandler: UriHandler?, + onLinkLongClick: (link: String) -> Unit = {} +) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { MarkdownText( ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null, - metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true + metaText = ci.timestampText, edited = ci.meta.itemEdited, + uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt index 6d503d1b46..d8f6a04432 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt @@ -1,17 +1,19 @@ package chat.simplex.app.views.chat.item -import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.* import chat.simplex.app.model.* +import chat.simplex.app.views.helpers.detectGesture val reserveTimestampStyle = SpanStyle(color = Color.Transparent) val boldFont = SpanStyle(fontWeight = FontWeight.Medium) @@ -45,7 +47,8 @@ fun MarkdownText ( overflow: TextOverflow = TextOverflow.Clip, uriHandler: UriHandler? = null, senderBold: Boolean = false, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onLinkLongClick: (link: String) -> Unit = {} ) { val reserve = if (edited) " " else " " if (formattedText == null) { @@ -77,9 +80,16 @@ fun MarkdownText ( } if (hasLinks && uriHandler != null) { ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, + onLongClick = { offset -> + annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset) + .firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) } + }, onClick = { offset -> annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset) .firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) } + }, + shouldConsumeEvent = { offset -> + annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any() } ) } else { @@ -87,3 +97,53 @@ fun MarkdownText ( } } } + +@Composable +fun ClickableText( + text: AnnotatedString, + modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, + softWrap: Boolean = true, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, + onTextLayout: (TextLayoutResult) -> Unit = {}, + onClick: (Int) -> Unit, + onLongClick: (Int) -> Unit = {}, + shouldConsumeEvent: (Int) -> Boolean +) { + val layoutResult = remember { mutableStateOf(null) } + val pressIndicator = Modifier.pointerInput(onClick, onLongClick) { + detectGesture(onLongPress = { pos -> + layoutResult.value?.let { layoutResult -> + onLongClick(layoutResult.getOffsetForPosition(pos)) + } + }, onPress = { pos -> + layoutResult.value?.let { layoutResult -> + val res = tryAwaitRelease() + if (res) { + onClick(layoutResult.getOffsetForPosition(pos)) + } + } + }, shouldConsumeEvent = { pos -> + var consume = false + layoutResult.value?.let { layoutResult -> + consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos)) + } + consume + } + ) + } + + BasicText( + text = text, + modifier = modifier.then(pressIndicator), + style = style, + softWrap = softWrap, + overflow = overflow, + maxLines = maxLines, + onTextLayout = { + layoutResult.value = it + onTextLayout(it) + } + ) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt new file mode 100644 index 0000000000..306d7113b0 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package chat.simplex.app.views.helpers + +import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.consumeAllChanges +import androidx.compose.ui.input.pointer.consumeDownChange +import androidx.compose.ui.input.pointer.isOutOfBounds +import androidx.compose.ui.input.pointer.positionChangeConsumed +import androidx.compose.ui.unit.Density +import androidx.compose.ui.util.fastAll +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEach +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex + +/** + * See original code here: [androidx.compose.foundation.gestures.detectTapGestures] + * */ + +interface PressGestureScope : Density { + suspend fun tryAwaitRelease(): Boolean +} + +private val NoPressGesture: suspend PressGestureScope.(Offset) -> Unit = { } + +suspend fun PointerInputScope.detectGesture( + onLongPress: ((Offset) -> Unit)? = null, + onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture, + shouldConsumeEvent: (Offset) -> Boolean +) = coroutineScope { + val pressScope = PressGestureScopeImpl(this@detectGesture) + + forEachGesture { + awaitPointerEventScope { + val down = awaitFirstDown() + // If shouldConsumeEvent == false, all touches will be propagated to parent + val shouldConsume = shouldConsumeEvent(down.position) + if (shouldConsume) + down.consumeDownChange() + pressScope.reset() + if (onPress !== NoPressGesture) launch { + pressScope.onPress(down.position) + } + + val longPressTimeout = onLongPress?.let { + viewConfiguration.longPressTimeoutMillis + } ?: (Long.MAX_VALUE / 2) + + try { + val upOrCancel: PointerInputChange? = withTimeout(longPressTimeout) { + waitForUpOrCancellation() + } + if (upOrCancel == null) { + pressScope.cancel() + } else { + if (shouldConsume) + upOrCancel.consumeDownChange() + + // If onLongPress event is needed, cancel short press event + if (onLongPress != null) + pressScope.cancel() + else + pressScope.release() + } + } catch (_: PointerEventTimeoutCancellationException) { + onLongPress?.invoke(down.position) + if (shouldConsume) + consumeUntilUp() + pressScope.release() + } + } + } +} + +private suspend fun AwaitPointerEventScope.consumeUntilUp() { + do { + val event = awaitPointerEvent() + event.changes.fastForEach { it.consumeAllChanges() } + } while (event.changes.fastAny { it.pressed }) +} + +suspend fun AwaitPointerEventScope.awaitFirstDown( + requireUnconsumed: Boolean = true +): PointerInputChange = + awaitFirstDownOnPass(pass = PointerEventPass.Main, requireUnconsumed = requireUnconsumed) + +internal suspend fun AwaitPointerEventScope.awaitFirstDownOnPass( + pass: PointerEventPass, + requireUnconsumed: Boolean +): PointerInputChange { + var event: PointerEvent + do { + event = awaitPointerEvent(pass) + } while ( + !event.changes.fastAll { + if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed() + } + ) + return event.changes[0] +} + +suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange? { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Main) + if (event.changes.fastAll { it.changedToUp() }) { + return event.changes[0] + } + + if (event.changes.fastAny { + it.consumed.downChange || it.isOutOfBounds(size, extendedTouchPadding) + } + ) { + return null + } + + val consumeCheck = awaitPointerEvent(PointerEventPass.Final) + if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) { + return null + } + } +} + +private class PressGestureScopeImpl( + density: Density +) : PressGestureScope, Density by density { + private var isReleased = false + private var isCanceled = false + private val mutex = Mutex(locked = false) + + fun cancel() { + isCanceled = true + mutex.unlock() + } + + fun release() { + isReleased = true + mutex.unlock() + } + + fun reset() { + mutex.tryLock() + isReleased = false + isCanceled = false + } + + override suspend fun tryAwaitRelease(): Boolean { + if (!isReleased && !isCanceled) { + mutex.lock() + } + return isCanceled + } +}