diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 94534ff21a..764d734442 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -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 && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt new file mode 100644 index 0000000000..285248a232 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnComposer.kt @@ -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, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt index b7c2417f33..f32ce5d320 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt @@ -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, ) {