feat: add support for reply message #44

Merged
reya merged 3 commits from feat/reply-message into master 2026-07-14 10:22:30 +00:00
5 changed files with 172 additions and 74 deletions

View File

@@ -27,7 +27,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
@@ -41,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import rust.nostr.sdk.EventId
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.URL_REGEX
@@ -49,27 +49,25 @@ import su.reya.coop.isImageUrl
import su.reya.coop.removeImageUrls
@Immutable
data class MessageUiModel(
val id: String,
data class MessageModel(
val id: EventId,
val author: PublicKey,
val annotatedContent: AnnotatedString,
val images: List<String>,
val timestamp: String,
val isMine: Boolean
val isMine: Boolean,
val replyEventIds: List<EventId>
)
@Composable
fun rememberMessageUiModel(
event: UnsignedEvent,
currentUserPublicKey: PublicKey?,
contentColor: Color
): MessageUiModel {
return remember(event, currentUserPublicKey, contentColor) {
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
return remember(event, currentUser) {
val id = event.ensureId().id()!!
val isMine = currentUser == event.author()
val content = event.content()
val images = URL_REGEX.findAll(content)
.map { it.value }
.filter { it.isImageUrl() }
.toList()
val replyEventIds = event.tags().eventIds()
val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
val cleanedContent = content.removeImageUrls()
val annotatedString = buildAnnotatedString {
@@ -82,7 +80,6 @@ fun rememberMessageUiModel(
url = url,
styles = TextLinkStyles(
style = SpanStyle(
color = contentColor,
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Medium
)
@@ -96,19 +93,21 @@ fun rememberMessageUiModel(
append(cleanedContent.substring(lastIndex))
}
MessageUiModel(
id = event.id()?.toHex() ?: event.hashCode().toString(),
MessageModel(
id = id,
author = event.author(),
annotatedContent = annotatedString,
images = images,
timestamp = event.createdAt().formatAsTime(),
isMine = event.author() == currentUserPublicKey
isMine = isMine,
replyEventIds = replyEventIds
)
}
}
@Composable
fun ChatMessage(
model: MessageUiModel,
model: MessageModel,
modifier: Modifier = Modifier,
onLongClick: (Rect) -> Unit = {}
) {
@@ -146,14 +145,14 @@ fun ChatMessage(
}
),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (model.annotatedContent.isNotBlank()) {
Surface(
modifier = Modifier.widthIn(max = 280.dp),
color = containerColor,
contentColor = contentColor,
shape = bubbleShape,
modifier = Modifier.widthIn(max = 280.dp)
) {
Text(
text = model.annotatedContent,

View File

@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -43,7 +44,6 @@ import androidx.compose.material3.DropdownMenuGroup
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
@@ -84,20 +84,20 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
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.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.RoomUiState
import su.reya.coop.Screen
import su.reya.coop.formatAsGroupHeader
import su.reya.coop.formatAsGroup
import su.reya.coop.shared.Avatar
import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel
@@ -138,19 +138,20 @@ fun ChatScreen(
return
}
val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages
val requireScreening = viewModel.requireScreening
val messages = viewModel.messages
val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
.collectAsStateWithLifecycle(RoomUiState())
var text by remember { mutableStateOf("") }
var selectedMessage by remember { mutableStateOf<Pair<MessageUiModel, Rect>?>(null) }
val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages
val requireScreening = viewModel.requireScreening
val messages = viewModel.messages
val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
var replyingTo by remember { mutableStateOf<MessageModel?>(null) }
val blurAmount by animateDpAsState(
targetValue = if (selectedMessage != null) 8.dp else 0.dp,
@@ -267,42 +268,48 @@ fun ChatScreen(
room?.let { ScreenerCard(accountViewModel, it) }
}
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
val otherColor = MaterialTheme.colorScheme.onSurface
when (messages.isNotEmpty()) {
true -> {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
reverseLayout = true,
state = listState,
reverseLayout = true,
contentPadding = PaddingValues(16.dp),
) {
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
items(
items = messagesInGroup,
key = { it.id()?.toHex() ?: it.hashCode().toString() }
key = { it.ensureId().id()?.toHex()!! }
) { event ->
val isMine = currentUser?.publicKey == event.author()
val uiModel = rememberMessageUiModel(
event = event,
currentUserPublicKey = currentUser?.publicKey,
contentColor = if (isMine) mineColor else otherColor
)
val isHighlighted =
selectedMessage?.first?.id == uiModel.id
val model =
rememberMessageModel(event, currentUser?.publicKey)
ChatMessage(
model = uiModel,
modifier = Modifier.graphicsLayer {
alpha = if (isHighlighted) 0f else 1f
},
onLongClick = { rect ->
selectedMessage = uiModel to rect
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(
model = model,
modifier = Modifier.graphicsLayer {
alpha =
if (selectedMessage?.first?.id == model.id) 0f else 1f
},
onLongClick = { rect ->
selectedMessage = model to rect
}
)
}
}
item {
DateSeparator(dateHeader)
@@ -373,6 +380,9 @@ fun ChatScreen(
}
else -> {
replyingTo?.let {
ReplyBox(it) { replyingTo = null }
}
ChatInput(
value = text,
onValueChange = { text = it },
@@ -471,6 +481,10 @@ fun ChatScreen(
}
}
"Reply" -> {
replyingTo = model
}
else -> {}
}
selectedMessage = null
@@ -481,13 +495,98 @@ fun ChatScreen(
}
}
@Composable
private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
val profileCache = LocalProfileCache.current
val profileFlow = remember(model) { profileCache.getMetadata(model.author) }
val profile by profileFlow.collectAsStateWithLifecycle()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onDismiss() },
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Replying to ${profile?.name}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
alpha = 0.6f
),
)
Text(
text = model.annotatedContent.toString().ifBlank {
if (model.images.isNotEmpty()) "[Image]" else ""
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
}
@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)
@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),
"Copy" to Res.drawable.ic_copy,
"Reply" to Res.drawable.ic_reply
)
DropdownMenuGroup(
@@ -497,7 +596,7 @@ private fun ContextMenu(onAction: (String) -> Unit) {
) {
val itemCount = menuItems.size
menuItems.forEachIndexed { index, (label, icon, hasDivider) ->
menuItems.forEachIndexed { index, (label, icon) ->
DropdownMenuItem(
shapes = MenuDefaults.itemShape(index, itemCount),
colors = MenuDefaults.selectableItemVibrantColors(),
@@ -511,11 +610,6 @@ private fun ContextMenu(onAction: (String) -> Unit) {
checked = false,
onCheckedChange = { _ -> onAction(label) },
)
if (hasDivider) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
)
}
}
}
}

View File

@@ -150,7 +150,7 @@ fun Timestamp.ago(): String {
}
}
fun Timestamp.formatAsGroupHeader(): String {
fun Timestamp.formatAsGroup(): String {
val timeZone = TimeZone.currentSystemDefault()
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
val inputDate = inputInstant.toLocalDateTime(timeZone).date

View File

@@ -1,7 +1,9 @@
package su.reya.coop.repository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -33,6 +35,7 @@ class ChatRepository(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
private val scope: CoroutineScope,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() {
private val _state = MutableStateFlow(ChatState())
val state = _state.stateIn(
@@ -58,7 +61,7 @@ class ChatRepository(
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
init {
scope.launch {
scope.launch(defaultDispatcher) {
nostr.waitUntilInitialized()
// Observe message sync progress
@@ -137,7 +140,7 @@ class ChatRepository(
}
fun refreshChatRooms() {
scope.launch {
scope.launch(defaultDispatcher) {
try {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState ->
@@ -166,7 +169,7 @@ class ChatRepository(
}
fun chatRoomConnect(roomId: Long) {
scope.launch {
scope.launch(defaultDispatcher) {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
val members = room.members
@@ -183,7 +186,7 @@ class ChatRepository(
showError("Message cannot be empty")
return
}
scope.launch {
scope.launch(defaultDispatcher) {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendMessage(
@@ -193,7 +196,7 @@ class ChatRepository(
replies = replies,
onRumorCreated = { event ->
updateRoomList(roomId, event)
scope.launch { _newEvents.tryEmit(event) }
scope.launch(defaultDispatcher) { _newEvents.tryEmit(event) }
},
)
} catch (e: Exception) {

View File

@@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import rust.nostr.sdk.EventId
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Profile
import su.reya.coop.Room
@@ -39,7 +40,7 @@ class ChatScreenViewModel(
private fun loadMessages() {
chatRepository.loadChatRoomMessages(id) { initialMessages ->
messages.clear()
messages.addAll(initialMessages)
messages.addAll(initialMessages.distinctBy { it.id() })
loading = false
}
}
@@ -52,7 +53,7 @@ class ChatScreenViewModel(
viewModelScope.launch {
chatRepository.newEvents.collect { event ->
if (event.roomId() == id) {
if (event.id() !in messages.map { it.id() }) {
if (messages.none { it.id() == event.id() }) {
messages.add(0, event)
}
} else {
@@ -62,8 +63,9 @@ class ChatScreenViewModel(
}
}
fun sendMessage(text: String) {
chatRepository.sendMessage(id, text)
fun sendMessage(text: String, replyTo: EventId? = null) {
val replyToList = if (replyTo != null) listOf(replyTo) else emptyList()
chatRepository.sendMessage(id, text, replyToList)
}
fun sendFileMessage(file: ByteArray?, type: String?) {