Compare commits
2 Commits
b58f745fbc
...
1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2169f28632 | |||
| 21aed4f682 |
@@ -13,7 +13,9 @@
|
|||||||
"WebFetch(domain:raw.githubusercontent.com)",
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
"Bash(export TERM=dumb:*)",
|
"Bash(export TERM=dumb:*)",
|
||||||
"Bash(grep:*)",
|
"Bash(grep:*)",
|
||||||
"Bash(jar tf:*)"
|
"Bash(jar tf:*)",
|
||||||
|
"Bash(javap:*)",
|
||||||
|
"Bash(jar xf:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ android {
|
|||||||
minSdk = 34
|
minSdk = 34
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ fun MainScreen(
|
|||||||
val expandedThreadRooms by viewModel.expandedThreadRooms.collectAsStateWithLifecycle()
|
val expandedThreadRooms by viewModel.expandedThreadRooms.collectAsStateWithLifecycle()
|
||||||
val selectedThread by viewModel.selectedThread.collectAsStateWithLifecycle()
|
val selectedThread by viewModel.selectedThread.collectAsStateWithLifecycle()
|
||||||
val threadMessages by viewModel.threadMessages.collectAsStateWithLifecycle()
|
val threadMessages by viewModel.threadMessages.collectAsStateWithLifecycle()
|
||||||
|
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||||
|
val replyingTo by viewModel.replyingTo.collectAsStateWithLifecycle()
|
||||||
|
val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle()
|
||||||
val listState = viewModel.channelListState
|
val listState = viewModel.channelListState
|
||||||
val preferencesManager: PreferencesManager = koinInject()
|
val preferencesManager: PreferencesManager = koinInject()
|
||||||
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
|
val hideSpacesWhenClosed by preferencesManager.hideSpacesWhenClosed.collectAsStateWithLifecycle(initialValue = false)
|
||||||
@@ -128,6 +131,17 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
replyingTo = replyingTo,
|
||||||
|
editingMessage = editingMessage,
|
||||||
|
onSetReplyingTo = { viewModel.setReplyingTo(it) },
|
||||||
|
onSetEditingMessage = { viewModel.setEditingMessage(it) },
|
||||||
|
onSendReply = { body, eventId -> viewModel.sendReply(body, eventId) },
|
||||||
|
onSendThreadReply = { body, eventId -> viewModel.sendThreadReply(body, eventId) },
|
||||||
|
onEditMessage = { eventId, body -> viewModel.editMessage(eventId, body) },
|
||||||
|
onEditThreadMessage = { eventId, body -> viewModel.editThreadMessage(eventId, body) },
|
||||||
|
onSendReaction = { eventId, emoji -> viewModel.sendReaction(eventId, emoji) },
|
||||||
|
onSendThreadReaction = { eventId, emoji -> viewModel.sendThreadReaction(eventId, emoji) },
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(visible = showMemberList) {
|
AnimatedVisibility(visible = showMemberList) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.matrix.rustcomponents.sdk.EditedContent
|
||||||
import org.matrix.rustcomponents.sdk.EventOrTransactionId
|
import org.matrix.rustcomponents.sdk.EventOrTransactionId
|
||||||
import org.matrix.rustcomponents.sdk.Membership
|
import org.matrix.rustcomponents.sdk.Membership
|
||||||
import org.matrix.rustcomponents.sdk.MembershipState
|
import org.matrix.rustcomponents.sdk.MembershipState
|
||||||
@@ -185,6 +186,15 @@ class MainViewModel(
|
|||||||
|
|
||||||
private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE)
|
private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE)
|
||||||
val homeUnreadStatus: StateFlow<UnreadStatus> = _homeUnreadStatus
|
val homeUnreadStatus: StateFlow<UnreadStatus> = _homeUnreadStatus
|
||||||
|
|
||||||
|
private val _currentUserId = MutableStateFlow<String?>(null)
|
||||||
|
val currentUserId: StateFlow<String?> = _currentUserId
|
||||||
|
|
||||||
|
private val _replyingTo = MutableStateFlow<MessageItem?>(null)
|
||||||
|
val replyingTo: StateFlow<MessageItem?> = _replyingTo
|
||||||
|
|
||||||
|
private val _editingMessage = MutableStateFlow<MessageItem?>(null)
|
||||||
|
val editingMessage: StateFlow<MessageItem?> = _editingMessage
|
||||||
private val _spaceChildrenMap = MutableStateFlow<Map<String, Set<String>>>(emptyMap())
|
private val _spaceChildrenMap = MutableStateFlow<Map<String, Set<String>>>(emptyMap())
|
||||||
|
|
||||||
private val _channelSections = MutableStateFlow<List<ChannelSection>>(emptyList())
|
private val _channelSections = MutableStateFlow<List<ChannelSection>>(emptyList())
|
||||||
@@ -271,6 +281,7 @@ class MainViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
syncService = authRepository.getOrStartSync()
|
syncService = authRepository.getOrStartSync()
|
||||||
|
try { _currentUserId.value = authRepository.getClient()?.userId() } catch (_: Exception) {}
|
||||||
loadRooms()
|
loadRooms()
|
||||||
}
|
}
|
||||||
observeSelectedChannel()
|
observeSelectedChannel()
|
||||||
@@ -545,11 +556,46 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
is TimelineDiff.Set -> {
|
is TimelineDiff.Set -> {
|
||||||
val idx = d.index.toInt()
|
val idx = d.index.toInt()
|
||||||
if (idx in sdkItems.indices) sdkItems[idx] = d.value
|
if (idx in sdkItems.indices) {
|
||||||
|
// Remove the old item from cache (e.g. TransactionId → EventId transition on send)
|
||||||
|
val old = sdkItems[idx]
|
||||||
|
sdkItems[idx] = d.value
|
||||||
|
val oldEvent = old.asEvent()
|
||||||
|
if (oldEvent != null) {
|
||||||
|
val oldId = when (val eot = oldEvent.eventOrTransactionId) {
|
||||||
|
is EventOrTransactionId.EventId -> eot.eventId
|
||||||
|
is EventOrTransactionId.TransactionId -> eot.transactionId
|
||||||
|
}
|
||||||
|
if (ids.remove(oldId)) {
|
||||||
|
val ri = cached.indexOfFirst { it.eventId == oldId }
|
||||||
|
if (ri >= 0) cached.removeAt(ri)
|
||||||
|
// Also remove from thread cache if it was a thread reply
|
||||||
|
threadMessageCache[roomId]?.values?.forEach { list ->
|
||||||
|
list.removeAll { it.eventId == oldId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is TimelineDiff.Remove -> {
|
is TimelineDiff.Remove -> {
|
||||||
val idx = d.index.toInt()
|
val idx = d.index.toInt()
|
||||||
if (idx in sdkItems.indices) sdkItems.removeAt(idx)
|
if (idx in sdkItems.indices) {
|
||||||
|
val removed = sdkItems.removeAt(idx)
|
||||||
|
val removedEvent = removed.asEvent()
|
||||||
|
if (removedEvent != null) {
|
||||||
|
val removedId = when (val eot = removedEvent.eventOrTransactionId) {
|
||||||
|
is EventOrTransactionId.EventId -> eot.eventId
|
||||||
|
is EventOrTransactionId.TransactionId -> eot.transactionId
|
||||||
|
}
|
||||||
|
if (ids.remove(removedId)) {
|
||||||
|
val ri = cached.indexOfFirst { it.eventId == removedId }
|
||||||
|
if (ri >= 0) cached.removeAt(ri)
|
||||||
|
threadMessageCache[roomId]?.values?.forEach { list ->
|
||||||
|
list.removeAll { it.eventId == removedId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is TimelineDiff.Truncate -> {
|
is TimelineDiff.Truncate -> {
|
||||||
val len = d.length.toInt()
|
val len = d.length.toInt()
|
||||||
@@ -1442,6 +1488,71 @@ class MainViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setReplyingTo(message: MessageItem?) { _replyingTo.value = message }
|
||||||
|
fun setEditingMessage(message: MessageItem?) { _editingMessage.value = message }
|
||||||
|
|
||||||
|
fun editMessage(eventId: String, newBody: String) {
|
||||||
|
val timeline = activeTimeline ?: return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
timeline.edit(
|
||||||
|
EventOrTransactionId.EventId(eventId),
|
||||||
|
EditedContent.RoomMessage(messageEventContentFromMarkdown(newBody)),
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
_editingMessage.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun editThreadMessage(eventId: String, newBody: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
threadTimelineMutex.withLock {
|
||||||
|
val t = activeThreadTimeline ?: return@withLock
|
||||||
|
try {
|
||||||
|
t.edit(
|
||||||
|
EventOrTransactionId.EventId(eventId),
|
||||||
|
EditedContent.RoomMessage(messageEventContentFromMarkdown(newBody)),
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
_editingMessage.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendReaction(eventId: String, emoji: String) {
|
||||||
|
val timeline = activeTimeline ?: return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try { timeline.toggleReaction(EventOrTransactionId.EventId(eventId), emoji) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendThreadReaction(eventId: String, emoji: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
threadTimelineMutex.withLock {
|
||||||
|
val t = activeThreadTimeline ?: return@withLock
|
||||||
|
try { t.toggleReaction(EventOrTransactionId.EventId(eventId), emoji) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendReply(body: String, replyToEventId: String) {
|
||||||
|
val timeline = activeTimeline ?: return
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try { timeline.sendReply(messageEventContentFromMarkdown(body), replyToEventId) } catch (_: Exception) {}
|
||||||
|
_replyingTo.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendThreadReply(body: String, replyToEventId: String) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
threadTimelineMutex.withLock {
|
||||||
|
val t = activeThreadTimeline ?: return@withLock
|
||||||
|
try { t.sendReply(messageEventContentFromMarkdown(body), replyToEventId) } catch (_: Exception) {}
|
||||||
|
_replyingTo.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
|
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@@ -55,15 +55,23 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import com.mikepenz.markdown.m3.Markdown
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
import com.mikepenz.markdown.m3.markdownColor
|
import com.mikepenz.markdown.m3.markdownColor
|
||||||
import com.mikepenz.markdown.m3.markdownTypography
|
import com.mikepenz.markdown.m3.markdownTypography
|
||||||
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
|
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||||
import androidx.compose.material.icons.filled.PlayCircleFilled
|
import androidx.compose.material.icons.filled.PlayCircleFilled
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
@@ -140,9 +148,21 @@ fun MessageTimeline(
|
|||||||
onOpenThread: (String) -> Unit = {},
|
onOpenThread: (String) -> Unit = {},
|
||||||
threadReplyCounts: Map<String, Int> = emptyMap(),
|
threadReplyCounts: Map<String, Int> = emptyMap(),
|
||||||
selectedThreadName: String? = null,
|
selectedThreadName: String? = null,
|
||||||
|
currentUserId: String? = null,
|
||||||
|
replyingTo: MessageItem? = null,
|
||||||
|
editingMessage: MessageItem? = null,
|
||||||
|
onSetReplyingTo: (MessageItem?) -> Unit = {},
|
||||||
|
onSetEditingMessage: (MessageItem?) -> Unit = {},
|
||||||
|
onSendReply: (String, String) -> Unit = { _, _ -> },
|
||||||
|
onSendThreadReply: (String, String) -> Unit = { _, _ -> },
|
||||||
|
onEditMessage: (String, String) -> Unit = { _, _ -> },
|
||||||
|
onEditThreadMessage: (String, String) -> Unit = { _, _ -> },
|
||||||
|
onSendReaction: (String, String) -> Unit = { _, _ -> },
|
||||||
|
onSendThreadReaction: (String, String) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
|
var fullscreenImageUrl by remember { mutableStateOf<String?>(null) }
|
||||||
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
|
var fullscreenVideoUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
var contextMenuMessage by remember { mutableStateOf<MessageItem?>(null) }
|
||||||
|
|
||||||
if (fullscreenImageUrl != null) {
|
if (fullscreenImageUrl != null) {
|
||||||
FullscreenImageViewer(
|
FullscreenImageViewer(
|
||||||
@@ -158,6 +178,32 @@ fun MessageTimeline(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contextMenuMessage?.let { msg ->
|
||||||
|
MessageContextMenu(
|
||||||
|
message = msg,
|
||||||
|
isOwnMessage = msg.senderId == currentUserId,
|
||||||
|
isInThread = selectedThread != null,
|
||||||
|
onDismiss = { contextMenuMessage = null },
|
||||||
|
onReact = { emoji ->
|
||||||
|
contextMenuMessage = null
|
||||||
|
if (selectedThread != null) onSendThreadReaction(msg.eventId, emoji)
|
||||||
|
else onSendReaction(msg.eventId, emoji)
|
||||||
|
},
|
||||||
|
onReply = {
|
||||||
|
onSetReplyingTo(msg)
|
||||||
|
contextMenuMessage = null
|
||||||
|
},
|
||||||
|
onEdit = {
|
||||||
|
onSetEditingMessage(msg)
|
||||||
|
contextMenuMessage = null
|
||||||
|
},
|
||||||
|
onStartThread = {
|
||||||
|
onOpenThread(msg.eventId)
|
||||||
|
contextMenuMessage = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalImageViewer provides { url -> fullscreenImageUrl = url },
|
LocalImageViewer provides { url -> fullscreenImageUrl = url },
|
||||||
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
|
LocalVideoPlayer provides { url -> fullscreenVideoUrl = url },
|
||||||
@@ -281,17 +327,17 @@ fun MessageTimeline(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
if (isFirstInGroup) {
|
if (isFirstInGroup) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
FullMessage(message, onOpenThread = onOpenThread, threadReplyCount = threadReplyCounts[message.eventId] ?: 0)
|
FullMessage(message, onOpenThread = onOpenThread, threadReplyCount = threadReplyCounts[message.eventId] ?: 0, onLongPress = { contextMenuMessage = it })
|
||||||
} else {
|
} else {
|
||||||
CompactMessage(message)
|
CompactMessage(message, onLongPress = { contextMenuMessage = it })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isFirstInGroup) {
|
if (isFirstInGroup) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
FullMessage(message, onOpenThread = onOpenThread)
|
FullMessage(message, onOpenThread = onOpenThread, onLongPress = { contextMenuMessage = it })
|
||||||
} else {
|
} else {
|
||||||
CompactMessage(message)
|
CompactMessage(message, onLongPress = { contextMenuMessage = it })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,10 +365,41 @@ fun MessageTimeline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
// Reply preview bar
|
||||||
|
replyingTo?.let { replyMsg ->
|
||||||
|
ReplyPreviewBar(
|
||||||
|
senderName = replyMsg.senderName,
|
||||||
|
body = when (val c = replyMsg.content) {
|
||||||
|
is com.example.fluffytrix.ui.screens.main.MessageContent.Text -> c.body
|
||||||
|
else -> "Attachment"
|
||||||
|
},
|
||||||
|
onDismiss = { onSetReplyingTo(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Edit mode bar
|
||||||
|
editingMessage?.let { editMsg ->
|
||||||
|
EditModeBar(
|
||||||
|
body = when (val c = editMsg.content) {
|
||||||
|
is com.example.fluffytrix.ui.screens.main.MessageContent.Text -> c.body
|
||||||
|
else -> ""
|
||||||
|
},
|
||||||
|
onDismiss = { onSetEditingMessage(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
MessageInput(
|
MessageInput(
|
||||||
channelName = if (selectedThread != null) "thread" else (channelName ?: "message"),
|
channelName = if (selectedThread != null) "thread" else (channelName ?: "message"),
|
||||||
|
replyingTo = replyingTo,
|
||||||
|
editingMessage = editingMessage,
|
||||||
onSendMessage = activeSend,
|
onSendMessage = activeSend,
|
||||||
onSendFiles = onSendFiles,
|
onSendFiles = onSendFiles,
|
||||||
|
onSendReply = { body, eventId ->
|
||||||
|
if (selectedThread != null) onSendThreadReply(body, eventId)
|
||||||
|
else onSendReply(body, eventId)
|
||||||
|
},
|
||||||
|
onEditMessage = { eventId, body ->
|
||||||
|
if (selectedThread != null) onEditThreadMessage(eventId, body)
|
||||||
|
else onEditMessage(eventId, body)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,12 +448,12 @@ private fun ThreadTopBar(title: String, onClose: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {}, threadReplyCount: Int = 0) {
|
private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {}, threadReplyCount: Int = 0, onLongPress: (MessageItem) -> Unit = {}) {
|
||||||
val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
|
val senderColor = remember(message.senderName) { colorForSender(message.senderName) }
|
||||||
val time = remember(message.timestamp) { formatTimestamp(message.timestamp) }
|
val time = remember(message.timestamp) { formatTimestamp(message.timestamp) }
|
||||||
val reply = message.replyTo
|
val reply = message.replyTo
|
||||||
|
|
||||||
Column {
|
Column(modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { onLongPress(message) })) {
|
||||||
if (reply != null) {
|
if (reply != null) {
|
||||||
ReplyConnector(reply, hasAvatar = true)
|
ReplyConnector(reply, hasAvatar = true)
|
||||||
}
|
}
|
||||||
@@ -427,8 +504,8 @@ private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CompactMessage(message: MessageItem) {
|
private fun CompactMessage(message: MessageItem, onLongPress: (MessageItem) -> Unit = {}) {
|
||||||
Column {
|
Column(modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { onLongPress(message) })) {
|
||||||
if (message.replyTo != null) {
|
if (message.replyTo != null) {
|
||||||
ReplyConnector(message.replyTo, hasAvatar = false)
|
ReplyConnector(message.replyTo, hasAvatar = false)
|
||||||
}
|
}
|
||||||
@@ -807,9 +884,27 @@ private fun formatFileSize(bytes: Long): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, onSendFiles: (List<Uri>, String?) -> Unit) {
|
private fun MessageInput(
|
||||||
|
channelName: String,
|
||||||
|
onSendMessage: (String) -> Unit,
|
||||||
|
onSendFiles: (List<Uri>, String?) -> Unit,
|
||||||
|
replyingTo: MessageItem? = null,
|
||||||
|
editingMessage: MessageItem? = null,
|
||||||
|
onSendReply: (String, String) -> Unit = { _, _ -> },
|
||||||
|
onEditMessage: (String, String) -> Unit = { _, _ -> },
|
||||||
|
) {
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var attachedUris by remember { mutableStateOf(listOf<Uri>()) }
|
var attachedUris by remember { mutableStateOf(listOf<Uri>()) }
|
||||||
|
|
||||||
|
// Pre-fill text when entering edit mode
|
||||||
|
androidx.compose.runtime.LaunchedEffect(editingMessage) {
|
||||||
|
if (editingMessage != null) {
|
||||||
|
text = when (val c = editingMessage.content) {
|
||||||
|
is com.example.fluffytrix.ui.screens.main.MessageContent.Text -> c.body
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
val filePickerLauncher = rememberLauncherForActivityResult(
|
val filePickerLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.GetMultipleContents()
|
contract = ActivityResultContracts.GetMultipleContents()
|
||||||
) { uris: List<Uri> ->
|
) { uris: List<Uri> ->
|
||||||
@@ -887,22 +982,186 @@ private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, o
|
|||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (attachedUris.isNotEmpty()) {
|
val trimmed = text.trim()
|
||||||
onSendFiles(attachedUris, text.trim().ifBlank { null })
|
when {
|
||||||
attachedUris = emptyList()
|
editingMessage != null && trimmed.isNotBlank() -> {
|
||||||
text = ""
|
onEditMessage(editingMessage.eventId, trimmed)
|
||||||
} else if (text.isNotBlank()) {
|
text = ""
|
||||||
onSendMessage(text.trim())
|
}
|
||||||
text = ""
|
replyingTo != null && trimmed.isNotBlank() -> {
|
||||||
|
onSendReply(trimmed, replyingTo.eventId)
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
|
attachedUris.isNotEmpty() -> {
|
||||||
|
onSendFiles(attachedUris, trimmed.ifBlank { null })
|
||||||
|
attachedUris = emptyList()
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
|
trimmed.isNotBlank() -> {
|
||||||
|
onSendMessage(trimmed)
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = canSend,
|
enabled = canSend,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Filled.Send, "Send",
|
if (editingMessage != null) Icons.Default.Check else Icons.AutoMirrored.Filled.Send,
|
||||||
|
if (editingMessage != null) "Confirm edit" else "Send",
|
||||||
tint = if (canSend) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = if (canSend) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val QUICK_REACTIONS = listOf("👍", "👎", "❤️", "😂", "😮", "😢", "🎉", "🔥")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageContextMenu(
|
||||||
|
message: MessageItem,
|
||||||
|
isOwnMessage: Boolean,
|
||||||
|
isInThread: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onReact: (String) -> Unit,
|
||||||
|
onReply: () -> Unit,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onStartThread: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = null,
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
|
// Quick emoji reactions
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
items(QUICK_REACTIONS) { emoji ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable { onReact(emoji) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(emoji, fontSize = 20.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = onReply,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.Reply, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Reply", modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
if (isOwnMessage) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onEdit,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Check, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Edit", modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isInThread) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onStartThread,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Tag, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Start Thread", modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReplyPreviewBar(senderName: String, body: String, onDismiss: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(3.dp)
|
||||||
|
.height(36.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, RoundedCornerShape(2.dp)),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"Replying to $senderName",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
body,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
|
||||||
|
Icon(Icons.Default.Close, "Cancel reply", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EditModeBar(body: String, onDismiss: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(3.dp)
|
||||||
|
.height(36.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.tertiary, RoundedCornerShape(2.dp)),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"Editing message",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
body,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
|
||||||
|
Icon(Icons.Default.Close, "Cancel edit", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user