feat(concord): rich message composer (@mention search, inline mentions, reply preview)

Replaces the bespoke plain-text composer with the app's standard chat input,
mirroring MarmotNewMessageViewModel: ConcordNewMessageViewModel drives
UserSuggestionState (type @ → avatar + name + NIP-05 dropdown, ranked by people
who've posted in the channel), and the field uses ThinPaddingTextField with
MentionPreservingInputTransformation + UrlUserTagOutputTransformation (npub
mentions render inline as colored @names), DisplayReplyingToNote for the reply
preview, and ThinSendButton. Send still routes through the Concord plane wrap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-10 23:45:29 +00:00
parent b48cc2c117
commit bfc77e80f6
2 changed files with 240 additions and 84 deletions
@@ -20,57 +20,63 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.layout.Box
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.DisposableEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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 androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send.ConcordNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
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.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* The chat screen of one Concord Channel. Messages are real Notes in [LocalCache]
* attached to the channel (landed on decrypt by
* [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager]), so the
* feed reuses the shared [RefreshingChatroomFeedView] — reactions, replies, zaps
* and OTS render exactly as in every other chat.
*
* Only the send path is Concord-specific: it derives the channel plane key and
* publishes an encrypted wrap to the community relays
* ([com.vitorpamplona.amethyst.model.Account.sendConcordChannelMessage]).
* [ConcordChannelSubscription] keeps the channel's plane live while foregrounded.
* attached to the channel, so the feed reuses the shared [RefreshingChatroomFeedView]
* (avatars, reactions, replies, zaps, OTS) and the composer reuses the same
* @-mention / inline-mention / reply machinery as the other chats. Only the send
* path is Concord-specific: it wraps the message on the channel plane.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -92,9 +98,9 @@ fun ConcordChannelScreen(
)
WatchLifecycleAndUpdateModel(feedViewModel)
val scope = rememberCoroutineScope()
var draft by remember { mutableStateOf("") }
var replyTo by remember { mutableStateOf<Note?>(null) }
val newMessageModel: ConcordNewMessageViewModel = viewModel(key = channel.channelId.toKey() + "ConcordNewMessageViewModel")
newMessageModel.init(accountViewModel)
newMessageModel.load(communityId, channelId)
Scaffold(
topBar = {
@@ -115,34 +121,25 @@ fun ConcordChannelScreen(
)
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).imePadding()) {
Column(Modifier.weight(1f).fillMaxWidth()) {
Column(Modifier.fillMaxHeight().padding(padding)) {
Column(Modifier.fillMaxHeight().weight(1f, true)) {
RefreshingChatroomFeedView(
feedContentState = feedViewModel.feedState,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Concord/$communityId/$channelId",
onWantsToReply = { replyTo = it },
onWantsToReply = { newMessageModel.reply(it) },
onWantsToEditDraft = {},
)
}
if (channel.canPost()) {
ConcordComposer(
draft = draft,
replyingTo = replyTo,
Spacer(modifier = DoubleVertSpacer)
ConcordMessageComposer(
newMessageModel = newMessageModel,
accountViewModel = accountViewModel,
onDraftChange = { draft = it },
onCancelReply = { replyTo = null },
onSend = {
val text = draft.trim()
if (text.isNotEmpty()) {
val parent = replyTo
draft = ""
replyTo = null
scope.launch { account.sendConcordChannelMessage(communityId, channelId, text, parent) }
}
},
nav = nav,
onMessageSent = { feedViewModel.feedState.sendToTop() },
)
}
}
@@ -150,53 +147,69 @@ fun ConcordChannelScreen(
}
@Composable
private fun ConcordComposer(
draft: String,
replyingTo: Note?,
private fun ConcordMessageComposer(
newMessageModel: ConcordNewMessageViewModel,
accountViewModel: AccountViewModel,
onDraftChange: (String) -> Unit,
onCancelReply: () -> Unit,
onSend: () -> Unit,
nav: INav,
onMessageSent: suspend () -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
if (replyingTo != null) {
val name by observeUserName(remember(replyingTo) { replyingTo.author ?: LocalCache.getOrCreateUser(replyingTo.event?.pubKey ?: "") }, accountViewModel)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$name: ${replyingTo.event?.content?.take(80).orEmpty()}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
maxLines = 1,
)
IconButton(onClick = onCancelReply) {
SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.cancel))
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = draft,
onValueChange = onDraftChange,
modifier = Modifier.weight(1f),
placeholder = { Text(stringRes(com.vitorpamplona.amethyst.R.string.reply_here)) },
maxLines = 5,
val scope = rememberCoroutineScope()
val canPost by remember { derivedStateOf { newMessageModel.canPost() } }
val context = LocalContext.current
DisposableEffect(newMessageModel.channelId) {
onDispose { newMessageModel.userSuggestions?.reset() }
}
newMessageModel.replyTo.value?.let {
DisplayReplyingToNote(it, accountViewModel, nav) { newMessageModel.clearReply() }
}
Column(modifier = EditFieldModifier) {
newMessageModel.userSuggestions?.let {
ShowUserSuggestionList(
it,
newMessageModel::autocompleteWithUser,
accountViewModel,
SuggestionListDefaultHeightChat,
)
Box(Modifier.padding(start = 6.dp)) {
IconButton(onClick = onSend, enabled = draft.isNotBlank()) {
SymbolIcon(
symbol = MaterialSymbols.AutoMirrored.Send,
contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.send),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
ThinPaddingTextField(
state = newMessageModel.message,
onTextChanged = { newMessageModel.onMessageChanged() },
inputTransformation = MentionPreservingInputTransformation,
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth(),
shape = EditFieldBorder,
placeholder = {
Text(
text = stringRes(com.vitorpamplona.amethyst.R.string.reply_here),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
ThinSendButton(
isActive = canPost,
modifier = EditFieldTrailingIconModifier,
) {
scope.launch(Dispatchers.IO) {
try {
newMessageModel.sendPost()
onMessageSent()
} 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,
),
)
}
}
@@ -0,0 +1,143 @@
/*
* 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.publicChannels.concord.send
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Composition state for the Concord channel message field, mirroring the other
* chat composers (MarmotNewMessageViewModel, ChannelNewMessageViewModel):
* @-mention user suggestions (avatar + name + NIP-05 dropdown) and reply state.
* Sending routes through [Account.sendConcordChannelMessage], which wraps the
* message on the channel plane. The typed text already carries `nostr:npub…`
* mentions inline (rewritten by [UserSuggestionState.replaceCurrentWord]).
*/
@Stable
open class ConcordNewMessageViewModel : ViewModel() {
lateinit var accountViewModel: AccountViewModel
lateinit var account: Account
var communityId: HexKey? = null
var channelId: HexKey? = null
val message = TextFieldState()
val replyTo = mutableStateOf<Note?>(null)
var userSuggestions: UserSuggestionState? = null
open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
this.userSuggestions?.reset()
this.userSuggestions =
UserSuggestionState(
accountVM.account,
accountVM.nip05ClientBuilder(),
// Rank people who have posted in this channel first.
priorityPubkeys = { channelAuthors() },
)
}
private fun channelAuthors(): Set<HexKey> {
val community = communityId ?: return emptySet()
val channel = channelId ?: return emptySet()
return LocalCache
.getConcordChannelIfExists(ConcordChannelId(community, channel))
?.notes
?.mapNotNull { _, note -> note.author?.pubkeyHex }
?.toSet()
?: emptySet()
}
open fun load(
communityId: HexKey,
channelId: HexKey,
) {
if (this.communityId != communityId || this.channelId != channelId) {
this.communityId = communityId
this.channelId = channelId
this.message.clearText()
this.replyTo.value = null
}
}
fun reply(note: Note) {
replyTo.value = note
}
fun clearReply() {
replyTo.value = null
}
fun editFromDraft(draftMessage: String) {
message.setTextAndPlaceCursorAtEnd(draftMessage)
}
fun canPost() = message.text.isNotBlank()
fun onMessageChanged() {
if (message.selection.collapsed) {
val lastWord = message.currentWord()
if (lastWord.startsWith("@")) {
userSuggestions?.processCurrentWord(lastWord)
} else {
userSuggestions?.reset()
}
}
}
fun autocompleteWithUser(item: User) {
userSuggestions?.let {
it.replaceCurrentWord(message, message.currentWord(), item)
it.reset()
}
}
/** Sends the field's text as a channel message (or a reply). Throws on failure. */
suspend fun sendPost() {
val community = communityId ?: return
val channel = channelId ?: return
val text = message.text.toString().trim()
if (text.isEmpty()) return
val parent = replyTo.value
account.sendConcordChannelMessage(community, channel, text, parent)
message.clearText()
replyTo.value = null
userSuggestions?.reset()
}
}