Merge pull request #217 from ouchadam/feature/copy-text
Add support for copying text content from message
This commit is contained in:
commit
82f2eea824
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package app.dapk.st.messenger
|
||||
|
||||
import java.util.*
|
||||
|
||||
internal class LocalIdFactory {
|
||||
fun create() = "local.${UUID.randomUUID()}"
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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<ImageGalleryActivityPayload>) {
|
||||
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<MessengerState>, replyActions: ReplyActions, onRetry: () -> Unit) {
|
||||
private fun ColumnScope.Room(roomStateLce: Lce<MessengerState>, 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<MessengerState>, 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,
|
||||
)
|
||||
|
|
|
@ -14,6 +14,7 @@ data class MessengerScreenState(
|
|||
|
||||
sealed interface MessengerEvent {
|
||||
object SelectImageAttachment : MessengerEvent
|
||||
data class Toast(val message: String) : MessengerEvent
|
||||
}
|
||||
|
||||
sealed interface ComposerState {
|
||||
|
|
|
@ -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<MessengerScreenState> = defaultStateFactory(),
|
||||
) : DapkViewModel<MessengerScreenState, MessengerEvent>(
|
||||
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
|
||||
|
|
|
@ -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<CopyToClipboard>()
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue