Three rough edges in the in-room chat panel:
3. Send swallowed broadcast failures and cleared the draft
unconditionally. An offline user typed, tapped Send, watched
the text vanish, and never saw a toast — the message landed
nowhere. Now the draft clears ONLY on success; failure toasts
`audio_room_chat_send_failed_title` with the exception
message and keeps the text in the field for retry. Composer
also disables itself for the brief in-flight window so a
double-tap can't fire two sends.
4. Auto-scroll forced to bottom on every new message, even
while the user was reading older messages. Switched to the
standard "only scroll if pinned near the bottom" pattern via
a derivedStateOf gate over `listState.firstVisibleItemIndex`.
+ Replaced the v1 placeholder (truncated 8-hex of the pubkey)
with the canonical Amethyst chat author-name pattern:
LoadUser kicks the metadata fetch, UsernameDisplay observes
the kind-0 flow, and the result falls back to a truncated
npub while the metadata is in flight or missing. Same code
path RenderChatClip / RenderChatRaid use.
This commit is contained in:
+57
-11
@@ -38,6 +38,7 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -45,11 +46,14 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel
|
import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
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.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||||
@@ -74,9 +78,21 @@ internal fun AudioRoomChatPanel(
|
|||||||
val messages by viewModel.chat.collectAsState()
|
val messages by viewModel.chat.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
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) {
|
LaunchedEffect(messages.size) {
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty() && isAtBottom) {
|
||||||
listState.animateScrollToItem(messages.size - 1)
|
listState.animateScrollToItem(messages.size - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,14 +138,22 @@ private fun ChatRow(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
// Truncated pubkey as the inline name placeholder. A future
|
// Same author-name pattern the rest of Amethyst's chat
|
||||||
// pass can resolve to the user's NIP-01 display name; for
|
// surfaces use — LoadUser kicks the metadata fetch and
|
||||||
// the v1 chat panel we keep the dependency surface tight.
|
// UsernameDisplay observes its kind-0 flow, falling back
|
||||||
Text(
|
// to a truncated npub while the metadata is in flight or
|
||||||
text = msg.pubKey.take(8),
|
// missing. Replaces the "first 8 hex chars of pubkey"
|
||||||
style = MaterialTheme.typography.labelMedium,
|
// placeholder the v1 chat panel shipped with.
|
||||||
color = MaterialTheme.colorScheme.primary,
|
LoadUser(baseUserHex = msg.pubKey, accountViewModel = accountViewModel) { user ->
|
||||||
|
if (user != null) {
|
||||||
|
UsernameDisplay(
|
||||||
|
baseUser = user,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textColor = MaterialTheme.colorScheme.primary,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = msg.content,
|
text = msg.content,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -144,6 +168,7 @@ private fun ChatComposer(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
) {
|
) {
|
||||||
var draft by remember { mutableStateOf("") }
|
var draft by remember { mutableStateOf("") }
|
||||||
|
var isSending by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -156,15 +181,17 @@ private fun ChatComposer(
|
|||||||
placeholder = { Text(stringRes(R.string.audio_room_chat_placeholder)) },
|
placeholder = { Text(stringRes(R.string.audio_room_chat_placeholder)) },
|
||||||
singleLine = false,
|
singleLine = false,
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
|
enabled = !isSending,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
enabled = draft.isNotBlank(),
|
enabled = draft.isNotBlank() && !isSending,
|
||||||
onClick = {
|
onClick = {
|
||||||
val toSend = draft.trim()
|
val toSend = draft.trim()
|
||||||
if (toSend.isEmpty()) return@TextButton
|
if (toSend.isEmpty()) return@TextButton
|
||||||
draft = ""
|
isSending = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
val result =
|
||||||
runCatching {
|
runCatching {
|
||||||
accountViewModel.account.signAndComputeBroadcast(
|
accountViewModel.account.signAndComputeBroadcast(
|
||||||
LiveActivitiesChatMessageEvent.message(
|
LiveActivitiesChatMessageEvent.message(
|
||||||
@@ -173,6 +200,23 @@ private fun ChatComposer(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
|||||||
@@ -504,6 +504,12 @@
|
|||||||
<string name="audio_room_connected">Audio connected</string>
|
<string name="audio_room_connected">Audio connected</string>
|
||||||
<string name="audio_room_reconnecting">Reconnecting…</string>
|
<string name="audio_room_reconnecting">Reconnecting…</string>
|
||||||
<string name="audio_room_listen_to_recording">Listen to recording</string>
|
<string name="audio_room_listen_to_recording">Listen to recording</string>
|
||||||
|
<string name="audio_room_chat_send_failed_title">Couldn\'t send message</string>
|
||||||
|
<string name="audio_room_no_app_to_open_link">No app installed to open this link.</string>
|
||||||
|
<string name="audio_room_close_room_confirm_title">Close this room?</string>
|
||||||
|
<string name="audio_room_close_room_confirm_body">All attendees will be disconnected. The room will show as CLOSED in the feed.</string>
|
||||||
|
<string name="audio_room_close_room_confirm_action">Close room</string>
|
||||||
|
<string name="audio_room_create_when_in_past">Pick a future start time.</string>
|
||||||
<string name="audio_room_audio_failed">Audio failed: %1$s</string>
|
<string name="audio_room_audio_failed">Audio failed: %1$s</string>
|
||||||
<string name="audio_room_audio_unavailable">Audio is not available for this room</string>
|
<string name="audio_room_audio_unavailable">Audio is not available for this room</string>
|
||||||
<string name="audio_room_talk">Talk</string>
|
<string name="audio_room_talk">Talk</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user