feat(cordn): shared composer field, send button, @ autocomplete and chat scaffold

The row moved onto ChatBubbleLayout last commit; the composer was still a
bare OutlinedTextField over a String with two IconButtons, and the screen
was still on a plain Scaffold.

Composer, now on the same field every other chat uses:

  - ThinPaddingTextField + ThinSendButton inside EditFieldModifier.
  - ShowUserSuggestionList for `@` autocomplete. This closes a
    half-built feature rather than restyling one: cordn already parsed
    mentions, rendered them as names, and tinted a message that named
    you — but the only way to enter one was to type `nostr:npub1…` by
    hand. Room members rank first and the shared list badges them "in
    this chat" for free.
  - MentionPreservingInputTransformation, so an IME cannot rewrite half
    a bech32, and UrlUserTagOutputTransformation, so a mention reads as
    a name while you type it. Both match on `@`|`nostr:`, so they work
    on cordn's URI form unchanged.
  - onContentReceived, so a GIF keyboard or a paste takes the same
    encrypted-upload path as the file picker.

Mentions insert the resolved `nostr:` URI, via a new forceNostrUri flag
on UserSuggestionState.replaceCurrentWord (default off, so every
existing caller is untouched). The `@npub1…` short form is only half a
mention: it relies on a send-time tagger to rewrite it and emit the `p`
tag. cordn has none — content goes into a sealed envelope verbatim and
its reader parses `nostr:` URIs out of that — so the short form would
have shipped as literal text and mentioned nobody.

commons owns the draft and may not depend on Compose UI, so it cannot
hold a TextFieldState. The field owns text and selection; two effects
keep it equal to `room.draft`, each guarded on inequality so neither
direction bounces off the other. The screen's three external writes
(clear on send, restore on failure, load a message to edit) all land
through it. Send passes the field's own text rather than letting the
screen read `room.draft`, which settles a frame later.

Screen, now on DisappearingScaffold(isInvertedLayout = true,
allowBarHide = false) like every other chat screen. The bare Scaffold
provided neither of the two things it brings: the bars' scroll
behaviour, and the IME inset — without which the composer sat under the
soft keyboard. allowBarHide is false to match the others; chat screens
in this app pin their chrome.

Also: the top bar had no back button at all. It has one now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-24 13:18:05 +00:00
parent f35e46fd8f
commit 5808747fa4
3 changed files with 239 additions and 43 deletions
@@ -254,8 +254,18 @@ class UserSuggestionState(
state: TextFieldState,
word: String,
item: User,
/**
* Always insert the resolved `nostr:` URI, never the `@npub1…` short form.
*
* The short form is only half a mention: it relies on a send-time tagger to
* rewrite it and emit the `p` tag. Surfaces that have one (every nostr-event
* composer) keep it. cordn does not — its content goes into a sealed envelope
* verbatim, and its reader parses `nostr:` URIs out of that content — so an
* `@npub1…` there would ship as literal text and mention nobody.
*/
forceNostrUri: Boolean = false,
) {
val wordToInsert = mentionInsertion(word, item)
val wordToInsert = mentionInsertion(word, item, forceNostrUri)
state.edit {
val lastWordStart = selection.end - word.length
replace(lastWordStart, selection.end, wordToInsert)
@@ -280,7 +290,10 @@ class UserSuggestionState(
private fun mentionInsertion(
word: String,
item: User,
forceNostrUri: Boolean = false,
): String {
if (forceNostrUri) return "nostr:${item.toNProfile()} "
val typed = userSearchTermOrNull(word)
val wasNip05Mention =
typed != null &&
@@ -0,0 +1,198 @@
/*
* 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.cordnGroup
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.chats.ui.ThinSendButton
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.commons.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* The cordn room's composer, on the same field every other Amethyst chat uses.
*
* [ThinPaddingTextField] + [ThinSendButton] inside [EditFieldModifier], with the shared
* mention machinery on top: [ShowUserSuggestionList] for `@` autocomplete,
* [MentionPreservingInputTransformation] so an IME cannot rewrite half a bech32, and
* [UrlUserTagOutputTransformation] so a pasted or completed mention reads as a name
* while you type it. Before this the room had a bare `OutlinedTextField` over a
* `String`: cordn could *render* mentions and highlight a message that named you, but
* the only way to enter one was to type `nostr:npub1…` by hand.
*
* Mentions are inserted as resolved `nostr:` URIs (`forceNostrUri`). See the parameter's
* KDoc — cordn has no send-time tagger, so the short `@npub1…` form would ship as text.
*
* Looking users up to offer them is the same safe direction as everywhere else on this
* screen: profiles are public relay data the cache already holds, and reading one puts
* no part of this conversation into it.
*/
@Composable
internal fun CordnComposer(
room: CordnGroupChatroom,
attaching: Boolean,
accountViewModel: AccountViewModel,
onAttach: (Uri) -> Unit,
onVoiceNote: (RecordingResult) -> Unit,
onSend: (String) -> Unit,
) {
// `room.draft` is the persisted String — commons holds the draft and may not depend
// on Compose UI, so it cannot hold a TextFieldState. The field owns text *and*
// selection; these two effects keep them equal. Both writes are guarded on
// inequality, so neither direction can bounce off the other.
val draftState = remember(room.gid) { TextFieldState(room.draft.value) }
LaunchedEffect(draftState, room) {
snapshotFlow { draftState.text.toString() }.collect {
if (room.draft.value != it) room.draft.value = it
}
}
LaunchedEffect(draftState, room) {
room.draft.collect { external ->
// The screen writes the draft from outside on three paths: clearing it on
// send, restoring it when a send fails, and loading a message's text into
// it to edit. Cursor to the end, as editFromDraft does elsewhere.
if (external != draftState.text.toString()) {
if (external.isEmpty()) draftState.clearText() else draftState.setTextAndPlaceCursorAtEnd(external)
}
}
}
val suggestions =
remember(room.gid, accountViewModel) {
UserSuggestionState(
accountViewModel.account,
accountViewModel.nip05ClientBuilder(),
// Members of this room rank above the rest of the address book: in a
// group, the person you are about to name is almost always in it.
priorityPubkeys = { room.members.value.toSet() },
)
}
DisposableEffect(suggestions) {
onDispose { suggestions.reset() }
}
val canPost by remember { derivedStateOf { draftState.text.isNotBlank() } }
val picker =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let(onAttach)
}
Column(modifier = EditFieldModifier) {
ShowUserSuggestionList(
suggestions,
onSelect = { user ->
suggestions.replaceCurrentWord(draftState, draftState.currentWord(), user, forceNostrUri = true)
suggestions.reset()
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightChat,
)
ThinPaddingTextField(
state = draftState,
onTextChanged = {
// Only while the caret is a point: during a range selection the "current
// word" is whatever is highlighted, which is not something being typed.
if (draftState.selection.collapsed) {
val lastWord = draftState.currentWord()
if (lastWord.startsWith("@")) {
suggestions.processCurrentWord(lastWord)
} else {
suggestions.reset()
}
}
},
// Anything the keyboard or a paste hands over as content — a GIF, a shared
// image — takes the same encrypted-upload path as the file picker.
onContentReceived = { uri, _ -> onAttach(uri) },
inputTransformation = MentionPreservingInputTransformation,
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth(),
shape = EditFieldBorder,
placeholder = {
Text(
text = stringRes(R.string.cordn_composer_hint),
color = MaterialTheme.colorScheme.placeholderText,
)
},
leadingIcon = {
Row {
IconButton(onClick = { picker.launch("*/*") }, enabled = !attaching) {
Icon(MaterialSymbols.AttachFile, contentDescription = stringRes(R.string.cordn_media_attach))
}
VoiceNoteButton(enabled = !attaching, onRecorded = onVoiceNote)
}
},
trailingIcon = {
ThinSendButton(
isActive = canPost,
modifier = EditFieldTrailingIconModifier,
) {
onSend(draftState.text.toString())
}
},
colors =
TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
)
}
}
@@ -48,8 +48,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -80,6 +78,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -87,6 +86,7 @@ import com.vitorpamplona.amethyst.model.cordn.CordnMediaService
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessageRecorder
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.cordn.appEncryptedMedia.CordnBlobUpload
@@ -228,13 +228,20 @@ private fun CordnGroupChat(
room.markRead()
}
Scaffold(
// The same scaffold every other chat screen uses. A bare Scaffold gave this room
// neither of the two things it provides: the bars' scroll behaviour, and the IME
// inset — without which the composer sat *under* the soft keyboard.
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
CordnChatTopBar(
title = name?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.cordn_group_untitled, room.gid.take(8)),
onBack = { nav.popBack() },
onInfo = { nav.nav(Route.CordnGroupInfo(room.coordinatorPubKey, room.gid)) },
)
},
accountViewModel = accountViewModel,
allowBarHide = false,
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
// One clock for every divider in the room, so they cannot disagree.
@@ -367,7 +374,8 @@ private fun CordnGroupChat(
}
CordnComposer(
draft = draft,
room = room,
accountViewModel = accountViewModel,
onAttach = { uri ->
scope.launch {
attaching = true
@@ -395,9 +403,11 @@ private fun CordnGroupChat(
}
},
attaching = attaching,
onDraftChange = { room.draft.value = it },
onSend = {
val text = draft.trim()
// The field hands its own text over rather than the screen reading
// `room.draft`: the two are kept in step by a snapshot collector, which
// settles a frame later, and a send must use what is on screen now.
onSend = { typed ->
val text = typed.trim()
if (text.isEmpty()) return@CordnComposer
val reply = replyingTo
@@ -504,10 +514,19 @@ private fun ComposerBanner(
@Composable
private fun CordnChatTopBar(
title: String,
onBack: () -> Unit,
onInfo: () -> Unit,
) {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(Res.string.back),
)
}
},
actions = {
IconButton(onClick = onInfo) {
Icon(MaterialSymbols.Info, contentDescription = stringRes(R.string.cordn_group_info))
@@ -516,40 +535,6 @@ private fun CordnChatTopBar(
)
}
@Composable
private fun CordnComposer(
draft: String,
attaching: Boolean,
onAttach: (Uri) -> Unit,
onVoiceNote: (RecordingResult) -> Unit,
onDraftChange: (String) -> Unit,
onSend: () -> Unit,
) {
val picker =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let(onAttach)
}
Row(
Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { picker.launch("*/*") }, enabled = !attaching) {
Icon(MaterialSymbols.AttachFile, contentDescription = stringRes(R.string.cordn_media_attach))
}
VoiceNoteButton(enabled = !attaching, onRecorded = onVoiceNote)
OutlinedTextField(
value = draft,
onValueChange = onDraftChange,
placeholder = { Text(stringRes(R.string.cordn_composer_hint)) },
modifier = Modifier.weight(1f),
)
IconButton(onClick = onSend, enabled = draft.isNotBlank()) {
Icon(MaterialSymbols.AutoMirrored.Send, contentDescription = stringRes(R.string.cordn_send))
}
}
}
/**
* A message's text, with `nostr:` mentions rendered as names.
*
@@ -722,7 +707,7 @@ private fun ByteArray.toImageBitmapOrNull(): ImageBitmap? =
* that appears before anyone reached for it trains people to dismiss it.
*/
@Composable
private fun VoiceNoteButton(
internal fun VoiceNoteButton(
enabled: Boolean,
onRecorded: (RecordingResult) -> Unit,
) {