Merge remote-tracking branch 'origin/main' into claude/add-zap-button-nest-KoZG4

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt
This commit is contained in:
Claude
2026-05-14 13:08:13 +00:00
22 changed files with 833 additions and 418 deletions
@@ -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(),
)
}
}
}
@@ -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)
@@ -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
@@ -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<SheetDialog?>(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<out Event>?,
toastMsg: String,
) {
broadcast(accountViewModel, template)
accountViewModel.toastManager.toast(toasts.title, toastMsg)
fun hostAction(template: EventTemplate<out Event>?) {
// 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<out Event>,
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<SheetDialog?, String> =
* (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),
)
@@ -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) }
@@ -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("👏", "🔥", "😂", "👀", "❤️", "")
@@ -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<Event>()
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,
)
}
}
}
@@ -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)
@@ -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<String?>(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 },
@@ -306,6 +306,8 @@ internal fun AudienceGrid(
onLongPressParticipant: ((String) -> Unit)? = null,
onTapParticipant: ((String) -> Unit)? = null,
myPubkey: String? = null,
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
zapsByPubkey: Map<String, List<RoomZap>> = 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<RoomReaction>,
zaps: List<RoomZap> = 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),
)
}
}
}
@@ -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.
*
* - `":<shortcode>:<url>"` → 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
@@ -16,7 +16,7 @@
<string name="group_picture">群聊图片</string>
<string name="explicit_content">露骨内容</string>
<string name="relay_notice">中继通知</string>
<string name="duplicated_post">重复的贴文</string>
<string name="duplicated_post">重复的帖子</string>
<string name="spam">垃圾信息</string>
<string name="spam_description">来自该中继的垃圾事件数量</string>
<string name="impersonation">冒充</string>
@@ -121,7 +121,7 @@
<string name="about_us">"关于我们.. "</string>
<string name="what_s_on_your_mind">你在想什么?</string>
<string name="write_a_message">写一条消息…</string>
<string name="post">贴文</string>
<string name="post">帖子</string>
<string name="save">保存</string>
<string name="create">创建</string>
<string name="rename">重命名</string>
@@ -201,7 +201,7 @@
<string name="known">私聊</string>
<string name="new_requests">新请求</string>
<string name="blocked_users">已隐藏用户</string>
<string name="new_threads">动态</string>
<string name="new_threads">主题帖</string>
<string name="conversations">会话</string>
<string name="feed"></string>
<string name="mod_queue">Mod 队列</string>
@@ -372,6 +372,8 @@
<string name="quick_action_block_dialog_btn">阻止</string>
<string name="quick_action_delete_dialog_btn">删除</string>
<string name="quick_action_block">隐藏</string>
<string name="quick_action_mute_thread">主题帖静音</string>
<string name="quick_action_unmute_thread">取消主题帖静音</string>
<string name="quick_action_report">举报</string>
<string name="quick_action_delete_button">删除</string>
<string name="quick_action_dont_show_again_button">不再次显示</string>
@@ -1128,6 +1130,11 @@
<string name="max_hashtag_limit_explainer">隐藏话题超过此限制的帖子。设为 0 表示禁用</string>
<string name="hide_community_rules_violations_title">隐藏违反社区规则的帖子</string>
<string name="hide_community_rules_violations_explainer">当社区发布了一份NIP-9B规则文档而一个事件会让它失灵时,丢弃社区的帖子。 当社区没有结构化规则时没有任何影响。</string>
<string name="security_section_blocked_content">拦截的内容</string>
<string name="security_unlimited"></string>
<string name="security_blocked_users_empty">您尚未屏蔽任何用户。</string>
<string name="security_spamming_users_empty">本次会话中还没有账户被标记为垃圾信息。</string>
<string name="security_hidden_words_empty">没有隐藏的单词。在下方添加一个单词来隐藏包含它的帖子。</string>
<string name="new_reaction_symbol">新回应符号</string>
<string name="no_reaction_type_setup_long_press_to_change">未选择回应类型。长按可更改</string>
<string name="zapraiser">Zapraiser</string>
@@ -1305,6 +1312,7 @@
<string name="spamming_users">垃圾邮件</string>
<string name="muted_button">静音。点击取消静音</string>
<string name="mute_button">声音开启。点击静音</string>
<string name="action_unmute">取消静音</string>
<string name="skip_back">后退 %d 秒</string>
<string name="skip_forward">前进 %d 秒</string>
<string name="picture_in_picture">画中画</string>
@@ -1321,9 +1329,9 @@
<string name="geohash_title">将位置显示为 </string>
<string name="geohash_explainer">将你所在位置的地理位置添加到帖子。公众会知道你在当前位置的5公里之内(3英里)</string>
<string name="geohash_exclusive">位置限定帖子</string>
<string name="geohash_exclusive_explainer">只有处于同一地理位置的关注者才能看到贴文。其他追随者无法看到。</string>
<string name="hashtag_exclusive">话题限定贴文</string>
<string name="hashtag_exclusive_explainer">只有关注了话题的人才能看到贴文,您的普通关注者无法看到。</string>
<string name="geohash_exclusive_explainer">只有处于同一地理位置的关注者才能看到贴帖子。一般关注者无法看到。</string>
<string name="hashtag_exclusive">话题限定</string>
<string name="hashtag_exclusive_explainer">只有关注了话题的人才能看到帖子,您的普通关注者无法看到。</string>
<string name="long_form_reading_minutes">需 %1$d 分钟读完</string>
<string name="loading_location">加载位置中</string>
<string name="lack_location_permissions">没有位置信息权限</string>
@@ -1428,6 +1436,9 @@
<string name="no_blossom_apps_found_description">找不到Blossom应用。请安装本地Blossom应用查看此文件</string>
<string name="hidden_words">隐藏单词</string>
<string name="hide_new_word_label">隐藏新单词或句子</string>
<string name="settings_muted_threads_title">静音的主题帖</string>
<string name="settings_muted_threads_empty">无静音的主题帖</string>
<string name="settings_muted_threads_unknown">未知主题帖 · %1$s</string>
<string name="automatically_show_profile_picture">个人头像</string>
<string name="automatically_show_profile_picture_description">显示个人头像</string>
<string name="select_an_option">选择一个选项</string>
@@ -1659,6 +1670,12 @@
<string name="home_tabs_settings">主页标签</string>
<string name="home_tabs_settings_description">选择主屏幕上出现的标签页。当只有一个标签处于活动状态时,标签栏将被隐藏。</string>
<string name="home_tab_everything">全部</string>
<string name="profile_ui_settings">个人资料界面</string>
<string name="profile_ui_settings_description">选择在用户个人资料屏幕上显示哪些部分和源。默认情况下所有选项都启用。</string>
<string name="profile_ui_setting_badges">个人资料徽章</string>
<string name="profile_ui_setting_app_recommendations">应用推荐</string>
<string name="profile_ui_setting_zap_received_feed">收到的打闪源</string>
<string name="profile_ui_setting_followers_feed">关注者源</string>
<string name="reactions_settings">回应设置</string>
<string name="reactions_settings_description">配置显示的回应按钮、按钮顺序及是否显示回应计数。</string>
<string name="reactions_settings_enabled">已启用</string>
@@ -1789,7 +1806,7 @@
<string name="proxy_section_explainer">聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。</string>
<string name="broadcast_relays_title">广播中继</string>
<string name="broadcast_section">广播中继</string>
<string name="broadcast_section_explainer">推送你的贴文到其他中继的中继例如:sendit.nosflare.com,应用会把这个中继作为提示嵌入到你发送的所有事件的 JSON 结构中</string>
<string name="broadcast_section_explainer">推送你的帖子到其他中继的中继例如:sendit.nosflare.com,应用会把这个中继作为提示嵌入到你发送的所有事件的 Json 结构中</string>
<string name="indexer_relays_title">索引器中继</string>
<string name="indexer_section">索引器中继</string>
<string name="indexer_section_explainer">专门保存每个人的账户元数据的中继(例如:purplepag.es),应用会使用这些中继来查找不在你列表中的账户元数据。</string>
@@ -2500,6 +2517,9 @@
<string name="ai_writing_setting_description">使用设备上的 AI 模型来提出文本更正和音调更改。</string>
<string name="tracked_broadcasts_setting_title">已跟踪的广播</string>
<string name="tracked_broadcasts_setting_description">在发送事件时使用已跟踪的广播。在广播时显示实时进度和每个中继的状态。</string>
<string name="compose_settings">撰写设置</string>
<string name="auto_create_drafts_setting_title">自动创建草稿</string>
<string name="auto_create_drafts_setting_description">当您输入或在编辑器中有未发送的文本时自动保存草稿事件并发送到您的私人发件箱中继。</string>
<string name="ai_writing_use_this">使用它</string>
<string name="ai_writing_dismiss">忽略</string>
<string name="ai_tone_correct">更正</string>
+2 -11
View File
@@ -659,7 +659,6 @@
<string name="nest_chat_send">Send</string>
<string name="nest_chat_placeholder">Say something…</string>
<string name="nest_chat_empty">No messages yet. Be the first to chat.</string>
<string name="nest_reactions_title">React</string>
<string name="nest_reactions_button">React</string>
<string name="nest_edit_title">Edit room</string>
<string name="nest_edit_save">Save</string>
@@ -689,16 +688,8 @@
<string name="nest_confirm_force_mute_body">Asks %1$s\'s client to mute its mic. Some clients may ignore this command.</string>
<string name="nest_confirm_force_mute_confirm">Force-mute</string>
<string name="nest_confirm_cancel">Cancel</string>
<string name="nest_toast_action_title">Audio room</string>
<string name="nest_toast_follow_sent">Following %1$s.</string>
<string name="nest_toast_unfollow_sent">Unfollowed %1$s.</string>
<string name="nest_toast_mute_sent">Muted %1$s.</string>
<string name="nest_toast_unmute_sent">Unmuted %1$s.</string>
<string name="nest_toast_promote_speaker_sent">Promoted %1$s to speaker.</string>
<string name="nest_toast_promote_moderator_sent">Promoted %1$s to moderator.</string>
<string name="nest_toast_demote_listener_sent">Demoted %1$s to listener.</string>
<string name="nest_toast_force_mute_sent">Force-muted %1$s.</string>
<string name="nest_toast_kick_sent">Kicked %1$s from the room.</string>
<string name="nest_toast_host_action_failed_title">Action failed</string>
<string name="nest_toast_host_action_failed_template_null">The action could not be prepared.</string>
<string name="nest_share_action">Share room</string>
<string name="nest_minimize">Minimize</string>
<string name="nest_minimize_description">Minimize to keep listening</string>
@@ -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<String, String> =
"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
@@ -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<ParticipantTag>,
presences: Map<String, RoomPresence>,
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,
@@ -78,9 +78,6 @@ class RoomReactionsAggregator {
// event id so the same kind-7 from two relays only counts once.
private val byEventId = LinkedHashMap<String, RoomReaction>()
/** 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<String, List<RoomReaction>> {
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. */
@@ -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<String, RoomZap>()
/** 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<String, List<RoomZap>> {
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 }
}
}
@@ -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"])
}
}
@@ -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)
@@ -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
@@ -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)
}
}
@@ -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)
}
}