feat: add support for reply message #44
@@ -27,7 +27,6 @@ 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.geometry.Rect
|
||||||
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.LayoutCoordinates
|
||||||
import androidx.compose.ui.layout.boundsInWindow
|
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.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.URL_REGEX
|
import su.reya.coop.URL_REGEX
|
||||||
@@ -49,27 +49,25 @@ import su.reya.coop.isImageUrl
|
|||||||
import su.reya.coop.removeImageUrls
|
import su.reya.coop.removeImageUrls
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class MessageUiModel(
|
data class MessageModel(
|
||||||
val id: String,
|
val id: EventId,
|
||||||
|
val author: PublicKey,
|
||||||
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
|
||||||
fun rememberMessageUiModel(
|
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
|
||||||
event: UnsignedEvent,
|
return remember(event, currentUser) {
|
||||||
currentUserPublicKey: PublicKey?,
|
val id = event.ensureId().id()!!
|
||||||
contentColor: Color
|
val isMine = currentUser == event.author()
|
||||||
): MessageUiModel {
|
|
||||||
return remember(event, currentUserPublicKey, contentColor) {
|
|
||||||
val content = event.content()
|
val content = event.content()
|
||||||
val images = URL_REGEX.findAll(content)
|
val replyEventIds = event.tags().eventIds()
|
||||||
.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()
|
||||||
|
|
||||||
val annotatedString = buildAnnotatedString {
|
val annotatedString = buildAnnotatedString {
|
||||||
@@ -82,7 +80,6 @@ fun rememberMessageUiModel(
|
|||||||
url = url,
|
url = url,
|
||||||
styles = TextLinkStyles(
|
styles = TextLinkStyles(
|
||||||
style = SpanStyle(
|
style = SpanStyle(
|
||||||
color = contentColor,
|
|
||||||
textDecoration = TextDecoration.Underline,
|
textDecoration = TextDecoration.Underline,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
@@ -96,19 +93,21 @@ fun rememberMessageUiModel(
|
|||||||
append(cleanedContent.substring(lastIndex))
|
append(cleanedContent.substring(lastIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageUiModel(
|
MessageModel(
|
||||||
id = event.id()?.toHex() ?: event.hashCode().toString(),
|
id = id,
|
||||||
|
author = event.author(),
|
||||||
annotatedContent = annotatedString,
|
annotatedContent = annotatedString,
|
||||||
images = images,
|
images = images,
|
||||||
timestamp = event.createdAt().formatAsTime(),
|
timestamp = event.createdAt().formatAsTime(),
|
||||||
isMine = event.author() == currentUserPublicKey
|
isMine = isMine,
|
||||||
|
replyEventIds = replyEventIds
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatMessage(
|
fun ChatMessage(
|
||||||
model: MessageUiModel,
|
model: MessageModel,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onLongClick: (Rect) -> Unit = {}
|
onLongClick: (Rect) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
@@ -146,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
|
||||||
@@ -43,7 +44,6 @@ import androidx.compose.material3.DropdownMenuGroup
|
|||||||
import androidx.compose.material3.DropdownMenuItem
|
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
|
||||||
@@ -84,20 +84,20 @@ 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_copy
|
||||||
import coop.composeapp.generated.resources.ic_info
|
|
||||||
import coop.composeapp.generated.resources.ic_reply
|
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
|
||||||
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
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.RoomUiState
|
import su.reya.coop.RoomUiState
|
||||||
import su.reya.coop.Screen
|
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.shared.Avatar
|
||||||
import su.reya.coop.uiStateFlow
|
import su.reya.coop.uiStateFlow
|
||||||
import su.reya.coop.viewmodel.AccountViewModel
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
@@ -138,19 +138,20 @@ fun ChatScreen(
|
|||||||
return
|
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)
|
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
||||||
.collectAsStateWithLifecycle(RoomUiState())
|
.collectAsStateWithLifecycle(RoomUiState())
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var selectedMessage by remember { mutableStateOf<Pair<MessageUiModel, Rect>?>(null) }
|
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
|
||||||
|
var replyingTo by remember { mutableStateOf<MessageModel?>(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() } } }
|
|
||||||
|
|
||||||
val blurAmount by animateDpAsState(
|
val blurAmount by animateDpAsState(
|
||||||
targetValue = if (selectedMessage != null) 8.dp else 0.dp,
|
targetValue = if (selectedMessage != null) 8.dp else 0.dp,
|
||||||
@@ -267,42 +268,48 @@ fun ChatScreen(
|
|||||||
room?.let { ScreenerCard(accountViewModel, it) }
|
room?.let { ScreenerCard(accountViewModel, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
val otherColor = MaterialTheme.colorScheme.onSurface
|
|
||||||
|
|
||||||
when (messages.isNotEmpty()) {
|
when (messages.isNotEmpty()) {
|
||||||
true -> {
|
true -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
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.id()?.toHex() ?: it.hashCode().toString() }
|
key = { it.ensureId().id()?.toHex()!! }
|
||||||
) { event ->
|
) { event ->
|
||||||
val isMine = currentUser?.publicKey == event.author()
|
val model =
|
||||||
val uiModel = rememberMessageUiModel(
|
rememberMessageModel(event, currentUser?.publicKey)
|
||||||
event = event,
|
|
||||||
currentUserPublicKey = currentUser?.publicKey,
|
|
||||||
contentColor = if (isMine) mineColor else otherColor
|
|
||||||
)
|
|
||||||
val isHighlighted =
|
|
||||||
selectedMessage?.first?.id == uiModel.id
|
|
||||||
|
|
||||||
ChatMessage(
|
val replyPreview =
|
||||||
model = uiModel,
|
remember(model.replyEventIds, messages.size) {
|
||||||
modifier = Modifier.graphicsLayer {
|
model.replyEventIds.firstOrNull()
|
||||||
alpha = if (isHighlighted) 0f else 1f
|
?.let { replyId ->
|
||||||
},
|
messages.find { it.id() == replyId }
|
||||||
onLongClick = { rect ->
|
}
|
||||||
selectedMessage = uiModel to rect
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
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 {
|
item {
|
||||||
DateSeparator(dateHeader)
|
DateSeparator(dateHeader)
|
||||||
@@ -373,6 +380,9 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
replyingTo?.let {
|
||||||
|
ReplyBox(it) { replyingTo = null }
|
||||||
|
}
|
||||||
ChatInput(
|
ChatInput(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
@@ -471,6 +481,10 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"Reply" -> {
|
||||||
|
replyingTo = model
|
||||||
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
selectedMessage = null
|
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)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContextMenu(onAction: (String) -> Unit) {
|
private fun ContextMenu(onAction: (String) -> Unit) {
|
||||||
val menuItems = listOf(
|
val menuItems = listOf(
|
||||||
Triple("Copy", Res.drawable.ic_copy, false),
|
"Copy" to Res.drawable.ic_copy,
|
||||||
Triple("Reply", Res.drawable.ic_reply, false),
|
"Reply" to Res.drawable.ic_reply
|
||||||
Triple("Info", Res.drawable.ic_info, false),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
DropdownMenuGroup(
|
DropdownMenuGroup(
|
||||||
@@ -497,7 +596,7 @@ private fun ContextMenu(onAction: (String) -> Unit) {
|
|||||||
) {
|
) {
|
||||||
val itemCount = menuItems.size
|
val itemCount = menuItems.size
|
||||||
|
|
||||||
menuItems.forEachIndexed { index, (label, icon, hasDivider) ->
|
menuItems.forEachIndexed { index, (label, icon) ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
shapes = MenuDefaults.itemShape(index, itemCount),
|
shapes = MenuDefaults.itemShape(index, itemCount),
|
||||||
colors = MenuDefaults.selectableItemVibrantColors(),
|
colors = MenuDefaults.selectableItemVibrantColors(),
|
||||||
@@ -511,11 +610,6 @@ private fun ContextMenu(onAction: (String) -> Unit) {
|
|||||||
checked = false,
|
checked = false,
|
||||||
onCheckedChange = { _ -> onAction(label) },
|
onCheckedChange = { _ -> onAction(label) },
|
||||||
)
|
)
|
||||||
if (hasDivider) {
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ fun Timestamp.ago(): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Timestamp.formatAsGroupHeader(): String {
|
fun Timestamp.formatAsGroup(): String {
|
||||||
val timeZone = TimeZone.currentSystemDefault()
|
val timeZone = TimeZone.currentSystemDefault()
|
||||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||||
val inputDate = inputInstant.toLocalDateTime(timeZone).date
|
val inputDate = inputInstant.toLocalDateTime(timeZone).date
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package su.reya.coop.repository
|
package su.reya.coop.repository
|
||||||
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -33,6 +35,7 @@ class ChatRepository(
|
|||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val mediaRepository: MediaRepository,
|
private val mediaRepository: MediaRepository,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
) : ErrorHost by createErrorHost() {
|
) : ErrorHost by createErrorHost() {
|
||||||
private val _state = MutableStateFlow(ChatState())
|
private val _state = MutableStateFlow(ChatState())
|
||||||
val state = _state.stateIn(
|
val state = _state.stateIn(
|
||||||
@@ -58,7 +61,7 @@ class ChatRepository(
|
|||||||
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
|
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch(defaultDispatcher) {
|
||||||
nostr.waitUntilInitialized()
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
// Observe message sync progress
|
// Observe message sync progress
|
||||||
@@ -137,7 +140,7 @@ class ChatRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun refreshChatRooms() {
|
fun refreshChatRooms() {
|
||||||
scope.launch {
|
scope.launch(defaultDispatcher) {
|
||||||
try {
|
try {
|
||||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
_state.update { currentState ->
|
_state.update { currentState ->
|
||||||
@@ -166,7 +169,7 @@ class ChatRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun chatRoomConnect(roomId: Long) {
|
fun chatRoomConnect(roomId: Long) {
|
||||||
scope.launch {
|
scope.launch(defaultDispatcher) {
|
||||||
try {
|
try {
|
||||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
val members = room.members
|
val members = room.members
|
||||||
@@ -183,7 +186,7 @@ class ChatRepository(
|
|||||||
showError("Message cannot be empty")
|
showError("Message cannot be empty")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch(defaultDispatcher) {
|
||||||
try {
|
try {
|
||||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
nostr.messages.sendMessage(
|
nostr.messages.sendMessage(
|
||||||
@@ -193,7 +196,7 @@ class ChatRepository(
|
|||||||
replies = replies,
|
replies = replies,
|
||||||
onRumorCreated = { event ->
|
onRumorCreated = { event ->
|
||||||
updateRoomList(roomId, event)
|
updateRoomList(roomId, event)
|
||||||
scope.launch { _newEvents.tryEmit(event) }
|
scope.launch(defaultDispatcher) { _newEvents.tryEmit(event) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.Profile
|
import su.reya.coop.Profile
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
@@ -39,7 +40,7 @@ class ChatScreenViewModel(
|
|||||||
private fun loadMessages() {
|
private fun loadMessages() {
|
||||||
chatRepository.loadChatRoomMessages(id) { initialMessages ->
|
chatRepository.loadChatRoomMessages(id) { initialMessages ->
|
||||||
messages.clear()
|
messages.clear()
|
||||||
messages.addAll(initialMessages)
|
messages.addAll(initialMessages.distinctBy { it.id() })
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@ class ChatScreenViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
chatRepository.newEvents.collect { event ->
|
chatRepository.newEvents.collect { event ->
|
||||||
if (event.roomId() == id) {
|
if (event.roomId() == id) {
|
||||||
if (event.id() !in messages.map { it.id() }) {
|
if (messages.none { it.id() == event.id() }) {
|
||||||
messages.add(0, event)
|
messages.add(0, event)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -62,8 +63,9 @@ class ChatScreenViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(text: String) {
|
fun sendMessage(text: String, replyTo: EventId? = null) {
|
||||||
chatRepository.sendMessage(id, text)
|
val replyToList = if (replyTo != null) listOf(replyTo) else emptyList()
|
||||||
|
chatRepository.sendMessage(id, text, replyToList)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendFileMessage(file: ByteArray?, type: String?) {
|
fun sendFileMessage(file: ByteArray?, type: String?) {
|
||||||
|
|||||||
Reference in New Issue
Block a user