diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomChatPanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomChatPanel.kt index 2637ada99..7d9692ef5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomChatPanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomChatPanel.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,11 +46,14 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -74,9 +78,21 @@ internal fun AudioRoomChatPanel( val messages by viewModel.chat.collectAsState() val listState = rememberLazyListState() - // Auto-scroll to bottom on new message arrival. + // Only auto-scroll when the user is already pinned near the + // bottom — otherwise a new message yanks them away from the + // history they're scrolling through. Standard chat-app pattern. + val isAtBottom by remember(messages.size) { + derivedStateOf { + val lastIdx = messages.lastIndex + if (lastIdx < 0) { + true + } else { + listState.firstVisibleItemIndex >= (lastIdx - PINNED_TO_BOTTOM_TOLERANCE) + } + } + } LaunchedEffect(messages.size) { - if (messages.isNotEmpty()) { + if (messages.isNotEmpty() && isAtBottom) { listState.animateScrollToItem(messages.size - 1) } } @@ -122,14 +138,22 @@ private fun ChatRow( ) Spacer(Modifier.width(8.dp)) Column(modifier = Modifier.fillMaxWidth()) { - // Truncated pubkey as the inline name placeholder. A future - // pass can resolve to the user's NIP-01 display name; for - // the v1 chat panel we keep the dependency surface tight. - Text( - text = msg.pubKey.take(8), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) + // Same author-name pattern the rest of Amethyst's chat + // surfaces use — LoadUser kicks the metadata fetch and + // UsernameDisplay observes its kind-0 flow, falling back + // to a truncated npub while the metadata is in flight or + // missing. Replaces the "first 8 hex chars of pubkey" + // placeholder the v1 chat panel shipped with. + LoadUser(baseUserHex = msg.pubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Bold, + textColor = MaterialTheme.colorScheme.primary, + accountViewModel = accountViewModel, + ) + } + } Text( text = msg.content, style = MaterialTheme.typography.bodyMedium, @@ -144,6 +168,7 @@ private fun ChatComposer( accountViewModel: AccountViewModel, ) { var draft by remember { mutableStateOf("") } + var isSending by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() Row( modifier = Modifier.fillMaxWidth(), @@ -156,21 +181,40 @@ private fun ChatComposer( placeholder = { Text(stringRes(R.string.audio_room_chat_placeholder)) }, singleLine = false, maxLines = 4, + enabled = !isSending, ) Spacer(Modifier.width(8.dp)) TextButton( - enabled = draft.isNotBlank(), + enabled = draft.isNotBlank() && !isSending, onClick = { val toSend = draft.trim() if (toSend.isEmpty()) return@TextButton - draft = "" + isSending = true scope.launch { - runCatching { - accountViewModel.account.signAndComputeBroadcast( - LiveActivitiesChatMessageEvent.message( - post = toSend, - activity = roomATag, - ), + val result = + runCatching { + accountViewModel.account.signAndComputeBroadcast( + LiveActivitiesChatMessageEvent.message( + post = toSend, + activity = roomATag, + ), + ) + } + isSending = false + if (result.isSuccess) { + // Clear ONLY on success so a network failure + // doesn't lose the user's text — they can retry + // without retyping. + draft = "" + } else { + val why = + result.exceptionOrNull()?.message + ?: result.exceptionOrNull()?.let { it::class.simpleName } + ?: "unknown error" + accountViewModel.toastManager.toast( + R.string.audio_room_chat_send_failed_title, + why, + user = null, ) } } @@ -180,3 +224,5 @@ private fun ChatComposer( } } } + +private const val PINNED_TO_BOTTOM_TOLERANCE = 1 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b1446d939..5da4e92dc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -504,6 +504,12 @@ Audio connected Reconnecting… Listen to recording + Couldn\'t send message + No app installed to open this link. + Close this room? + All attendees will be disconnected. The room will show as CLOSED in the feed. + Close room + Pick a future start time. Audio failed: %1$s Audio is not available for this room Talk