feat: add context menu for chat message (#43)
Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360Q497,360 508.5,348.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:autoMirrored="true"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L273,480L417,624L360,680L120,440L360,200L417,256L273,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z" />
|
||||||
|
</vector>
|
||||||
@@ -5,7 +5,7 @@ import androidx.compose.animation.expandVertically
|
|||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -26,8 +26,12 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
@@ -103,8 +107,13 @@ fun rememberMessageUiModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatMessage(model: MessageUiModel) {
|
fun ChatMessage(
|
||||||
|
model: MessageUiModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onLongClick: (Rect) -> Unit = {}
|
||||||
|
) {
|
||||||
var isMessageClicked by remember { mutableStateOf(false) }
|
var isMessageClicked by remember { mutableStateOf(false) }
|
||||||
|
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||||
|
|
||||||
val bubbleShape = if (model.isMine) {
|
val bubbleShape = if (model.isMine) {
|
||||||
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||||
@@ -119,18 +128,23 @@ fun ChatMessage(model: MessageUiModel) {
|
|||||||
if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
|
if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
|
.onGloballyPositioned { layoutCoordinates = it }
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.clickable(
|
modifier = Modifier.combinedClickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null
|
indication = null,
|
||||||
) {
|
onClick = { isMessageClicked = !isMessageClicked },
|
||||||
isMessageClicked = !isMessageClicked
|
onLongClick = {
|
||||||
},
|
layoutCoordinates?.let { coords ->
|
||||||
|
onLongClick(coords.boundsInWindow())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
package su.reya.coop.screens.chat
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.ClipData
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.speech.RecognizerIntent
|
import android.speech.RecognizerIntent
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -16,24 +22,33 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.ime
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.union
|
import androidx.compose.foundation.layout.union
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.BadgedBox
|
import androidx.compose.material3.BadgedBox
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenuGroup
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LoadingIndicator
|
import androidx.compose.material3.LoadingIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.MenuDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.ScaffoldDefaults
|
import androidx.compose.material3.ScaffoldDefaults
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@@ -45,18 +60,32 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import coop.composeapp.generated.resources.ic_copy
|
||||||
|
import coop.composeapp.generated.resources.ic_info
|
||||||
|
import coop.composeapp.generated.resources.ic_reply
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -83,6 +112,7 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val clipboardManager = LocalClipboard.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val profileCache = LocalProfileCache.current
|
val profileCache = LocalProfileCache.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -112,6 +142,8 @@ fun ChatScreen(
|
|||||||
.collectAsStateWithLifecycle(RoomUiState())
|
.collectAsStateWithLifecycle(RoomUiState())
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
|
var selectedMessage by remember { mutableStateOf<Pair<MessageUiModel, Rect>?>(null) }
|
||||||
|
|
||||||
val loading = viewModel.loading
|
val loading = viewModel.loading
|
||||||
val newOtherMessages = viewModel.newOtherMessages
|
val newOtherMessages = viewModel.newOtherMessages
|
||||||
val requireScreening = viewModel.requireScreening
|
val requireScreening = viewModel.requireScreening
|
||||||
@@ -120,6 +152,11 @@ fun ChatScreen(
|
|||||||
val groupedMessages =
|
val groupedMessages =
|
||||||
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
|
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
|
||||||
|
|
||||||
|
val blurAmount by animateDpAsState(
|
||||||
|
targetValue = if (selectedMessage != null) 8.dp else 0.dp,
|
||||||
|
label = "blurAnimation"
|
||||||
|
)
|
||||||
|
|
||||||
val sendFile = { uri: Uri ->
|
val sendFile = { uri: Uri ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Read file on IO dispatcher
|
// Read file on IO dispatcher
|
||||||
@@ -149,213 +186,336 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(id) {
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(messages.size) {
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty()) {
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Box(
|
||||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
modifier = Modifier.fillMaxSize()
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
) {
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
Scaffold(
|
||||||
topBar = {
|
modifier = Modifier.blur(blurAmount),
|
||||||
TopAppBar(
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||||
title = {
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
Row(
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
topBar = {
|
||||||
modifier = Modifier.clickable {
|
TopAppBar(
|
||||||
room?.members?.firstOrNull()?.let { pubkey ->
|
title = {
|
||||||
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
Row(
|
||||||
}
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
}
|
modifier = Modifier.clickable {
|
||||||
) {
|
room?.members?.firstOrNull()?.let { pubkey ->
|
||||||
if (loading) {
|
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||||
LoadingIndicator(modifier = Modifier.size(32.dp))
|
|
||||||
} else {
|
|
||||||
Avatar(
|
|
||||||
picture = roomState.picture,
|
|
||||||
description = roomState.name,
|
|
||||||
size = 32.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = roomState.name,
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
BadgedBox(
|
|
||||||
badge = {
|
|
||||||
if (newOtherMessages > 0) {
|
|
||||||
Badge {
|
|
||||||
Text(newOtherMessages.toString())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
) {
|
||||||
) {
|
if (loading) {
|
||||||
IconButton(onClick = { navigator.goBack() }) {
|
LoadingIndicator(modifier = Modifier.size(32.dp))
|
||||||
Icon(
|
} else {
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
Avatar(
|
||||||
contentDescription = "Back"
|
picture = roomState.picture,
|
||||||
|
description = roomState.name,
|
||||||
|
size = 32.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = roomState.name,
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
navigationIcon = {
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
BadgedBox(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
badge = {
|
||||||
|
if (newOtherMessages > 0) {
|
||||||
|
Badge {
|
||||||
|
Text(newOtherMessages.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
},
|
||||||
},
|
content = { innerPadding ->
|
||||||
content = { innerPadding ->
|
Surface(
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(top = innerPadding.calculateTopPadding()),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
.padding(top = innerPadding.calculateTopPadding()),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
) {
|
) {
|
||||||
if (requireScreening) {
|
Column(
|
||||||
room?.let { ScreenerCard(accountViewModel, it) }
|
modifier = Modifier
|
||||||
}
|
.fillMaxSize()
|
||||||
|
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||||
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
|
) {
|
||||||
val otherColor = MaterialTheme.colorScheme.onSurface
|
if (requireScreening) {
|
||||||
|
room?.let { ScreenerCard(accountViewModel, it) }
|
||||||
when (messages.isNotEmpty()) {
|
|
||||||
true -> {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
reverseLayout = true,
|
|
||||||
state = listState,
|
|
||||||
) {
|
|
||||||
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
|
|
||||||
items(
|
|
||||||
items = messagesInGroup,
|
|
||||||
key = { it.id()?.toHex() ?: it.hashCode().toString() }
|
|
||||||
) { event ->
|
|
||||||
val isMine = currentUser?.publicKey == event.author()
|
|
||||||
val uiModel = rememberMessageUiModel(
|
|
||||||
event = event,
|
|
||||||
currentUserPublicKey = currentUser?.publicKey,
|
|
||||||
contentColor = if (isMine) mineColor else otherColor
|
|
||||||
)
|
|
||||||
ChatMessage(model = uiModel)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DateSeparator(dateHeader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
false -> {
|
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
Box(
|
val otherColor = MaterialTheme.colorScheme.onSurface
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "No messages yet",
|
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Your conversations will appear here.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
when (requireScreening) {
|
when (messages.isNotEmpty()) {
|
||||||
true -> {
|
true -> {
|
||||||
Row(
|
LazyColumn(
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = { navigator.goBack() },
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.size(ButtonDefaults.MediumContainerHeight)
|
.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
reverseLayout = true,
|
||||||
|
state = listState,
|
||||||
) {
|
) {
|
||||||
Text(
|
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
|
||||||
text = "Reject",
|
items(
|
||||||
style = MaterialTheme.typography.titleMedium,
|
items = messagesInGroup,
|
||||||
)
|
key = { it.id()?.toHex() ?: it.hashCode().toString() }
|
||||||
}
|
) { event ->
|
||||||
FilledTonalButton(
|
val isMine = currentUser?.publicKey == event.author()
|
||||||
onClick = { viewModel.requireScreening = false },
|
val uiModel = rememberMessageUiModel(
|
||||||
modifier = Modifier
|
event = event,
|
||||||
.weight(1f)
|
currentUserPublicKey = currentUser?.publicKey,
|
||||||
.size(ButtonDefaults.MediumContainerHeight)
|
contentColor = if (isMine) mineColor else otherColor
|
||||||
) {
|
)
|
||||||
Text(
|
val isHighlighted =
|
||||||
text = "Accept",
|
selectedMessage?.first?.id == uiModel.id
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
ChatMessage(
|
||||||
}
|
model = uiModel,
|
||||||
}
|
modifier = Modifier.graphicsLayer {
|
||||||
}
|
alpha = if (isHighlighted) 0f else 1f
|
||||||
|
},
|
||||||
else -> {
|
onLongClick = { rect ->
|
||||||
ChatInput(
|
selectedMessage = uiModel to rect
|
||||||
value = text,
|
}
|
||||||
onValueChange = { text = it },
|
|
||||||
onSend = {
|
|
||||||
viewModel.sendMessage(text)
|
|
||||||
text = ""
|
|
||||||
},
|
|
||||||
onUpload = {
|
|
||||||
fileLauncher.launch("image/*")
|
|
||||||
},
|
|
||||||
onMicClick = {
|
|
||||||
val intent =
|
|
||||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
|
||||||
putExtra(
|
|
||||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
|
||||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
|
||||||
)
|
)
|
||||||
putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak now...")
|
|
||||||
}
|
}
|
||||||
try {
|
item {
|
||||||
sttLauncher.launch(intent)
|
DateSeparator(dateHeader)
|
||||||
} catch (e: Exception) {
|
|
||||||
scope.launch {
|
|
||||||
snackbarHostState.showSnackbar("Speech recognition not available")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
|
false -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No messages yet",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your conversations will appear here.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (requireScreening) {
|
||||||
|
true -> {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { navigator.goBack() },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.size(ButtonDefaults.MediumContainerHeight)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Reject",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { viewModel.requireScreening = false },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.size(ButtonDefaults.MediumContainerHeight)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Accept",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
ChatInput(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
onSend = {
|
||||||
|
viewModel.sendMessage(text)
|
||||||
|
text = ""
|
||||||
|
},
|
||||||
|
onUpload = {
|
||||||
|
fileLauncher.launch("image/*")
|
||||||
|
},
|
||||||
|
onMicClick = {
|
||||||
|
val intent =
|
||||||
|
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||||
|
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
||||||
|
)
|
||||||
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_PROMPT,
|
||||||
|
"Speak now..."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sttLauncher.launch(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar("Speech recognition not available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = selectedMessage != null,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut()
|
||||||
|
) {
|
||||||
|
val (model, bounds) = selectedMessage ?: return@AnimatedVisibility
|
||||||
|
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val windowInfo = LocalWindowInfo.current
|
||||||
|
val windowHeight = windowInfo.containerSize.height
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
var menuHeight by remember { mutableFloatStateOf(0f) }
|
||||||
|
val spacing = with(density) { 12.dp.toPx() }
|
||||||
|
val showAbove =
|
||||||
|
(windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f))
|
||||||
|
.clickable { selectedMessage = null }
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
val totalExtraHeight = if (menuHeight > 0) menuHeight + spacing else 300f
|
||||||
|
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(contentBottom + 200.dp))
|
||||||
|
|
||||||
|
ChatMessage(
|
||||||
|
model = model,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset { IntOffset(0, bounds.top.toInt()) }
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
val menuOffset = if (showAbove) {
|
||||||
|
bounds.top - menuHeight - spacing
|
||||||
|
} else {
|
||||||
|
bounds.bottom + spacing
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) }
|
||||||
|
.onGloballyPositioned { menuHeight = it.size.height.toFloat() }
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
ContextMenu { action ->
|
||||||
|
when (action) {
|
||||||
|
"Copy" -> {
|
||||||
|
scope.launch {
|
||||||
|
val content = model.annotatedContent
|
||||||
|
val data = ClipData.newPlainText(content, content)
|
||||||
|
clipboardManager.setClipEntry(ClipEntry(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
selectedMessage = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun ContextMenu(onAction: (String) -> Unit) {
|
||||||
|
val menuItems = listOf(
|
||||||
|
Triple("Copy", Res.drawable.ic_copy, false),
|
||||||
|
Triple("Reply", Res.drawable.ic_reply, false),
|
||||||
|
Triple("Info", Res.drawable.ic_info, false),
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownMenuGroup(
|
||||||
|
shapes = MenuDefaults.groupShape(1, 1),
|
||||||
|
containerColor = MenuDefaults.groupVibrantContainerColor,
|
||||||
|
modifier = Modifier.width(220.dp)
|
||||||
|
) {
|
||||||
|
val itemCount = menuItems.size
|
||||||
|
|
||||||
|
menuItems.forEachIndexed { index, (label, icon, hasDivider) ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
shapes = MenuDefaults.itemShape(index, itemCount),
|
||||||
|
colors = MenuDefaults.selectableItemVibrantColors(),
|
||||||
|
text = { Text(label) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(icon),
|
||||||
|
contentDescription = label,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
checked = false,
|
||||||
|
onCheckedChange = { _ -> onAction(label) },
|
||||||
|
)
|
||||||
|
if (hasDivider) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,11 @@ package su.reya.coop
|
|||||||
|
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
|
|
||||||
fun PublicKey.short(): String {
|
|
||||||
val bech32 = toBech32()
|
|
||||||
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
|
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
|
||||||
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "avif")
|
||||||
|
|
||||||
fun String.removeImageUrls(): String {
|
fun String.removeImageUrls(): String {
|
||||||
return URL_REGEX.replace(this) { result ->
|
return URL_REGEX.replace(this) { if (it.value.isImageUrl()) "" else it.value }.trim()
|
||||||
if (result.value.isImageUrl()) "" else result.value
|
|
||||||
}.trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun String.isImageUrl(): Boolean {
|
fun String.isImageUrl(): Boolean {
|
||||||
@@ -24,3 +17,8 @@ fun String.isImageUrl(): Boolean {
|
|||||||
fun String.sanitizeName(): String {
|
fun String.sanitizeName(): String {
|
||||||
return this.replace("\n", " ").replace("\r", " ").trim()
|
return this.replace("\n", " ").replace("\r", " ").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun PublicKey.short(): String {
|
||||||
|
val bech32 = toBech32()
|
||||||
|
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user