diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4e8c799aff..56e5ecc5ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1986,6 +1986,26 @@ class Account( return true } + /** + * Post [text] into [rootNote]'s minichat — a kind-1111 thread reply rooted at that + * message. Resolves the chat context from the note's gatherer; today it drives the + * Concord channel path (NIP-28/NIP-29 public-chat minichats are a follow-up). Returns + * false if the message isn't in a chat we can post a thread reply to. + */ + suspend fun sendMinichatReply( + rootNote: Note, + text: String, + ): Boolean { + val concord = rootNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false + return sendConcordChannelMessage( + concord.channelId.communityId, + concord.channelId.channelId, + text, + rootNote, + ReplyMode.MINICHAT, + ) + } + /** * React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors * [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index d58c8ad814..d49d68af89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -97,6 +97,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroup import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupInfoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat.MinichatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen @@ -602,6 +603,14 @@ fun BuildNavigation( ) } + composableFromEndArgs { + MinichatScreen( + rootId = it.rootId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { ConcordChannelListScreen( communityId = it.communityId, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b4ab7953bc..6c11434f12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -707,6 +707,13 @@ sealed class Route { @Serializable object Concords : Route() + // The "minichat" of a chat message: its kind-1111 thread replies, opened from the message and + // rendered as a chat-within-a-chat. Keyed by the root message id; the screen resolves the chat + // context (Concord channel, public chat, relay group) from the note's gatherer. + @Serializable data class ChatMinichat( + val rootId: HexKey, + ) : Route() + @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index d76b6087d4..96b3dafb98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -415,7 +415,7 @@ private fun MinichatReplyChip( Surface( shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.clickable { nav.nav(Route.Note(note.idHex)) }, + modifier = Modifier.clickable { nav.nav(Route.ChatMinichat(note.idHex)) }, ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt new file mode 100644 index 0000000000..6c9d917be6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplies +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder +import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier +import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The minichat ("chat within a chat") of a single chat message: the message pinned at + * top, then its kind-1111 thread replies as chat bubbles, with a composer that always + * posts a kind-1111 rooted at that message (flat — replying inside a minichat doesn't + * spawn sub-threads). Opened from the "N replies" chip on any chat message. + * + * It reuses [ChatroomMessageCompose] so the bubbles look exactly like the main chat. + * Observing the root's replies also loads them from relays for public chats; Concord's + * thread replies keep arriving over the account-wide plane ingestion. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MinichatScreen( + rootId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val rootNote = remember(rootId) { LocalCache.getOrCreateNote(rootId) } + + // Loads + reacts to the root's replies (kind-1111 among them). + val replyState by observeNoteReplies(rootNote, accountViewModel) + val replies = + remember(replyState) { + rootNote.replies + .filter { it.event is CommentEvent } + .sortedWith(compareBy({ it.createdAt() ?: 0L }, { it.idHex })) + } + + val composer = remember { TextFieldState() } + val scope = rememberCoroutineScope() + val context = LocalContext.current + val canPost by remember { derivedStateOf { composer.text.isNotBlank() } } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.chat_minichat_title), fontWeight = FontWeight.Bold, maxLines = 1) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + }, + ) + }, + ) { padding -> + Column(Modifier.fillMaxHeight().padding(padding)) { + LazyColumn(Modifier.fillMaxWidth().weight(1f, true)) { + item("root") { + ChatroomMessageCompose( + baseNote = rootNote, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {}, + onWantsToEditDraft = {}, + ) + HorizontalDivider() + } + items(replies, key = { it.idHex }) { reply -> + ChatroomMessageCompose( + baseNote = reply, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {}, + onWantsToEditDraft = {}, + ) + } + } + + Column(modifier = EditFieldModifier) { + ThinPaddingTextField( + state = composer, + modifier = Modifier.fillMaxWidth(), + shape = EditFieldBorder, + placeholder = { + Text( + text = stringRes(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + ThinSendButton( + isActive = canPost, + modifier = EditFieldTrailingIconModifier, + ) { + val text = composer.text.toString().trim() + if (text.isNotEmpty()) { + composer.clearText() + scope.launch(Dispatchers.IO) { + try { + accountViewModel.account.sendMinichatReply(rootNote, text) + } catch (e: Exception) { + launch(Dispatchers.Main) { + Toast.makeText(context, "Failed to send message: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3309ae1179..553edca79d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -352,6 +352,8 @@ In chat In thread + + Thread %1$d reply