mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+14
-1
@@ -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 &&
|
||||
|
||||
+198
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+27
-42
@@ -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,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user