feat: add support for reply message #44
@@ -55,7 +55,8 @@ data class MessageModel(
|
|||||||
val annotatedContent: AnnotatedString,
|
val annotatedContent: AnnotatedString,
|
||||||
val images: List<String>,
|
val images: List<String>,
|
||||||
val timestamp: String,
|
val timestamp: String,
|
||||||
val isMine: Boolean
|
val isMine: Boolean,
|
||||||
|
val replyEventIds: List<EventId>
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -64,6 +65,7 @@ fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null):
|
|||||||
val id = event.ensureId().id()!!
|
val id = event.ensureId().id()!!
|
||||||
val isMine = currentUser == event.author()
|
val isMine = currentUser == event.author()
|
||||||
val content = event.content()
|
val content = event.content()
|
||||||
|
val replyEventIds = event.tags().eventIds()
|
||||||
|
|
||||||
val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
|
val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
|
||||||
val cleanedContent = content.removeImageUrls()
|
val cleanedContent = content.removeImageUrls()
|
||||||
@@ -98,6 +100,7 @@ fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null):
|
|||||||
images = images,
|
images = images,
|
||||||
timestamp = event.createdAt().formatAsTime(),
|
timestamp = event.createdAt().formatAsTime(),
|
||||||
isMine = isMine,
|
isMine = isMine,
|
||||||
|
replyEventIds = replyEventIds
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,14 +145,14 @@ fun ChatMessage(
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
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(4.dp)
|
||||||
) {
|
) {
|
||||||
if (model.annotatedContent.isNotBlank()) {
|
if (model.annotatedContent.isNotBlank()) {
|
||||||
Surface(
|
Surface(
|
||||||
|
modifier = Modifier.widthIn(max = 280.dp),
|
||||||
color = containerColor,
|
color = containerColor,
|
||||||
contentColor = contentColor,
|
contentColor = contentColor,
|
||||||
shape = bubbleShape,
|
shape = bubbleShape,
|
||||||
modifier = Modifier.widthIn(max = 280.dp)
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = model.annotatedContent,
|
text = model.annotatedContent,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
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
|
||||||
@@ -89,6 +90,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileCache
|
import su.reya.coop.LocalProfileCache
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
@@ -272,18 +274,31 @@ fun ChatScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
reverseLayout = true,
|
|
||||||
state = listState,
|
state = listState,
|
||||||
|
reverseLayout = true,
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
) {
|
) {
|
||||||
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
|
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
|
||||||
items(
|
items(
|
||||||
items = messagesInGroup,
|
items = messagesInGroup,
|
||||||
key = { it.ensureId().id()?.toHex()!! }
|
key = { it.ensureId().id()?.toHex()!! }
|
||||||
) {
|
) { event ->
|
||||||
val model =
|
val model =
|
||||||
rememberMessageModel(it, currentUser?.publicKey)
|
rememberMessageModel(event, currentUser?.publicKey)
|
||||||
|
|
||||||
|
val replyPreview =
|
||||||
|
remember(model.replyEventIds, messages.size) {
|
||||||
|
model.replyEventIds.firstOrNull()
|
||||||
|
?.let { replyId ->
|
||||||
|
messages.find { it.id() == replyId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
replyPreview?.let { ReplyPreview(it, model.isMine) }
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
model = model,
|
model = model,
|
||||||
modifier = Modifier.graphicsLayer {
|
modifier = Modifier.graphicsLayer {
|
||||||
@@ -295,6 +310,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
DateSeparator(dateHeader)
|
DateSeparator(dateHeader)
|
||||||
}
|
}
|
||||||
@@ -521,6 +537,50 @@ private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
|
||||||
|
val profileCache = LocalProfileCache.current
|
||||||
|
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
|
||||||
|
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val bubbleShape = if (isMine) {
|
||||||
|
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.widthIn(max = 280.dp),
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
shape = bubbleShape,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = profile?.name ?: "Unknown",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
|
||||||
|
alpha = 0.6f
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = event.content(),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContextMenu(onAction: (String) -> Unit) {
|
private fun ContextMenu(onAction: (String) -> Unit) {
|
||||||
|
|||||||
Reference in New Issue
Block a user