diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 26e75a4..5d73c54 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,9 @@ "WebFetch(domain:raw.githubusercontent.com)", "Bash(export TERM=dumb:*)", "Bash(grep:*)", - "Bash(jar tf:*)" + "Bash(jar tf:*)", + "Bash(javap:*)", + "Bash(jar xf:*)" ] } } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt index 7f8da8b..34ccf55 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt @@ -50,6 +50,9 @@ fun MainScreen( val expandedThreadRooms by viewModel.expandedThreadRooms.collectAsStateWithLifecycle() val selectedThread by viewModel.selectedThread.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 preferencesManager: PreferencesManager = koinInject() 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) { diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt index e3527c0..352cbe0 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.EditedContent import org.matrix.rustcomponents.sdk.EventOrTransactionId import org.matrix.rustcomponents.sdk.Membership import org.matrix.rustcomponents.sdk.MembershipState @@ -185,6 +186,15 @@ class MainViewModel( private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE) val homeUnreadStatus: StateFlow = _homeUnreadStatus + + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId + + private val _replyingTo = MutableStateFlow(null) + val replyingTo: StateFlow = _replyingTo + + private val _editingMessage = MutableStateFlow(null) + val editingMessage: StateFlow = _editingMessage private val _spaceChildrenMap = MutableStateFlow>>(emptyMap()) private val _channelSections = MutableStateFlow>(emptyList()) @@ -271,6 +281,7 @@ class MainViewModel( init { viewModelScope.launch(Dispatchers.IO) { syncService = authRepository.getOrStartSync() + try { _currentUserId.value = authRepository.getClient()?.userId() } catch (_: Exception) {} loadRooms() } observeSelectedChannel() @@ -545,11 +556,46 @@ class MainViewModel( } is TimelineDiff.Set -> { 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 -> { 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 -> { 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() { ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) viewModelScope.launch { diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt index e7ac0e7..dde91ff 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt @@ -55,15 +55,23 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable 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.markdownColor import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.KeyboardArrowDown +import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.filled.PlayCircleFilled import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -140,9 +148,21 @@ fun MessageTimeline( onOpenThread: (String) -> Unit = {}, threadReplyCounts: Map = emptyMap(), 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(null) } var fullscreenVideoUrl by remember { mutableStateOf(null) } + var contextMenuMessage by remember { mutableStateOf(null) } if (fullscreenImageUrl != null) { 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( LocalImageViewer provides { url -> fullscreenImageUrl = url }, LocalVideoPlayer provides { url -> fullscreenVideoUrl = url }, @@ -281,17 +327,17 @@ fun MessageTimeline( Spacer(modifier = Modifier.height(8.dp)) if (isFirstInGroup) { 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 { - CompactMessage(message) + CompactMessage(message, onLongPress = { contextMenuMessage = it }) } } } else { if (isFirstInGroup) { Spacer(modifier = Modifier.height(12.dp)) - FullMessage(message, onOpenThread = onOpenThread) + FullMessage(message, onOpenThread = onOpenThread, onLongPress = { contextMenuMessage = it }) } else { - CompactMessage(message) + CompactMessage(message, onLongPress = { contextMenuMessage = it }) } } } @@ -319,10 +365,41 @@ fun MessageTimeline( } 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( channelName = if (selectedThread != null) "thread" else (channelName ?: "message"), + replyingTo = replyingTo, + editingMessage = editingMessage, onSendMessage = activeSend, 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 -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 time = remember(message.timestamp) { formatTimestamp(message.timestamp) } val reply = message.replyTo - Column { + Column(modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { onLongPress(message) })) { if (reply != null) { ReplyConnector(reply, hasAvatar = true) } @@ -427,8 +504,8 @@ private fun FullMessage(message: MessageItem, onOpenThread: (String) -> Unit = { } @Composable -private fun CompactMessage(message: MessageItem) { - Column { +private fun CompactMessage(message: MessageItem, onLongPress: (MessageItem) -> Unit = {}) { + Column(modifier = Modifier.combinedClickable(onClick = {}, onLongClick = { onLongPress(message) })) { if (message.replyTo != null) { ReplyConnector(message.replyTo, hasAvatar = false) } @@ -807,9 +884,27 @@ private fun formatFileSize(bytes: Long): String { } @Composable -private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, onSendFiles: (List, String?) -> Unit) { +private fun MessageInput( + channelName: String, + onSendMessage: (String) -> Unit, + onSendFiles: (List, String?) -> Unit, + replyingTo: MessageItem? = null, + editingMessage: MessageItem? = null, + onSendReply: (String, String) -> Unit = { _, _ -> }, + onEditMessage: (String, String) -> Unit = { _, _ -> }, +) { var text by remember { mutableStateOf("") } var attachedUris by remember { mutableStateOf(listOf()) } + + // 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( contract = ActivityResultContracts.GetMultipleContents() ) { uris: List -> @@ -887,22 +982,186 @@ private fun MessageInput(channelName: String, onSendMessage: (String) -> Unit, o ) IconButton( onClick = { - if (attachedUris.isNotEmpty()) { - onSendFiles(attachedUris, text.trim().ifBlank { null }) - attachedUris = emptyList() - text = "" - } else if (text.isNotBlank()) { - onSendMessage(text.trim()) - text = "" + val trimmed = text.trim() + when { + editingMessage != null && trimmed.isNotBlank() -> { + onEditMessage(editingMessage.eventId, trimmed) + 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, ) { 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, ) } } } } + +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) + } + } + } +}