From f695b319ebb547a5dd11233de1d3ba6d3fa8d536 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 22 Oct 2022 16:16:03 +0100 Subject: [PATCH] handling bubble long clicks as copy text events --- .../kotlin/app/dapk/st/graph/AppModule.kt | 1 + .../app/dapk/st/design/components/Bubble.kt | 36 ++++++++++--------- .../app/dapk/st/messenger/CopyToClipboard.kt | 22 ++++++++++++ .../app/dapk/st/messenger/LocalIdFactory.kt | 7 ---- .../app/dapk/st/messenger/MessengerModule.kt | 5 +++ .../app/dapk/st/messenger/MessengerScreen.kt | 33 ++++++++++------- .../app/dapk/st/messenger/MessengerState.kt | 1 + .../dapk/st/messenger/MessengerViewModel.kt | 33 +++++++++++++++++ .../st/messenger/MessengerViewModelTest.kt | 10 ++++++ 9 files changed, 113 insertions(+), 35 deletions(-) create mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/CopyToClipboard.kt delete mode 100644 features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalIdFactory.kt diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index a77fc82..47caa4c 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -160,6 +160,7 @@ internal class FeatureModules internal constructor( chatEngineModule.engine, context, storeModule.value.messageStore(), + deviceMeta, ) } val homeModule by unsafeLazy { HomeModule(chatEngineModule.engine, storeModule.value, buildMeta) } diff --git a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt index 3533232..0a59935 100644 --- a/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt +++ b/design-library/src/main/kotlin/app/dapk/st/design/components/Bubble.kt @@ -1,8 +1,10 @@ package app.dapk.st.design.components import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text @@ -40,20 +42,20 @@ sealed interface BubbleModel { private fun BubbleModel.Reply.isReplyingToSelf() = this.replyingTo.event.authorId == this.reply.event.authorId - @Composable -fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit) { +fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, onLongClick: (BubbleModel) -> Unit) { + val itemisedLongClick = { onLongClick.invoke(model) } when (model) { - is BubbleModel.Text -> TextBubble(bubble, model, status) - is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status) - is BubbleModel.Image -> ImageBubble(bubble, model, status) - is BubbleModel.Reply -> ReplyBubble(bubble, model, status) + is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick) + is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status, itemisedLongClick) + is BubbleModel.Image -> ImageBubble(bubble, model, status, itemisedLongClick) + is BubbleModel.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick) } } @Composable -private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit) { - Bubble(bubble) { +private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) { + Bubble(bubble, onLongClick) { if (bubble.isNotSelf()) { AuthorName(model.event, bubble) } @@ -63,13 +65,13 @@ private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Com } @Composable -private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, status: @Composable () -> Unit) { - TextBubble(bubble, BubbleModel.Text(content = "Encrypted message", model.event), status) +private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, status: @Composable () -> Unit, onLongClick: () -> Unit) { + TextBubble(bubble, BubbleModel.Text(content = "Encrypted message", model.event), status, onLongClick) } @Composable -private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit) { - Bubble(bubble) { +private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onLongClick: () -> Unit) { + Bubble(bubble, onLongClick) { if (bubble.isNotSelf()) { AuthorName(model.event, bubble) } @@ -85,8 +87,8 @@ private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @C } @Composable -private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit) { - Bubble(bubble) { +private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) { + Bubble(bubble, onLongClick) { Column( Modifier .fillMaxWidth() @@ -191,15 +193,17 @@ private fun Int.scalerFor(max: Float): Float { } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun Bubble(bubble: BubbleMeta, content: @Composable () -> Unit) { +private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Composable () -> Unit) { Box(modifier = Modifier.padding(start = 6.dp)) { Box( Modifier .padding(4.dp) .clip(bubble.shape) .background(bubble.background) - .height(IntrinsicSize.Max), + .height(IntrinsicSize.Max) + .combinedClickable(onLongClick = onLongClick, onClick = {}), ) { Column( Modifier diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/CopyToClipboard.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/CopyToClipboard.kt new file mode 100644 index 0000000..a389b3c --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/CopyToClipboard.kt @@ -0,0 +1,22 @@ +package app.dapk.st.messenger + +import android.content.ClipData +import android.content.ClipboardManager + +class CopyToClipboard(private val clipboard: ClipboardManager) { + + fun copy(copyable: Copyable) { + + clipboard.addPrimaryClipChangedListener { } + + when (copyable) { + is Copyable.Text -> { + clipboard.setPrimaryClip(ClipData.newPlainText("", copyable.value)) + } + } + } + + sealed interface Copyable { + data class Text(val value: String) : Copyable + } +} diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalIdFactory.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalIdFactory.kt deleted file mode 100644 index ecb1777..0000000 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/LocalIdFactory.kt +++ /dev/null @@ -1,7 +0,0 @@ -package app.dapk.st.messenger - -import java.util.* - -internal class LocalIdFactory { - fun create() = "local.${UUID.randomUUID()}" -} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt index e46f37a..bcb5ad4 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt @@ -1,6 +1,8 @@ package app.dapk.st.messenger +import android.content.ClipboardManager import android.content.Context +import app.dapk.st.core.DeviceMeta import app.dapk.st.core.ProvidableModule import app.dapk.st.domain.application.message.MessageOptionsStore import app.dapk.st.engine.ChatEngine @@ -10,12 +12,15 @@ class MessengerModule( private val chatEngine: ChatEngine, private val context: Context, private val messageOptionsStore: MessageOptionsStore, + private val deviceMeta: DeviceMeta, ) : ProvidableModule { internal fun messengerViewModel(): MessengerViewModel { return MessengerViewModel( chatEngine, messageOptionsStore, + CopyToClipboard(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager), + deviceMeta, ) } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index 06bb911..6fe4ce1 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -1,5 +1,6 @@ package app.dapk.st.messenger +import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.compose.animation.* import androidx.compose.animation.core.tween @@ -7,6 +8,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.CircleShape @@ -71,9 +73,10 @@ internal fun MessengerScreen( else -> null } - val replyActions = ReplyActions( + val messageActions = MessageActions( onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) }, - onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) } + onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) }, + onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) } ) Column { @@ -84,13 +87,13 @@ internal fun MessengerScreen( }) when (state.composerState) { is ComposerState.Text -> { - Room(state.roomState, replyActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) }) + Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) }) TextComposer( state.composerState, onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) }, onSend = { viewModel.post(MessengerAction.ComposerSendText) }, onAttach = { viewModel.startAttachment() }, - replyActions = replyActions, + messageActions = messageActions, ) } @@ -107,6 +110,7 @@ internal fun MessengerScreen( @Composable private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLauncher) { + val context = LocalContext.current StartObserving { this@ObserveEvents.events.launch { when (it) { @@ -115,17 +119,21 @@ private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLaun galleryLauncher.launch(ImageGalleryActivityPayload(it.roomState.roomOverview.roomName ?: "")) } } + + is MessengerEvent.Toast -> { + Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() + } } } } } @Composable -private fun ColumnScope.Room(roomStateLce: Lce, replyActions: ReplyActions, onRetry: () -> Unit) { +private fun ColumnScope.Room(roomStateLce: Lce, messageActions: MessageActions, onRetry: () -> Unit) { when (val state = roomStateLce) { is Lce.Loading -> CenteredLoading() is Lce.Content -> { - RoomContent(state.value.self, state.value.roomState, replyActions) + RoomContent(state.value.self, state.value.roomState, messageActions) val eventBarHeight = 14.dp val typing = state.value.typing when { @@ -159,7 +167,7 @@ private fun ColumnScope.Room(roomStateLce: Lce, replyActions: Re } @Composable -private fun ColumnScope.RoomContent(self: UserId, state: RoomState, replyActions: ReplyActions) { +private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActions: MessageActions) { val listState: LazyListState = rememberLazyListState( initialFirstVisibleItemIndex = 0 ) @@ -190,11 +198,11 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, replyActions avatar = Avatar(item.author.avatarUrl?.value, item.author.displayName ?: item.author.id.value), isSelf = self == item.author.id, wasPreviousMessageSameSender = wasPreviousMessageSameSender, - onReply = { replyActions.onReply(item) }, + onReply = { messageActions.onReply(item) }, ) { val event = BubbleModel.Event(item.author.id.value, item.author.displayName ?: item.author.id.value, item.edited, item.time) val status = @Composable { SendStatus(item) } - MessageBubble(this, item.toModel(event), status) + MessageBubble(this, item.toModel(event), status, onLongClick = messageActions.onLongClick) } } } @@ -260,7 +268,7 @@ private fun SendStatus(message: RoomEvent) { @OptIn(ExperimentalAnimationApi::class) @Composable -private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit, replyActions: ReplyActions) { +private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit, messageActions: MessageActions) { Row( Modifier .fillMaxWidth() @@ -289,7 +297,7 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un ) { if (it is RoomEvent.Message) { Box(Modifier.padding(12.dp)) { - Box(Modifier.padding(8.dp).clickable { replyActions.onDismiss() }.wrapContentWidth().align(Alignment.TopEnd)) { + Box(Modifier.padding(8.dp).clickable { messageActions.onDismiss() }.wrapContentWidth().align(Alignment.TopEnd)) { Icon( modifier = Modifier.size(16.dp), imageVector = Icons.Filled.Close, @@ -420,7 +428,8 @@ private fun AttachmentComposer(state: ComposerState.Attachments, onSend: () -> U } } -class ReplyActions( +class MessageActions( val onReply: (RoomEvent) -> Unit, val onDismiss: () -> Unit, + val onLongClick: (BubbleModel) -> Unit, ) diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt index ce4d829..fb50fc9 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt @@ -14,6 +14,7 @@ data class MessengerScreenState( sealed interface MessengerEvent { object SelectImageAttachment : MessengerEvent + data class Toast(val message: String) : MessengerEvent } sealed interface ComposerState { diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt index 6c87dc0..66de6e1 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt @@ -1,8 +1,11 @@ package app.dapk.st.messenger +import android.os.Build import androidx.lifecycle.viewModelScope +import app.dapk.st.core.DeviceMeta import app.dapk.st.core.Lce import app.dapk.st.core.extensions.takeIfContent +import app.dapk.st.design.components.BubbleModel import app.dapk.st.domain.application.message.MessageOptionsStore import app.dapk.st.engine.ChatEngine import app.dapk.st.engine.RoomEvent @@ -20,6 +23,8 @@ import kotlinx.coroutines.launch internal class MessengerViewModel( private val chatEngine: ChatEngine, private val messageOptionsStore: MessageOptionsStore, + private val copyToClipboard: CopyToClipboard, + private val deviceMeta: DeviceMeta, factory: MutableStateFactory = defaultStateFactory(), ) : DapkViewModel( initialState = MessengerScreenState( @@ -65,6 +70,21 @@ internal class MessengerViewModel( } ) } + + is MessengerAction.CopyToClipboard -> { + viewModelScope.launch { + when (val result = action.model.findCopyableContent()) { + is CopyableResult.Content -> { + copyToClipboard.copy(result.value) + if (deviceMeta.apiVersion <= Build.VERSION_CODES.S_V2) { + _events.emit(MessengerEvent.Toast("Copied to clipboard")) + } + } + + CopyableResult.NothingToCopy -> _events.emit(MessengerEvent.Toast("Nothing to copy")) + } + } + } } } @@ -137,10 +157,23 @@ internal class MessengerViewModel( } +private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) { + is BubbleModel.Encrypted -> CopyableResult.NothingToCopy + is BubbleModel.Image -> CopyableResult.NothingToCopy + is BubbleModel.Reply -> this.reply.findCopyableContent() + is BubbleModel.Text -> CopyableResult.Content(CopyToClipboard.Copyable.Text(this.content)) +} + +private sealed interface CopyableResult { + object NothingToCopy : CopyableResult + data class Content(val value: CopyToClipboard.Copyable) : CopyableResult +} + sealed interface MessengerAction { data class ComposerTextUpdate(val newValue: String) : MessengerAction data class ComposerEnterReplyMode(val replyingTo: RoomEvent) : MessengerAction object ComposerExitReplyMode : MessengerAction + data class CopyToClipboard(val model: BubbleModel) : MessengerAction data class ComposerImageUpdate(val newValue: MessageAttachment) : MessengerAction object ComposerSendText : MessengerAction object ComposerClear : MessengerAction diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt index fdaaadc..ee605bf 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt @@ -1,6 +1,7 @@ package app.dapk.st.messenger import ViewModelTest +import app.dapk.st.core.DeviceMeta import app.dapk.st.core.Lce import app.dapk.st.core.extensions.takeIfContent import app.dapk.st.engine.MessengerState @@ -12,6 +13,7 @@ import app.dapk.st.matrix.common.UserId import fake.FakeChatEngine import fake.FakeMessageOptionsStore import fixture.* +import io.mockk.mockk import kotlinx.coroutines.flow.flowOf import org.junit.Test @@ -27,10 +29,14 @@ class MessengerViewModelTest { private val fakeMessageOptionsStore = FakeMessageOptionsStore() private val fakeChatEngine = FakeChatEngine() + private val fakeCopyToClipboard = FakeCopyToClipboard() + private val deviceMeta = DeviceMeta(26) private val viewModel = MessengerViewModel( fakeChatEngine, fakeMessageOptionsStore.instance, + fakeCopyToClipboard.instance, + deviceMeta, factory = runViewModelTest.testMutableStateFactory(), ) @@ -110,3 +116,7 @@ fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, m roomState = Lce.Content(roomState), composerState = ComposerState.Text(value = messageContent ?: "", reply = null) ) + +class FakeCopyToClipboard { + val instance = mockk() +}