diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt index 1ff53d481..80ab4b2ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt @@ -38,6 +38,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.StringResSetup +import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.screen.ManageRelayServices import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp import com.vitorpamplona.amethyst.ui.theme.AmethystTheme @@ -190,6 +192,24 @@ class NestActivity : AppCompatActivity() { onLeave = { finish() }, onMinimize = { enterPip() }, ) + + // Mirror MainActivity's `AppNavigation` → render toasts + // inside *this* Activity's Compose tree so promote / + // demote / kick / room-edit results actually surface in + // front of the room UI. Without this, every + // `toastManager.toast(...)` from nest code queues into a + // StateFlow whose only collector lives in MainActivity — + // the dialog only becomes visible after the user PIPs out. + // EmptyNav is the documented stub for hosts that have no + // navigation graph; the only consumer that reads `nav` + // is `MultiErrorToastMsg` (zap-error aggregator) — nest + // call sites emit `ResourceToastMsg` / `StringToastMsg` + // which don't touch nav. + DisplayErrorMessages( + toastManager = accountViewModel.toastManager, + accountViewModel = accountViewModel, + nav = EmptyNav(), + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt index 28a889ff0..e82ce21db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt @@ -251,6 +251,14 @@ private fun NestActivityBody( participants = event.participants(), presences = presences, hostPubkey = event.pubKey, + // Treat presence as authoritative only when fresher + // than the role grant. nostrnests audience members + // emit kind-10312 with `onstage=0` and never flip + // it back on after a promotion — without this gate, + // a freshly-promoted speaker would render in the + // audience tab with role=SPEAKER, looking unchanged + // to the host who just promoted them. + roleGrantSec = event.createdAt, ) } val onStageKeys = @@ -282,6 +290,19 @@ private fun NestActivityBody( } } + // Re-sync the optimistic local intent flag with the role-derived + // on-stage state whenever the user becomes on stage. Symmetric to + // the auto-stop above: when `isOnStageMe` flips to true (initial + // promotion or a re-promotion after a previous voluntary leave), + // also flip `ui.onStageNow` back to true so [StageControlsBar]'s + // `!ui.onStageNow` gate doesn't hide the talk button when their + // role says they CAN talk. Without this, a promoted speaker + // appeared on the stage grid but saw no speaker controls + // — confirmed during testing on 2026-05-13. + LaunchedEffect(isOnStageMe) { + if (isOnStageMe) viewModel.setOnStage(true) + } + // Single REQ per relay covering chat, presence, reactions, admin // commands. See NestRoomFilterAssembler for the filter shape. NestRoomFilterAssemblerSubscription(roomNote, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestSheet.kt index 8227f9519..494b1b938 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestSheet.kt @@ -26,9 +26,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -108,60 +111,80 @@ fun EditNestSheet( ) } ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + // Two-region layout so the keyboard never squashes the action + // row: the form scrolls inside its own viewport, the bottom + // buttons stay anchored. `imePadding()` on the outer Column + // lifts the whole sheet above the keyboard, so the anchored + // row sits just above the IME instead of behind it. Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().imePadding(), ) { - Text( - text = stringRes(R.string.nest_edit_title), - style = MaterialTheme.typography.titleLarge, - ) - - OutlinedTextField( - value = state.roomName, - onValueChange = viewModel::setRoomName, - label = { Text(stringRes(R.string.nest_create_field_room)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.summary, - onValueChange = viewModel::setSummary, - label = { Text(stringRes(R.string.nest_create_field_summary)) }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.serviceUrl, - onValueChange = viewModel::setServiceUrl, - label = { Text(stringRes(R.string.nest_create_field_service)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.endpointUrl, - onValueChange = viewModel::setEndpointUrl, - label = { Text(stringRes(R.string.nest_create_field_endpoint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.imageUrl, - onValueChange = viewModel::setImageUrl, - label = { Text(stringRes(R.string.nest_create_field_image)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - state.error?.let { error -> + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { Text( - text = error, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, + text = stringRes(R.string.nest_edit_title), + style = MaterialTheme.typography.titleLarge, ) - } - Spacer(Modifier.height(4.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OutlinedTextField( + value = state.roomName, + onValueChange = viewModel::setRoomName, + label = { Text(stringRes(R.string.nest_create_field_room)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.summary, + onValueChange = viewModel::setSummary, + label = { Text(stringRes(R.string.nest_create_field_summary)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.serviceUrl, + onValueChange = viewModel::setServiceUrl, + label = { Text(stringRes(R.string.nest_create_field_service)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.endpointUrl, + onValueChange = viewModel::setEndpointUrl, + label = { Text(stringRes(R.string.nest_create_field_endpoint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = state.imageUrl, + onValueChange = viewModel::setImageUrl, + label = { Text(stringRes(R.string.nest_create_field_image)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + state.error?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(Modifier.height(4.dp)) + } + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { // Destructive — flips status to CLOSED with the same d-tag // so subscribers see the room go dark. Confirm prompt // gates the actual publish so a misclick on the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt index 9d3dfa4fa..fc7dadce0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog @@ -71,6 +72,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * Per-participant context sheet (T2 #2). Long-press on any participant @@ -100,12 +104,12 @@ internal fun ParticipantHostActionsSheet( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val targetUser = remember(target) { LocalCache.getOrCreateUser(target) } - // Reactive display name — UsernameDisplay observes this for the - // header, and we reuse the same flow for toast text so the header - // and toast never disagree (e.g. metadata arriving mid-sheet). + // Reactive display name for confirmation-dialog bodies (kick / + // force-mute) — `observeUserInfo` lets the dialog text track a + // late-arriving metadata event without re-opening the sheet. val userInfo by observeUserInfo(targetUser, accountViewModel) val displayName = userInfo?.info?.bestName() ?: targetUser.pubkeyDisplayHex() - val toasts = rememberParticipantToasts(displayName) + val toasts = rememberParticipantToasts() var openDialog by rememberSaveable(stateSaver = SheetDialogSaver) { mutableStateOf(null) @@ -174,7 +178,6 @@ internal fun ParticipantHostActionsSheet( // so future presence events don't re-render them broadcast(accountViewModel, AdminCommandEvent.kick(roomATag, target)) broadcast(accountViewModel, RoomParticipantActions.removeParticipant(event, target)) - accountViewModel.toastManager.toast(toasts.title, toasts.kickSent) onDismiss() }, onDismiss = { openDialog = null }, @@ -190,7 +193,6 @@ internal fun ParticipantHostActionsSheet( openDialog = null val roomATag = ATag(event.kind, event.pubKey, event.dTag(), null) broadcast(accountViewModel, AdminCommandEvent.forceMute(roomATag, target)) - accountViewModel.toastManager.toast(toasts.title, toasts.forceMuteSent) onDismiss() }, onDismiss = { openDialog = null }, @@ -273,10 +275,8 @@ private fun AudienceActions( ) { if (isFollowing) { accountViewModel.unfollow(targetUser) - accountViewModel.toastManager.toast(toasts.title, toasts.unfollowSent) } else { accountViewModel.follow(targetUser) - accountViewModel.toastManager.toast(toasts.title, toasts.followSent) } onDismiss() } @@ -289,10 +289,8 @@ private fun AudienceActions( ) { if (isHidden) { accountViewModel.show(targetUser) - accountViewModel.toastManager.toast(toasts.title, toasts.unmuteSent) } else { accountViewModel.hide(targetUser) - accountViewModel.toastManager.toast(toasts.title, toasts.muteSent) } onDismiss() } @@ -337,13 +335,22 @@ private fun HostActions( val nestPresences by nestViewModel.presences.collectAsState() val targetIsBroadcasting = nestPresences[target]?.publishing == true - fun hostAction( - template: EventTemplate?, - toastMsg: String, - ) { - broadcast(accountViewModel, template) - accountViewModel.toastManager.toast(toasts.title, toastMsg) + fun hostAction(template: EventTemplate?) { + // Sign+publish runs inside a coroutine that surfaces ANY + // failure as a toast. Success is confirmed by the UI moving + // the target between stage/audience (or removing them), so + // no success toast is needed — the previous "Promoted X" + // toast was redundant once the kind-30312 + stale-presence + // fixes made the grid update visible. Silent signer failures + // (NIP-46 timeout, manual denial, etc.) still need to + // surface because they're invisible from the host's POV. + if (template == null) { + accountViewModel.toastManager.toast(toasts.failedTitle, toasts.failedTemplateNull) + onDismiss() + return + } onDismiss() + broadcastWithDiagnostics(accountViewModel, template, toasts.failedTitle) } Spacer(Modifier.height(4.dp)) @@ -353,12 +360,12 @@ private fun HostActions( // wasted relay traffic and an obvious UX tell. if (targetRole != ROLE.SPEAKER) { ActionRow(stringRes(R.string.nest_promote_speaker)) { - hostAction(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER), toasts.promoteSpeakerSent) + hostAction(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER)) } } if (targetRole != ROLE.MODERATOR) { ActionRow(stringRes(R.string.nest_promote_moderator)) { - hostAction(RoomParticipantActions.setRole(event, target, ROLE.MODERATOR), toasts.promoteModeratorSent) + hostAction(RoomParticipantActions.setRole(event, target, ROLE.MODERATOR)) } } // Demote only when the target actually has a role above listener — @@ -366,7 +373,7 @@ private fun HostActions( // and the visible row sets up a misleading expectation. if (targetRole != null && targetRole != ROLE.PARTICIPANT) { ActionRow(stringRes(R.string.nest_demote_listener)) { - hostAction(RoomParticipantActions.demoteToListener(event, target), toasts.demoteListenerSent) + hostAction(RoomParticipantActions.demoteToListener(event, target)) } } // Force-mute is honor-based: the target's client must honor the @@ -498,6 +505,36 @@ private fun broadcast( accountViewModel.launchSigner { accountViewModel.account.signAndComputeBroadcast(template) } } +/** + * Sign+publish a host-action template and surface only FAILURES as + * a toast. Success is implicit — the UI updates when the kind-30312 + * round-trips, so a success toast would be redundant. Any throw + * (including the silent ones [launchSigner] Log.w-only's — + * `TimedOutException`, `ManuallyUnauthorizedException`, + * `CouldNotPerformException`, + * `RunningOnBackgroundWithoutAutomaticPermissionException`, + * `AutomaticallyUnauthorizedException`) becomes a visible error + * toast with the exception's class name + message so the host knows + * a remote-signer denial happened. + */ +private fun broadcastWithDiagnostics( + accountViewModel: AccountViewModel, + template: EventTemplate, + failedTitle: String, +) { + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + try { + accountViewModel.account.signAndComputeBroadcast(template) + } catch (ce: CancellationException) { + throw ce + } catch (e: Throwable) { + val klass = e::class.simpleName ?: "Throwable" + val msg = e.message?.takeIf { it.isNotBlank() } ?: "no message" + accountViewModel.toastManager.toast(failedTitle, "$klass: $msg") + } + } +} + private fun openProfileInMainActivity( context: Context, target: String, @@ -545,33 +582,17 @@ private val SheetDialogSaver: Saver = * (which are not @Composable) read them by reference. */ private data class ParticipantToasts( - val title: String, - val followSent: String, - val unfollowSent: String, - val muteSent: String, - val unmuteSent: String, - val promoteSpeakerSent: String, - val promoteModeratorSent: String, - val demoteListenerSent: String, - val forceMuteSent: String, - val kickSent: String, + val failedTitle: String, + val failedTemplateNull: String, val noAppMessage: String, val zapSplitUnsupported: String, ) @Composable -private fun rememberParticipantToasts(displayName: String): ParticipantToasts = +private fun rememberParticipantToasts(): ParticipantToasts = ParticipantToasts( - title = stringRes(R.string.nest_toast_action_title), - followSent = stringRes(R.string.nest_toast_follow_sent, displayName), - unfollowSent = stringRes(R.string.nest_toast_unfollow_sent, displayName), - muteSent = stringRes(R.string.nest_toast_mute_sent, displayName), - unmuteSent = stringRes(R.string.nest_toast_unmute_sent, displayName), - promoteSpeakerSent = stringRes(R.string.nest_toast_promote_speaker_sent, displayName), - promoteModeratorSent = stringRes(R.string.nest_toast_promote_moderator_sent, displayName), - demoteListenerSent = stringRes(R.string.nest_toast_demote_listener_sent, displayName), - forceMuteSent = stringRes(R.string.nest_toast_force_mute_sent, displayName), - kickSent = stringRes(R.string.nest_toast_kick_sent, displayName), + failedTitle = stringRes(R.string.nest_toast_host_action_failed_title), + failedTemplateNull = stringRes(R.string.nest_toast_host_action_failed_template_null), noAppMessage = stringRes(R.string.nest_no_app_to_open_link), zapSplitUnsupported = stringRes(R.string.nest_participant_zap_split_unsupported), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt index c7a396081..b28c392e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.summary import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE +import com.vitorpamplona.quartz.utils.TimeUtils /** * Pure builders for participant-list mutations on a kind-30312 @@ -132,12 +133,25 @@ internal object RoomParticipantActions { ?: ParticipantTag(original.pubKey, null, ROLE.HOST.code, null) val others = participants.filterNot { it.pubKey == host.pubKey } + // Strictly monotonic createdAt. NIP-01 replaceable-event + // semantics drop a new version whose createdAt is <= the + // previous one (with a lower-id tie-break that's effectively + // random under signing). Creating a room then immediately + // promoting in the same wall-clock second produced the + // dreaded silent-no-op: the local cache rejected the new + // event in `consumeBaseReplaceable` (strict `>`) and relays + // applied the same rule. `+ 1` is enough to win the tie; + // `coerceAtLeast(now)` keeps the wire timestamp accurate + // when the original is already in the past. + val nextCreatedAt = (original.createdAt + 1L).coerceAtLeast(TimeUtils.now()) + return MeetingSpaceEvent.build( room = original.room().orEmpty(), status = status, service = original.service().orEmpty(), host = host, dTag = original.dTag(), + createdAt = nextCreatedAt, ) { original.endpoint()?.let { endpoint(it) } original.summary()?.takeIf { it.isNotBlank() }?.let { summary(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPickerSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPickerSheet.kt deleted file mode 100644 index 9c633500e..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPickerSheet.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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.nests.room.reactions - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.stringRes - -/** - * Quick-pick emoji sheet for the room. Six default reactions cover - * the common audio-room interactions (clap, fire, laugh, eyes, - * heart, lightning); the parent invokes [onPick] with the chosen - * emoji which is then sent as a kind-7 reaction tagged for the - * current room (and optional speaker target). - * - * Custom-emoji-pack favourites (NIP-30) are intentionally deferred — - * the v1 sheet is a plain hard-coded set so the dependency surface - * stays tight; later passes can read the user's - * `EmojiPackEvent`s and merge them in. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun RoomReactionPickerSheet( - onPick: (String) -> Unit, - onDismiss: () -> Unit, -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { - Text( - text = stringRes(R.string.nest_reactions_title), - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - Row( - modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 12.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - DEFAULT_REACTIONS.forEach { emoji -> - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = - Modifier - .size(48.dp) - .clickable { - onPick(emoji) - onDismiss() - }, - ) { - Text( - text = emoji, - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) - } - } - } - } - } -} - -private val DEFAULT_REACTIONS = listOf("👏", "🔥", "😂", "👀", "❤️", "⚡") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPopup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPopup.kt new file mode 100644 index 000000000..e25e19172 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/reactions/RoomReactionPopup.kt @@ -0,0 +1,131 @@ +/* + * 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.nests.room.reactions + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.note.ReactionChoicePopupContent +import com.vitorpamplona.amethyst.ui.note.popupAnimationEnter +import com.vitorpamplona.amethyst.ui.note.popupAnimationExit +import com.vitorpamplona.amethyst.ui.note.rememberVisibilityState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import kotlinx.collections.immutable.persistentSetOf + +/** + * Room-scoped reaction picker. Visually identical to + * [com.vitorpamplona.amethyst.ui.note.ReactionChoicePopup] — same + * pop-up animation, same user-configured reaction set (including + * NIP-30 custom emojis), same "change reactions" affordance — but + * with two semantic deviations specific to live audio rooms: + * + * 1. **Repeatable.** A note's heart `ReactionChoicePopup` calls + * `reactToOrDelete`, which toggles a `(note, emoji)` pair: a + * second tap of the same emoji DELETES the first. For an audio + * room every tap is an ephemeral cheer — the user should be + * able to fire `🔥` ten times during a great moment without the + * second tap nuking the first. We bypass `reactToOrDelete` and + * call `account.reactTo` directly, which always emits a fresh + * kind-7. + * + * 2. **No "already reacted" gate.** The standard popup passes a + * `toRemove` set so already-reacted buttons render with a + * destructive hint (next tap = delete). For us every button is + * always "fresh", so `toRemove` is empty. + * + * Anchoring + animation mirror the original popup so the affordance + * feels the same when the user enters/exits the room. + */ +@Composable +fun RoomReactionPopup( + roomNote: AddressableNote, + iconSize: Dp, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + onChangeAmount: () -> Unit, +) { + val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() } + val visibilityState = rememberVisibilityState(onDismiss) + val reactions by accountViewModel.reactionChoicesFlow().collectAsState() + + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, iconSizePx), + onDismissRequest = { visibilityState.targetState = false }, + properties = PopupProperties(focusable = true), + ) { + AnimatedVisibility( + visibleState = visibilityState, + enter = popupAnimationEnter, + exit = popupAnimationExit, + ) { + ReactionChoicePopupContent( + listOfReactions = reactions, + // Empty set — never mark a reaction as "already + // emitted" so the user can fire the same emoji + // repeatedly throughout the room. + toRemove = persistentSetOf(), + onClick = { reactionType -> + accountViewModel.launchSigner { + // Bypass `Account.reactTo` (which delegates to + // `ReactionAction.reactTo(note, …)` and + // short-circuits on `note.hasReacted(by, reaction)` + // — line 181 in ReactionAction.kt). For a live + // audio room we WANT the second tap of the same + // emoji to fire another kind-7. Build the + // template directly and hand it to + // `signAndComputeBroadcast`, which signs + + // publishes + writes to LocalCache without the + // duplicate gate. + val eventHint = roomNote.toEventHint() + if (eventHint != null) { + val template = + if (reactionType.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reactionType) + if (emojiUrl != null) { + ReactionEvent.build(emojiUrl, eventHint) + } else { + ReactionEvent.build(reactionType, eventHint) + } + } else { + ReactionEvent.build(reactionType, eventHint) + } + accountViewModel.account.signAndComputeBroadcast(template) + } + } + visibilityState.targetState = false + }, + onChangeAmount = onChangeAmount, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index 35327c16f..a2df46dc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.ZapCustomDialog import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.note.zapClick import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.reactions.RoomReactionPopup import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers @@ -115,11 +116,10 @@ internal fun NestActionBar( isOnStage: Boolean, handRaised: Boolean, onHandRaisedChange: (Boolean) -> Unit, - onShowReactionPicker: () -> Unit, - onLeave: () -> Unit, roomNote: AddressableNote, accountViewModel: AccountViewModel, nav: INav, + onLeave: () -> Unit, ) { Surface( modifier = Modifier.fillMaxWidth(), @@ -143,11 +143,10 @@ internal fun NestActionBar( isConnected = ui.connection is ConnectionUiState.Connected, handRaised = handRaised, onHandRaisedChange = onHandRaisedChange, - onShowReactionPicker = onShowReactionPicker, - onLeave = onLeave, roomNote = roomNote, accountViewModel = accountViewModel, nav = nav, + onLeave = onLeave, ) } } @@ -377,11 +376,10 @@ private fun EndCluster( isConnected: Boolean, handRaised: Boolean, onHandRaisedChange: (Boolean) -> Unit, - onShowReactionPicker: () -> Unit, - onLeave: () -> Unit, roomNote: AddressableNote, accountViewModel: AccountViewModel, nav: INav, + onLeave: () -> Unit, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -399,11 +397,32 @@ private fun EndCluster( accountViewModel = accountViewModel, nav = nav, ) - FilledTonalIconButton(onClick = onShowReactionPicker) { + // React works in any state — even disconnected users can react + // via the room note. Reuses the same ReactionChoicePopup + // NoteCompose's heart button drives (NIP-30 custom-emoji + // support, user-configured reaction set). Earlier hand-rolled + // bottom-sheet picker is gone — it only emitted a fixed set + // of six unicode emojis with no path to custom packs. + var wantsToReact by rememberSaveable { mutableStateOf(false) } + FilledTonalIconButton(onClick = { wantsToReact = true }) { Icon( symbol = MaterialSymbols.EmojiEmotions, contentDescription = stringRes(R.string.nest_reactions_button), ) + if (wantsToReact) { + RoomReactionPopup( + roomNote = roomNote, + iconSize = 24.dp, + accountViewModel = accountViewModel, + onDismiss = { wantsToReact = false }, + // No `Route.UpdateReactionType` from inside the + // standalone NestActivity (no NavController). The + // "change reactions" button still dismisses the + // popup; users edit their reaction set from the + // main app's settings. + onChangeAmount = { wantsToReact = false }, + ) + } } Spacer(Modifier.width(4.dp)) LeaveRoomButton(onClick = onLeave) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index 1dcf4431a..fbe2b6e2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -71,7 +71,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestChatPan import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.edit.EditNestSheet import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.edit.EditNestViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants.ParticipantHostActionsSheet -import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.reactions.RoomReactionPickerSheet import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.AudienceGrid import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.HandRaiseQueueSection import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.StageGrid @@ -121,7 +120,6 @@ internal fun NestFullScreen( var showEditSheet by rememberSaveable { mutableStateOf(false) } var showHostMenu by rememberSaveable { mutableStateOf(false) } var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) } - var showReactionPicker by rememberSaveable { mutableStateOf(false) } var hostMenuTarget by rememberSaveable { mutableStateOf(null) } // Summary is collapsed by default; tapping the top-bar title // toggles it so the user can preview the room description without @@ -276,7 +274,9 @@ internal fun NestFullScreen( isOnStage = isOnStageMe, handRaised = handRaised, onHandRaisedChange = onHandRaisedChange, - onShowReactionPicker = { showReactionPicker = true }, + roomNote = roomNote, + accountViewModel = accountViewModel, + nav = actionBarNav, onLeave = { if (isHost) { showHostLeaveConfirm = true @@ -284,9 +284,6 @@ internal fun NestFullScreen( onLeave() } }, - roomNote = roomNote, - accountViewModel = accountViewModel, - nav = actionBarNav, ) NestTabRow( tabs = tabs, @@ -321,6 +318,8 @@ internal fun NestFullScreen( onLongPressParticipant = onLongPressParticipant, onTapParticipant = onLongPressParticipant, myPubkey = myPubkey, + reactionsByPubkey = reactionsByPubkey, + zapsByPubkey = zapsByPubkey, modifier = Modifier .weight(1f) @@ -360,13 +359,6 @@ internal fun NestFullScreen( ) } - if (showReactionPicker) { - RoomReactionPickerSheet( - onPick = { emoji -> accountViewModel.reactToOrDelete(roomNote, emoji) }, - onDismiss = { showReactionPicker = false }, - ) - } - if (showHostLeaveConfirm) { AlertDialog( onDismissRequest = { showHostLeaveConfirm = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt index 33e87656f..4df5b9406 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt @@ -306,6 +306,8 @@ internal fun AudienceGrid( onLongPressParticipant: ((String) -> Unit)? = null, onTapParticipant: ((String) -> Unit)? = null, myPubkey: String? = null, + reactionsByPubkey: Map> = emptyMap(), + zapsByPubkey: Map> = emptyMap(), ) { if (members.isEmpty()) { Box( @@ -344,7 +346,8 @@ internal fun AudienceGrid( audioLevel = 0f, isConnecting = false, showMicBadge = false, - reactions = emptyList(), + reactions = reactionsByPubkey[member.pubkey].orEmpty(), + zaps = zapsByPubkey[member.pubkey].orEmpty(), accountViewModel = accountViewModel, onLongPressParticipant = onLongPressParticipant, onTapParticipant = onTapParticipant, @@ -528,9 +531,53 @@ private fun MemberCell( isConnecting = isConnecting, showMicBadge = showMicBadge, isSpeaking = isSpeaking, - reactions = reactions, - zaps = zaps, ) + // Reactions overlay sits as a SIBLING of AvatarAndBadges + // inside this fixed-size outer Box, NOT inside + // AvatarAndBadges itself. AvatarAndBadges' inner Box + // wraps content, so any change to the overlay's measured + // size (chip appears / animates / disappears) would shift + // the inner Box's centre and the badges aligned to its + // corners would drift with it. The outer Box has an + // explicit `.size(outerBoxSize)`, so adding the overlay + // here can't change layout dimensions. + // + // Anchor the chip's bottom-RIGHT to the avatar's + // bottom-right corner (small inward nudge from the outer + // Box edge so it clears the ring/glow padding). The chip + // extends LEFTWARD inside the cell as content widens + // (single emoji vs `×N` count). Earlier `BottomStart` + // experiment anchored the chip's left edge at the same + // corner — looked great for a fixed-width chip but bled + // straight into the next column once a real emoji was + // rendered, because the cell was only ~100 dp wide and + // the chip extended out 40+ dp to the right. + if (reactions.isNotEmpty()) { + SpeakerReactionOverlay( + reactions = reactions, + modifier = + Modifier + .align(Alignment.BottomEnd) + .offset(x = -ringPadding + 6.dp, y = -ringPadding + 6.dp), + ) + } + // Zaps animate in the avatar's TOP-right corner — same + // outer-Box sibling placement and reasoning as the + // reaction overlay above, just the opposite vertical + // corner so the two streams never stack on each other + // (reactions BottomEnd, mic badge BottomCenter). End- + // anchored like reactions so the chip extends LEFTWARD + // into the cell as `⚡ Nsats` widens, rather than bleeding + // into the neighbouring column. + if (zaps.isNotEmpty()) { + SpeakerZapOverlay( + zaps = zaps, + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = -ringPadding + 6.dp, y = ringPadding - 6.dp), + ) + } } UsernameDisplay( baseUser = user, @@ -553,8 +600,6 @@ private fun AvatarAndBadges( isConnecting: Boolean, showMicBadge: Boolean, isSpeaking: Boolean, - reactions: List, - zaps: List = emptyList(), ) { Box(contentAlignment = Alignment.Center) { ClickableUserPicture( @@ -597,34 +642,6 @@ private fun AvatarAndBadges( modifier = Modifier.align(Alignment.BottomCenter), ) } - // Reactions float over the avatar's bottom-right corner so - // a 👏 burst no longer pushes the username down and reflows - // neighbouring cells. The mic badge sits at BottomCenter, - // so BottomEnd + a small outward offset keeps them clear. - if (reactions.isNotEmpty()) { - SpeakerReactionOverlay( - reactions = reactions, - modifier = - Modifier - .align(Alignment.BottomEnd) - .offset(x = 6.dp, y = 6.dp), - ) - } - // Zaps float over the avatar's bottom-left corner so they don't - // collide with the reaction overlay (BottomEnd), the mic badge - // (BottomCenter), the role badge (TopStart) or the hand-raise - // badge (TopEnd). Same sliding-window cadence as reactions — - // both fade up + out over [REACTION_WINDOW_SEC] so the two - // streams animate in lockstep. - if (zaps.isNotEmpty()) { - SpeakerZapOverlay( - zaps = zaps, - modifier = - Modifier - .align(Alignment.BottomStart) - .offset(x = -6.dp, y = 6.dp), - ) - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt index 32ac342f8..fb38f49d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt @@ -21,43 +21,50 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.viewmodels.REACTION_WINDOW_SEC +import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction -import kotlinx.coroutines.delay +import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji +import kotlinx.collections.immutable.persistentListOf /** - * Floating-emoji overlay drawn under a speaker's avatar. Each chip - * is keyed by emoji content; bursts of the same emoji collapse into - * a single `🔥 ×3` chip whose count tracks live as new reactions land. + * Floating-emoji overlay drawn under a speaker's avatar. Each + * incoming `kind-7` becomes its own independent chip — two + * reactions arriving in quick succession produce two concurrent + * rises, not one shared chip that restarts. The previous + * implementation grouped by content and used `youngestSec` as a + * `remember` key, which meant a second `🔥` from the same speaker + * interrupted the first chip's animation and showed `×2` instead + * of two distinct floating emojis. * - * Reactions are about what the speaker is saying RIGHT NOW, so each - * chip lives for [REACTION_WINDOW_SEC] seconds: the moment its - * youngest reaction arrives the chip fades+scales in, and over that - * window it slowly drifts upward and fades out before the eviction - * tick removes it from the underlying list. + * Reactions live for [REACTION_WINDOW_SEC] seconds in the + * aggregator; each chip animates over [REACTION_RISE_MS] and then + * stays invisible (alpha=0) until the aggregator evicts it. We cap + * the visible-chip count at [MAX_VISIBLE_CHIPS] so a flurry of + * reactions doesn't bleed the Row into the next column. */ @Composable internal fun SpeakerReactionOverlay( @@ -70,24 +77,37 @@ internal fun SpeakerReactionOverlay( exit = fadeOut() + scaleOut(targetScale = 0.6f), modifier = modifier, ) { - // Group by content so duplicate emojis collapse into a single - // "🔥 ×N" chip. Order by most recent so a fresh reaction lands - // at the front of the row. - val byContent = reactions.groupBy { it.content } - val ordered = - byContent.toList().sortedByDescending { (_, list) -> - list.maxOf { it.createdAtSec } + // Most-recent first; capped so the layered Box doesn't grow + // an unbounded set of overlapping chips. Older reactions drop + // off as new ones arrive (the aggregator still tracks them + // for eviction but they're not drawn once past the cap). + val visible = + remember(reactions) { + reactions + .sortedByDescending { it.createdAtSec } + .take(MAX_VISIBLE_CHIPS) } - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - ordered.forEach { (content, list) -> - // Newest createdAt drives the chip's lifecycle — - // a fresh reaction restarts the upward-drift+fade. - val youngestSec = list.maxOf { it.createdAtSec } - ReactionChip( - content = content, - count = list.size, - youngestSec = youngestSec, - ) + // Box, not Row — every chip stacks at the same right-anchored + // spot so the X position stays fixed throughout the chip's + // lifecycle. The previous Row layout slid older chips + // leftward each time a new reaction arrived (sortedByDescending + // put the newest at index 0). With overlapping chips, each + // animates independently in place; the newest is drawn on + // top by virtue of being last in the iteration. + Box(contentAlignment = androidx.compose.ui.Alignment.BottomEnd) { + // Iterate in reverse (oldest first) so the newest reaction + // ends up last in the Box and therefore on top of the + // paint stack. + visible.asReversed().forEach { reaction -> + // Per-event-id key — independent animation lifecycle + // per reaction. Same-emoji bursts no longer collide + // on a shared chip. + key(reaction.eventId) { + ReactionChip( + content = reaction.content, + youngestSec = reaction.createdAtSec, + ) + } } } } @@ -96,62 +116,161 @@ internal fun SpeakerReactionOverlay( @Composable private fun ReactionChip( content: String, - count: Int, youngestSec: Long, ) { - // Tick a [progress] state from 0f → 1f over the eviction window - // so the chip can drift + fade in lockstep with how close it is - // to falling out of the aggregator. Reset whenever a fresher - // reaction lands by re-keying on [youngestSec]. - var progress by remember(youngestSec) { mutableStateOf(0f) } + // Manual frame-clock animation. Earlier passes using + // `Animatable.animateTo(...)` and `animateFloatAsState(...)` never + // produced visible motion in this site — five iterations of + // tuning didn't help. Driving the progress State directly from + // `withFrameNanos` is the lowest-level Compose animation pattern + // there is: each frame, we read the elapsed time, compute a 0→1 + // fraction, and write it into a MutableFloatState. The + // `graphicsLayer` lambda below reads that State, so its block + // re-invokes per frame without going through the standard + // animation framework. Keyed on [youngestSec] so a fresh + // reaction in the same emoji burst restarts the animation + // cleanly. + var progress by remember(youngestSec) { mutableFloatStateOf(0f) } LaunchedEffect(youngestSec) { - // Re-sync against wall-clock so a chip whose window started - // before this composable mounted (e.g. user rotated the - // device mid-burst) still ages correctly. - val ageMs = (System.currentTimeMillis() / 1000L - youngestSec).coerceAtLeast(0L) * 1000L - val remaining = (REACTION_WINDOW_MS - ageMs).coerceAtLeast(0L) - progress = (ageMs.toFloat() / REACTION_WINDOW_MS).coerceIn(0f, 1f) - if (remaining <= 0L) return@LaunchedEffect - // 100 ms ticks keep the drift smooth without burning a frame - // budget — the avatar is small and the chip's motion is - // sub-pixel between ticks anyway. - val steps = (remaining / 100L).coerceAtLeast(1L) - repeat(steps.toInt()) { - delay(100L) - progress = - ((System.currentTimeMillis() / 1000L - youngestSec).toFloat() * 1000f / REACTION_WINDOW_MS) - .coerceIn(0f, 1f) + val startNanos = withFrameNanos { it } + while (true) { + val nowNanos = withFrameNanos { it } + val elapsedMs = (nowNanos - startNanos) / 1_000_000f + val raw = (elapsedMs / REACTION_RISE_MS).coerceIn(0f, 1f) + // FastOutSlowInEasing — quick start, slow tail. Pulled + // by hand so we don't import the animation-core easing + // for one call site. + progress = FastOutSlowInEasing.transform(raw) + if (raw >= 1f) break } - progress = 1f } - // Drift up by ~16 dp over the window so the chip reads as - // "rising and dissipating", and fade out over the second half - // so the first half stays solidly readable. - val driftDp = (-16f * progress).dp - val animatedAlpha by animateFloatAsState( - targetValue = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f), - animationSpec = tween(durationMillis = 100, easing = LinearEasing), - label = "reaction-chip-alpha", - ) - - Surface( + // The chip drifts up, grows, and fades out — three concurrent + // tracks driven by the same `progress` value, all set inside + // the graphicsLayer lambda so the read tracks at draw time + // (per frame, no full recomposition needed): + // * translationY: 0 → -DRIFT_MAX_DP. Capped within the cell's + // headroom (otherwise the chip clips the avatar above on a + // 75 dp grid cell). + // * scale: 1 → SCALE_MAX. Mirrors a "rising bubble" — the + // emoji looks closer/larger as it lifts. + // * alpha: 1 → 1 → 0. Solid for the first ALPHA_HOLD_FRAC of + // progress; fades over the remainder so the chip dissipates + // well before the 10 s aggregator-eviction tick. + // Fixed-size Box so each chip occupies the same footprint + // regardless of which emoji is rendered — "🔥" and "👍" have + // different intrinsic glyph widths, and without a fixed box the + // Row's right-anchored layout slid the visible content left/right + // by a few dp on every new reaction. Centring the Text inside a + // [CHIP_SIZE]-square Box pins the emoji's centre to a stable + // point. No Surface / background — transparent over the avatar. + Box( modifier = Modifier - .offset(y = driftDp) - .alpha(animatedAlpha), - shape = MaterialTheme.shapes.small, - tonalElevation = 2.dp, - shadowElevation = 1.dp, + .size(CHIP_SIZE) + .graphicsLayer { + val p = progress + translationY = -DRIFT_MAX_DP.dp.toPx() * p + scaleX = 1f + (SCALE_MAX - 1f) * p + scaleY = scaleX + alpha = + ( + 1f - + ( + (p - ALPHA_HOLD_FRAC).coerceAtLeast(0f) / + (1f - ALPHA_HOLD_FRAC) + ) + ).coerceIn(0f, 1f) + }, + contentAlignment = Alignment.Center, ) { - val label = if (count > 1) "$content ×$count" else content - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) + RenderReactionContent(content) } } -private const val REACTION_WINDOW_MS = REACTION_WINDOW_SEC * 1000L +/** + * Render a single reaction's content the same way + * `ReactionsRow.RenderReactionType` does, so a kind-7 with a NIP-30 + * custom emoji shows the image (not the `:shortcode:url` string) + * and special "+" / "-" tokens fall back to the standard heart / 👎 + * conventions. + * + * - `"::"` → image rendered inline via + * [InLineIconRenderer] using [CustomEmoji.ImageUrlType]. + * - `"+"` → ❤️ (the legacy NIP-25 "like" symbol; the heart icon + * vector lives in `ReactionsRow.kt` as a private composable so + * here we render the unicode heart at the same size). + * - `"-"` → 👎. + * - Anything else → rendered verbatim as Text (the common case — + * a single unicode emoji). + */ +@Composable +private fun RenderReactionContent(content: String) { + if (content.isNotEmpty() && content[0] == ':') { + val renderable = + remember(content) { + persistentListOf( + CustomEmoji.ImageUrlType(content.removePrefix(":").substringAfter(":")), + ) + } + InLineIconRenderer( + wordsInOrder = renderable, + style = SpanStyle(color = Color.Unspecified), + fontSize = EMOJI_FONT_SIZE, + maxLines = 1, + ) + return + } + val display = + when (content) { + "+" -> "❤️" + "-" -> "👎" + else -> content + } + Text( + text = display, + style = MaterialTheme.typography.titleLarge.copy(fontSize = EMOJI_FONT_SIZE), + color = MaterialTheme.colorScheme.onSurface, + ) +} + +// 3 s pop-and-drift. Earlier 1.5 s, 4 s, and 6 s passes (when the +// animation was broken upstream) didn't read well; 3 s with the +// working frame-clock animation lands as a brisk rise — fast enough +// to feel like a reaction, slow enough to track visually — and +// still finishes inside the 10 s aggregator-eviction window. +private const val REACTION_RISE_MS = 3000L + +// Final rise distance has to clear the avatar without crashing into +// the cell above. 28 dp lands the chip near the top edge of a 75 dp +// avatar — readable, no clip. +private const val DRIFT_MAX_DP = 28f + +// 1.6× peak scale gives a clearly visible "growing as it rises" +// effect. Applied via graphicsLayer so it's drawing-only and +// doesn't reflow neighbours each frame. +private const val SCALE_MAX = 1.6f + +// Alpha holds at 1 for the first 50 % of progress, then ramps to 0 +// over the remaining 50 %. Keeps the reaction readable in its prime +// without abrupt removal. +private const val ALPHA_HOLD_FRAC = 0.5f + +// Base font for the emoji glyph. labelSmall (default Material caption +// ~11 sp) was too small to read at avatar-grid scale; 22 sp gets the +// emoji to roughly the same visual size as a stage-grid avatar badge. +private val EMOJI_FONT_SIZE = 22.sp + +// Fixed chip footprint. Slightly larger than the emoji glyph so the +// glyph (which varies in intrinsic width across emojis) is centred +// in a consistent box. With this, the Row's right-anchored layout +// produces a perfectly stable left edge per chip count, no matter +// which emoji. +private val CHIP_SIZE = 30.dp + +// Cap on concurrently-rendered chips per sender. The aggregator +// keeps reactions for 10 s; without a cap, a burst of 8+ reactions +// would stretch the Row past the cell and into the next column even +// after the older chips have faded to alpha=0 (still occupy layout). +// 3 reads as "the user is reacting a lot" without overflowing. +private const val MAX_VISIBLE_CHIPS = 3 diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 0c0d6df56..21cf93cc3 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -16,7 +16,7 @@ 群聊图片 露骨内容 中继通知 - 重复的贴文 + 重复的帖子 垃圾信息 来自该中继的垃圾事件数量 冒充 @@ -121,7 +121,7 @@ "关于我们.. " 你在想什么? 写一条消息… - 贴文 + 帖子 保存 创建 重命名 @@ -201,7 +201,7 @@ 私聊 新请求 已隐藏用户 - 新动态 + 新主题帖 会话 Mod 队列 @@ -372,6 +372,8 @@ 阻止 删除 隐藏 + 主题帖静音 + 取消主题帖静音 举报 删除 不再次显示 @@ -1128,6 +1130,11 @@ 隐藏话题超过此限制的帖子。设为 0 表示禁用 隐藏违反社区规则的帖子 当社区发布了一份NIP-9B规则文档而一个事件会让它失灵时,丢弃社区的帖子。 当社区没有结构化规则时没有任何影响。 + 拦截的内容 + + 您尚未屏蔽任何用户。 + 本次会话中还没有账户被标记为垃圾信息。 + 没有隐藏的单词。在下方添加一个单词来隐藏包含它的帖子。 新回应符号 未选择回应类型。长按可更改 Zapraiser @@ -1305,6 +1312,7 @@ 垃圾邮件 静音。点击取消静音 声音开启。点击静音 + 取消静音 后退 %d 秒 前进 %d 秒 画中画 @@ -1321,9 +1329,9 @@ 将位置显示为 将你所在位置的地理位置添加到帖子。公众会知道你在当前位置的5公里之内(3英里) 位置限定帖子 - 只有处于同一地理位置的关注者才能看到贴文。其他追随者无法看到。 - 话题限定贴文 - 只有关注了话题的人才能看到贴文,您的普通关注者也无法看到。 + 只有处于同一地理位置的关注者才能看到贴帖子。一般关注者无法看到。 + 话题限定帖 + 只有关注了话题的人才能看到帖子,您的普通关注者无法看到。 需 %1$d 分钟读完 加载位置中 没有位置信息权限 @@ -1428,6 +1436,9 @@ 找不到Blossom应用。请安装本地Blossom应用查看此文件 隐藏单词 隐藏新单词或句子 + 静音的主题帖 + 无静音的主题帖 + 未知主题帖 · %1$s 个人头像 显示个人头像 选择一个选项 @@ -1659,6 +1670,12 @@ 主页标签 选择主屏幕上出现的标签页。当只有一个标签处于活动状态时,标签栏将被隐藏。 全部 + 个人资料界面 + 选择在用户个人资料屏幕上显示哪些部分和源。默认情况下所有选项都启用。 + 个人资料徽章 + 应用推荐 + 收到的打闪源 + 关注者源 回应设置 配置显示的回应按钮、按钮顺序及是否显示回应计数。 已启用 @@ -1789,7 +1806,7 @@ 聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。 广播中继 广播中继 - 推送你的贴文到其他中继的中继(例如:sendit.nosflare.com),应用会把这个中继作为提示嵌入到你发送的所有事件的 JSON 结构中。 + 推送你的帖子到其他中继的中继,例如:sendit.nosflare.com,应用会把这个中继作为提示嵌入到你发送的所有事件的 Json 结构中 索引器中继 索引器中继 专门保存每个人的账户元数据的中继(例如:purplepag.es),应用会使用这些中继来查找不在你列表中的账户元数据。 @@ -2500,6 +2517,9 @@ 使用设备上的 AI 模型来提出文本更正和音调更改。 已跟踪的广播 在发送事件时使用已跟踪的广播。在广播时显示实时进度和每个中继的状态。 + 撰写设置 + 自动创建草稿 + 当您输入或在编辑器中有未发送的文本时自动保存草稿事件并发送到您的私人发件箱中继。 使用它 忽略 更正 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a25a9cca6..e9fd03b1c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -659,7 +659,6 @@ Send Say something… No messages yet. Be the first to chat. - React React Edit room Save @@ -689,16 +688,8 @@ Asks %1$s\'s client to mute its mic. Some clients may ignore this command. Force-mute Cancel - Audio room - Following %1$s. - Unfollowed %1$s. - Muted %1$s. - Unmuted %1$s. - Promoted %1$s to speaker. - Promoted %1$s to moderator. - Demoted %1$s to listener. - Force-muted %1$s. - Kicked %1$s from the room. + Action failed + The action could not be prepared. Share room Minimize Minimize to keep listening diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index e3cb26313..abddd2325 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -420,7 +420,7 @@ class RichTextParser { ) val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif") - val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a") + val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a", "f4a") val pdfExt = listOf("pdf") val imageExtensions = imageExt + imageExt.map { it.uppercase() } @@ -556,6 +556,7 @@ val mimeTypeMap: Map = "wav" to "audio/wav", "ogg" to "audio/ogg", "m4a" to "audio/mp4", + "f4a" to "audio/mp4", "aac" to "audio/aac", "flac" to "audio/flac", // Documents diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt index 877fb2f23..19e7868d3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt @@ -69,6 +69,16 @@ data class ParticipantGrid( * AND whose latest presence advertises `onstage != false`. A * speaker who explicitly emitted `onstage=0` ("step off the * stage") drops to audience without losing their role tag. + * + * Caveat: a presence event is only honoured for the on-stage + * gate when it's NEWER than the role grant ([roleGrantSec], + * conservatively the kind-30312 `created_at`). Otherwise a + * freshly-promoted audience member whose pre-promotion + * `kind-10312` carried `onstage=0` would be pinned to the + * audience tab until they re-heartbeat — which on nostrnests + * never happens, since audience members there don't toggle + * their `onstage` flag back on after a role bump. Stale + * presence ⇒ trust the role. * * Audience: every pubkey with recent presence that isn't on * stage, plus participant-tagged users who haven't emitted * presence yet (rendered as `absent = true`). @@ -86,6 +96,7 @@ fun buildParticipantGrid( participants: List, presences: Map, hostPubkey: HexKey? = null, + roleGrantSec: Long = 0L, ): ParticipantGrid { val effectiveParticipants = if (hostPubkey != null && participants.none { it.pubKey == hostPubkey }) { @@ -103,7 +114,10 @@ fun buildParticipantGrid( val pres = presences[p.pubKey] val role = p.effectiveRole() val canSpeak = p.canSpeak() - val onstageFlag = pres?.onstage ?: true // default true for backwards compat + // Stale-presence override: trust the role when the presence + // event predates the role grant. See KDoc above for why. + val presenceIsFresh = pres != null && pres.updatedAtSec >= roleGrantSec + val onstageFlag = if (presenceIsFresh) pres.onstage else true val member = RoomMember( pubkey = p.pubKey, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt index a538d1c59..4fddcd8b1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt @@ -78,9 +78,6 @@ class RoomReactionsAggregator { // event id so the same kind-7 from two relays only counts once. private val byEventId = LinkedHashMap() - /** Stable key for room-wide reactions in the returned map. */ - private val roomWideKey = "" - /** Apply one reaction and return the post-evict snapshot. */ fun apply( event: ReactionEvent, @@ -102,13 +99,24 @@ class RoomReactionsAggregator { * cadence (typically every second so the floating-up animation * frame rate is set by the eviction tick rather than by a * per-Composable timer). + * + * Reactions are grouped by [RoomReaction.sourcePubkey] — the + * user who SENT the reaction. The chip floats up from the + * reactor's own avatar (matching nostrnests' UX) rather than + * from the target speaker's avatar (which would surface the + * NIP-25 `p`-tag's "originalAuthor" semantics — useful for + * comment threads but confusing in a live audio room, where the + * audience expects to see who's reacting, not who's being + * reacted to). Room-wide reactions (no `p`-tag at all) still + * land under their sender's key, so they show up on the + * reactor's avatar just like targeted ones. */ fun evictAndSnapshot(olderThanSec: Long): Map> { val it = byEventId.entries.iterator() while (it.hasNext()) { if (it.next().value.createdAtSec < olderThanSec) it.remove() } - return byEventId.values.groupBy { it.targetPubkey ?: roomWideKey } + return byEventId.values.groupBy { it.sourcePubkey } } /** Whether the aggregator currently holds any unevicted reactions. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt index 10c470fbb..89deb9d67 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt @@ -24,22 +24,22 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent /** - * One in-flight kind-9735 zap to render as a floating overlay on a - * participant's avatar (or as a room-wide pulse). Same ephemeral - * contract as [RoomReaction]: the aggregator drops entries older - * than the staleness window so the overlay clears itself without - * per-component animation bookkeeping. + * One in-flight kind-9735 zap to render as a floating overlay on the + * zapper's avatar. Same ephemeral contract as [RoomReaction]: the + * aggregator drops entries older than the staleness window so the + * overlay clears itself without per-component animation bookkeeping. * - * `targetPubkey == null` means the zap targets the room itself (no - * `p` tag on the receipt); the UI can render those room-wide. + * `targetPubkey == null` means the receipt carried no `p` tag — the + * zap still floats from the zapper's avatar, so the target is only + * informational. */ @Immutable data class RoomZap( /** Zap receipt event id — used for dedup across re-emits. */ val eventId: String, - /** Who paid the invoice (zap recipient lnurl provider's signing key). */ + /** Who SENT the zap (the kind-9734 request author, not the LN service). */ val sourcePubkey: String, - /** Participant the zap is aimed at, or `null` for the room itself. */ + /** Participant the zap names via its `p` tag, or `null` if none. */ val targetPubkey: String?, /** Amount in sats, or `null` if the invoice couldn't be parsed. */ val amountSats: Long?, @@ -47,14 +47,16 @@ data class RoomZap( ) { companion object { /** - * Project a kind-9735 [LnZapEvent] into a [RoomZap]. The target - * pubkey is the FIRST `p` tag — the participant a zap message - * names; an empty list means the zap is room-wide. + * Project a kind-9735 [LnZapEvent] into a [RoomZap]. The + * source pubkey is the embedded kind-9734 request author — + * the actual zapper — NOT the receipt's own `pubKey`, which + * is the lnurl service provider's signing key. Falls back to + * the receipt issuer only when the request can't be parsed. */ fun from(event: LnZapEvent): RoomZap = RoomZap( eventId = event.id, - sourcePubkey = event.pubKey, + sourcePubkey = event.zapRequest?.pubKey ?: event.pubKey, targetPubkey = event.zappedAuthor().firstOrNull(), amountSats = event.amount?.toLong(), createdAtSec = event.createdAt, @@ -65,8 +67,8 @@ data class RoomZap( /** * Sliding-window aggregator for room zaps. Mirrors * [RoomReactionsAggregator] — same dedup-by-event-id rule and - * groupBy-target-pubkey output shape so the UI can use one overlay - * component for both streams. + * group-by-sender output shape so the UI can use one overlay + * placement convention for both streams. * * Not thread-safe — call from the VM's single coroutine. */ @@ -75,9 +77,6 @@ class RoomZapsAggregator { // event id so the same receipt from two relays only counts once. private val byEventId = LinkedHashMap() - /** Stable key for room-wide zaps in the returned map. */ - private val roomWideKey = "" - /** Apply one zap and return the post-evict snapshot. */ fun apply( event: LnZapEvent, @@ -96,12 +95,20 @@ class RoomZapsAggregator { * — typically every second so the floating-up animation frame * rate is set by the eviction tick rather than by a * per-Composable timer. + * + * Zaps are grouped by [RoomZap.sourcePubkey] — the user who SENT + * the zap — so the chip floats up from the zapper's own avatar, + * matching how [RoomReactionsAggregator] groups reactions. In a + * live audio room the audience expects to see who's zapping, not + * who's being zapped; and the zapper may be an audience member, + * so the overlay is wired into the audience grid as well as the + * stage. */ fun evictAndSnapshot(olderThanSec: Long): Map> { val it = byEventId.entries.iterator() while (it.hasNext()) { if (it.next().value.createdAtSec < olderThanSec) it.remove() } - return byEventId.values.groupBy { it.targetPubkey ?: roomWideKey } + return byEventId.values.groupBy { it.sourcePubkey } } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/F4aAudioParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/F4aAudioParserTest.kt new file mode 100644 index 000000000..12d6c1c3c --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/F4aAudioParserTest.kt @@ -0,0 +1,53 @@ +/* + * 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.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class F4aAudioParserTest { + @Test + fun detectsF4aByExtension() { + val url = "https://example.com/audio/track.f4a" + val state = RichTextParser().parseText(url, EmptyTagList, null) + + val media = state.mediaForPager[url] + assertTrue(media is MediaUrlVideo, "Expected MediaUrlVideo for .f4a URL") + + val segment = state.paragraphs[0].words[0] + assertTrue(segment is VideoSegment, "Expected VideoSegment for .f4a URL, got ${segment::class.simpleName}") + assertEquals(url, segment.segmentText) + } + + @Test + fun isVideoUrlHelperMatchesF4aExtension() { + assertTrue(RichTextParser.isVideoUrl("https://example.com/track.f4a")) + assertTrue(RichTextParser.isVideoUrl("https://example.com/track.F4A")) + assertTrue(RichTextParser.isVideoUrl("https://example.com/track.f4a?sig=abc")) + } + + @Test + fun f4aMapsToMp4AudioMimeType() { + assertEquals("audio/mp4", mimeTypeMap["f4a"]) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt index 59b7a91ca..18fd08dbf 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt @@ -313,7 +313,7 @@ class NestViewModelTest { } @Test - fun onReactionEventGroupsByTargetAndEvictsOnTick() = + fun onReactionEventGroupsBySenderAndEvictsOnTick() = runVmTest { val vm = newViewModel { FakeNestsListener() } val alice = "a".repeat(64) @@ -339,10 +339,13 @@ class NestViewModelTest { sig = "0".repeat(128), ) - // Two reactions land within the 30-s window. + // Two reactions sent BY alice land within the 30-s window. + // The aggregator groups by sender (so the floating chip + // rises from the reactor's avatar), NOT by the `p`-tag + // target — both land under alice's key. vm.onReactionEvent(rxn(alice, bob, "🔥", 100L), nowSec = 100L) vm.onReactionEvent(rxn(alice, bob, "👏", 105L), nowSec = 105L) - assertEquals(2, vm.recentReactions.value[bob]!!.size) + assertEquals(2, vm.recentReactions.value[alice]!!.size) // LocalCache.observeEvents re-emits the full matching list // on every cache mutation; the same kind-7 must collapse @@ -350,7 +353,7 @@ class NestViewModelTest { val replayed = rxn(alice, bob, "🔥", 100L, id = "f".repeat(64)) vm.onReactionEvent(replayed, nowSec = 105L) vm.onReactionEvent(replayed, nowSec = 105L) - assertEquals(3, vm.recentReactions.value[bob]!!.size) + assertEquals(3, vm.recentReactions.value[alice]!!.size) // Tick advances past the window — all reactions evicted. vm.evictReactions(olderThanSec = 200L) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt index 2a5ff4bcb..ac4871e3c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt @@ -168,6 +168,50 @@ class ParticipantGridTest { assertEquals(ROLE.HOST, hostRow?.role) } + @Test + fun stalePresenceWithOnstageFalseIsIgnoredAfterRoleGrant() { + // Audience-promoted-to-speaker scenario: presence event was + // emitted at t=100 with onstage=false (their pre-promotion + // audience-mode kind-10312). The host then promotes them at + // t=200 (the room's new createdAt). Without the staleness + // gate, this lands the speaker in the audience tab even + // though their role just changed — the bug reported on + // 2026-05-13. With roleGrantSec=200, the t=100 presence is + // stale and the role tag wins → speaker on stage. + val grid = + buildParticipantGrid( + participants = listOf(pTag(host, ROLE.HOST), pTag(speaker, ROLE.SPEAKER)), + presences = + mapOf( + host to presence(host, 200L), + speaker to presence(speaker, 100L, onstage = false), + ), + roleGrantSec = 200L, + ) + assertEquals(setOf(host, speaker), grid.onStage.map { it.pubkey }.toSet()) + assertEquals(0, grid.audience.size) + } + + @Test + fun freshOnstageFalseAfterRoleGrantStillDropsToAudience() { + // Inverse: speaker emits onstage=0 AFTER the role grant + // (deliberate "stepped off stage" while keeping the role). + // Should still drop to audience — the existing leave-stage + // contract. + val grid = + buildParticipantGrid( + participants = listOf(pTag(host, ROLE.HOST), pTag(speaker, ROLE.SPEAKER)), + presences = + mapOf( + host to presence(host, 200L), + speaker to presence(speaker, 300L, onstage = false), + ), + roleGrantSec = 200L, + ) + assertEquals(setOf(host), grid.onStage.map { it.pubkey }.toSet()) + assertEquals(setOf(speaker), grid.audience.map { it.pubkey }.toSet()) + } + @Test fun explicitHostInPTagsIsNotDuplicated() { // The author already tagged themselves with role=host. The diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt index 38df29857..7a894efa8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt @@ -24,7 +24,6 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull -import kotlin.test.assertTrue class RoomReactionsStateTest { private val alice = "a".repeat(64) @@ -71,14 +70,19 @@ class RoomReactionsStateTest { } @Test - fun aggregatorGroupsBySpeaker() { + fun aggregatorGroupsBySender() { + // Two reactions sent BY alice and charlie, both targeting bob. + // Group by sender so the floating chip rises from the + // reactor's avatar rather than the target speaker's. val agg = RoomReactionsAggregator() agg.apply(reaction(alice, bob, "🔥", 100L), nowSec = 100L, windowSec = 30L) val snap = agg.apply(reaction(charlie, bob, "👏", 100L), nowSec = 100L, windowSec = 30L) - // Two reactions on bob. - assertEquals(setOf(bob), snap.keys) - assertEquals(2, snap[bob]!!.size) + assertEquals(setOf(alice, charlie), snap.keys) + assertEquals(1, snap[alice]!!.size) + assertEquals("🔥", snap[alice]!![0].content) + assertEquals(1, snap[charlie]!!.size) + assertEquals("👏", snap[charlie]!![0].content) } @Test @@ -89,20 +93,22 @@ class RoomReactionsStateTest { // Fresh reaction (T=105) — inside the window. val snap = agg.apply(reaction(charlie, bob, "👏", 105L), nowSec = 110L, windowSec = 30L) - // bob has only the fresh one left. - assertEquals(1, snap[bob]!!.size) - assertEquals("👏", snap[bob]!![0].content) + // alice's reaction is evicted, charlie's stays. + assertNull(snap[alice]) + assertEquals(1, snap[charlie]!!.size) + assertEquals("👏", snap[charlie]!![0].content) } @Test - fun aggregatorRoomWideReactionsKeyedByEmptyString() { + fun aggregatorRoomWideReactionsKeyedBySender() { val agg = RoomReactionsAggregator() val snap = agg.apply(reaction(alice, null, "🎉", 100L), nowSec = 100L, windowSec = 30L) - // Room-wide reactions land under the empty-string key so the - // map's value-type stays uniform; the UI can split them on render. - assertTrue(snap.containsKey("")) - assertEquals(1, snap[""]!!.size) + // A reaction with no `p`-tag (room-wide) still groups under + // the sender's key, so it floats from the reactor's avatar + // exactly the same way a speaker-targeted reaction does. + assertEquals(setOf(alice), snap.keys) + assertEquals(1, snap[alice]!!.size) } @Test @@ -129,6 +135,7 @@ class RoomReactionsStateTest { agg.apply(replay, nowSec = 100L, windowSec = 30L) val snap = agg.apply(replay, nowSec = 100L, windowSec = 30L) - assertEquals(1, snap[bob]!!.size) + // Sender-grouped: only one entry, under the sender (alice). + assertEquals(1, snap[alice]!!.size) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt index 57b0bd6fb..eea3ffcd0 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt @@ -24,7 +24,6 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull -import kotlin.test.assertTrue class RoomZapsStateTest { private val alice = "a".repeat(64) @@ -34,11 +33,13 @@ class RoomZapsStateTest { private var nextEventId = 1 /** - * Builds a kind-9735 zap receipt tagged for a room (`a`) and, - * optionally, a target participant (`p`). No `bolt11` tag is - * attached, so [LnZapEvent.amount] resolves to null — the - * aggregator's grouping / eviction / dedup logic does not depend - * on the amount, so that keeps the fixtures invoice-free. + * Builds a kind-9735 zap receipt with no embedded `description` + * request, so [RoomZap.from] falls back to the receipt's own + * `pubKey` as the sender — i.e. [from] is the grouping key. A + * `p` tag for [to] is attached when non-null (informational + * `targetPubkey`); no `bolt11` tag, so the amount resolves to + * null. The aggregator's grouping / eviction / dedup logic does + * not depend on the amount, which keeps the fixtures invoice-free. */ private fun zap( from: String, @@ -62,7 +63,7 @@ class RoomZapsStateTest { } @Test - fun fromEventGroupsByTargetPubkey() { + fun fromEventUsesSenderAndTarget() { val zap = RoomZap.from(zap(alice, bob, 100L)) assertEquals(alice, zap.sourcePubkey) assertEquals(bob, zap.targetPubkey) @@ -74,17 +75,21 @@ class RoomZapsStateTest { fun fromEventNullTargetWhenNoPTag() { val zap = RoomZap.from(zap(alice, null, 100L)) assertNull(zap.targetPubkey) + // Source still resolves even without a `p` tag — the zap + // floats from the zapper's avatar regardless of target. + assertEquals(alice, zap.sourcePubkey) } @Test - fun aggregatorGroupsByParticipant() { + fun aggregatorGroupsBySender() { val agg = RoomZapsAggregator() + // Two zaps from alice, aimed at different participants — both + // float from alice's avatar, so both land under alice's key. agg.apply(zap(alice, bob, 100L), nowSec = 100L, windowSec = 30L) - val snap = agg.apply(zap(charlie, bob, 100L), nowSec = 100L, windowSec = 30L) + val snap = agg.apply(zap(alice, charlie, 100L), nowSec = 100L, windowSec = 30L) - // Two zaps on bob. - assertEquals(setOf(bob), snap.keys) - assertEquals(2, snap[bob]!!.size) + assertEquals(setOf(alice), snap.keys) + assertEquals(2, snap[alice]!!.size) } @Test @@ -93,25 +98,14 @@ class RoomZapsStateTest { // Old zap (T=70) — outside the window when now=110, windowSec=30. agg.apply(zap(alice, bob, 70L), nowSec = 70L, windowSec = 30L) // Fresh zap (T=105) — inside the window. - val snap = agg.apply(zap(charlie, bob, 105L), nowSec = 110L, windowSec = 30L) + val snap = agg.apply(zap(bob, charlie, 105L), nowSec = 110L, windowSec = 30L) - // bob has only the fresh one left. + // alice's stale zap is gone; only bob's fresh one remains. + assertNull(snap[alice]) assertEquals(1, snap[bob]!!.size) assertEquals(105L, snap[bob]!![0].createdAtSec) } - @Test - fun aggregatorRoomWideZapsKeyedByEmptyString() { - val agg = RoomZapsAggregator() - val snap = agg.apply(zap(alice, null, 100L), nowSec = 100L, windowSec = 30L) - - // Room-wide zaps (no `p` tag) land under the empty-string key - // so the map's value-type stays uniform; the UI can split them - // on render. - assertTrue(snap.containsKey("")) - assertEquals(1, snap[""]!!.size) - } - @Test fun evictAndSnapshotIsIdempotentWhenNothingChanged() { val agg = RoomZapsAggregator() @@ -136,6 +130,6 @@ class RoomZapsStateTest { agg.apply(replay, nowSec = 100L, windowSec = 30L) val snap = agg.apply(replay, nowSec = 100L, windowSec = 30L) - assertEquals(1, snap[bob]!!.size) + assertEquals(1, snap[alice]!!.size) } }