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.authCommand.compose.RelayAuthSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.StringResSetup 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.ManageRelayServices
import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
@@ -190,6 +192,24 @@ class NestActivity : AppCompatActivity() {
onLeave = { finish() }, onLeave = { finish() },
onMinimize = { enterPip() }, 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(), participants = event.participants(),
presences = presences, presences = presences,
hostPubkey = event.pubKey, 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 = 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 // Single REQ per relay covering chat, presence, reactions, admin
// commands. See NestRoomFilterAssembler for the filter shape. // commands. See NestRoomFilterAssembler for the filter shape.
NestRoomFilterAssemblerSubscription(roomNote, accountViewModel) NestRoomFilterAssemblerSubscription(roomNote, accountViewModel)
@@ -26,9 +26,12 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -108,60 +111,80 @@ fun EditNestSheet(
) )
} }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { 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( Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().imePadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Column(
text = stringRes(R.string.nest_edit_title), modifier =
style = MaterialTheme.typography.titleLarge, Modifier
) .fillMaxWidth()
.weight(1f, fill = false)
OutlinedTextField( .verticalScroll(rememberScrollState())
value = state.roomName, .padding(horizontal = 16.dp, vertical = 8.dp),
onValueChange = viewModel::setRoomName, verticalArrangement = Arrangement.spacedBy(8.dp),
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(
text = error, text = stringRes(R.string.nest_edit_title),
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.bodySmall,
) )
}
Spacer(Modifier.height(4.dp)) OutlinedTextField(
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { 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 // Destructive — flips status to CLOSED with the same d-tag
// so subscribers see the room go dark. Confirm prompt // so subscribers see the room go dark. Confirm prompt
// gates the actual publish so a misclick on the // 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.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog 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.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE 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 * Per-participant context sheet (T2 #2). Long-press on any participant
@@ -100,12 +104,12 @@ internal fun ParticipantHostActionsSheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val targetUser = remember(target) { LocalCache.getOrCreateUser(target) } val targetUser = remember(target) { LocalCache.getOrCreateUser(target) }
// Reactive display name — UsernameDisplay observes this for the // Reactive display name for confirmation-dialog bodies (kick /
// header, and we reuse the same flow for toast text so the header // force-mute) — `observeUserInfo` lets the dialog text track a
// and toast never disagree (e.g. metadata arriving mid-sheet). // late-arriving metadata event without re-opening the sheet.
val userInfo by observeUserInfo(targetUser, accountViewModel) val userInfo by observeUserInfo(targetUser, accountViewModel)
val displayName = userInfo?.info?.bestName() ?: targetUser.pubkeyDisplayHex() val displayName = userInfo?.info?.bestName() ?: targetUser.pubkeyDisplayHex()
val toasts = rememberParticipantToasts(displayName) val toasts = rememberParticipantToasts()
var openDialog by rememberSaveable(stateSaver = SheetDialogSaver) { var openDialog by rememberSaveable(stateSaver = SheetDialogSaver) {
mutableStateOf<SheetDialog?>(null) mutableStateOf<SheetDialog?>(null)
@@ -174,7 +178,6 @@ internal fun ParticipantHostActionsSheet(
// so future presence events don't re-render them // so future presence events don't re-render them
broadcast(accountViewModel, AdminCommandEvent.kick(roomATag, target)) broadcast(accountViewModel, AdminCommandEvent.kick(roomATag, target))
broadcast(accountViewModel, RoomParticipantActions.removeParticipant(event, target)) broadcast(accountViewModel, RoomParticipantActions.removeParticipant(event, target))
accountViewModel.toastManager.toast(toasts.title, toasts.kickSent)
onDismiss() onDismiss()
}, },
onDismiss = { openDialog = null }, onDismiss = { openDialog = null },
@@ -190,7 +193,6 @@ internal fun ParticipantHostActionsSheet(
openDialog = null openDialog = null
val roomATag = ATag(event.kind, event.pubKey, event.dTag(), null) val roomATag = ATag(event.kind, event.pubKey, event.dTag(), null)
broadcast(accountViewModel, AdminCommandEvent.forceMute(roomATag, target)) broadcast(accountViewModel, AdminCommandEvent.forceMute(roomATag, target))
accountViewModel.toastManager.toast(toasts.title, toasts.forceMuteSent)
onDismiss() onDismiss()
}, },
onDismiss = { openDialog = null }, onDismiss = { openDialog = null },
@@ -273,10 +275,8 @@ private fun AudienceActions(
) { ) {
if (isFollowing) { if (isFollowing) {
accountViewModel.unfollow(targetUser) accountViewModel.unfollow(targetUser)
accountViewModel.toastManager.toast(toasts.title, toasts.unfollowSent)
} else { } else {
accountViewModel.follow(targetUser) accountViewModel.follow(targetUser)
accountViewModel.toastManager.toast(toasts.title, toasts.followSent)
} }
onDismiss() onDismiss()
} }
@@ -289,10 +289,8 @@ private fun AudienceActions(
) { ) {
if (isHidden) { if (isHidden) {
accountViewModel.show(targetUser) accountViewModel.show(targetUser)
accountViewModel.toastManager.toast(toasts.title, toasts.unmuteSent)
} else { } else {
accountViewModel.hide(targetUser) accountViewModel.hide(targetUser)
accountViewModel.toastManager.toast(toasts.title, toasts.muteSent)
} }
onDismiss() onDismiss()
} }
@@ -337,13 +335,22 @@ private fun HostActions(
val nestPresences by nestViewModel.presences.collectAsState() val nestPresences by nestViewModel.presences.collectAsState()
val targetIsBroadcasting = nestPresences[target]?.publishing == true val targetIsBroadcasting = nestPresences[target]?.publishing == true
fun hostAction( fun hostAction(template: EventTemplate<out Event>?) {
template: EventTemplate<out Event>?, // Sign+publish runs inside a coroutine that surfaces ANY
toastMsg: String, // failure as a toast. Success is confirmed by the UI moving
) { // the target between stage/audience (or removing them), so
broadcast(accountViewModel, template) // no success toast is needed — the previous "Promoted X"
accountViewModel.toastManager.toast(toasts.title, toastMsg) // 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() onDismiss()
broadcastWithDiagnostics(accountViewModel, template, toasts.failedTitle)
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
@@ -353,12 +360,12 @@ private fun HostActions(
// wasted relay traffic and an obvious UX tell. // wasted relay traffic and an obvious UX tell.
if (targetRole != ROLE.SPEAKER) { if (targetRole != ROLE.SPEAKER) {
ActionRow(stringRes(R.string.nest_promote_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) { if (targetRole != ROLE.MODERATOR) {
ActionRow(stringRes(R.string.nest_promote_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 — // 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. // and the visible row sets up a misleading expectation.
if (targetRole != null && targetRole != ROLE.PARTICIPANT) { if (targetRole != null && targetRole != ROLE.PARTICIPANT) {
ActionRow(stringRes(R.string.nest_demote_listener)) { 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 // 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) } 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( private fun openProfileInMainActivity(
context: Context, context: Context,
target: String, target: String,
@@ -545,33 +582,17 @@ private val SheetDialogSaver: Saver<SheetDialog?, String> =
* (which are not @Composable) read them by reference. * (which are not @Composable) read them by reference.
*/ */
private data class ParticipantToasts( private data class ParticipantToasts(
val title: String, val failedTitle: String,
val followSent: String, val failedTemplateNull: 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 noAppMessage: String, val noAppMessage: String,
val zapSplitUnsupported: String, val zapSplitUnsupported: String,
) )
@Composable @Composable
private fun rememberParticipantToasts(displayName: String): ParticipantToasts = private fun rememberParticipantToasts(): ParticipantToasts =
ParticipantToasts( ParticipantToasts(
title = stringRes(R.string.nest_toast_action_title), failedTitle = stringRes(R.string.nest_toast_host_action_failed_title),
followSent = stringRes(R.string.nest_toast_follow_sent, displayName), failedTemplateNull = stringRes(R.string.nest_toast_host_action_failed_template_null),
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),
noAppMessage = stringRes(R.string.nest_no_app_to_open_link), noAppMessage = stringRes(R.string.nest_no_app_to_open_link),
zapSplitUnsupported = stringRes(R.string.nest_participant_zap_split_unsupported), 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.meetingSpaces.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import com.vitorpamplona.quartz.utils.TimeUtils
/** /**
* Pure builders for participant-list mutations on a kind-30312 * 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) ?: ParticipantTag(original.pubKey, null, ROLE.HOST.code, null)
val others = participants.filterNot { it.pubKey == host.pubKey } 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( return MeetingSpaceEvent.build(
room = original.room().orEmpty(), room = original.room().orEmpty(),
status = status, status = status,
service = original.service().orEmpty(), service = original.service().orEmpty(),
host = host, host = host,
dTag = original.dTag(), dTag = original.dTag(),
createdAt = nextCreatedAt,
) { ) {
original.endpoint()?.let { endpoint(it) } original.endpoint()?.let { endpoint(it) }
original.summary()?.takeIf { it.isNotBlank() }?.let { summary(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.payViaIntent
import com.vitorpamplona.amethyst.ui.note.zapClick import com.vitorpamplona.amethyst.ui.note.zapClick
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -115,11 +116,10 @@ internal fun NestActionBar(
isOnStage: Boolean, isOnStage: Boolean,
handRaised: Boolean, handRaised: Boolean,
onHandRaisedChange: (Boolean) -> Unit, onHandRaisedChange: (Boolean) -> Unit,
onShowReactionPicker: () -> Unit,
onLeave: () -> Unit,
roomNote: AddressableNote, roomNote: AddressableNote,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
onLeave: () -> Unit,
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -143,11 +143,10 @@ internal fun NestActionBar(
isConnected = ui.connection is ConnectionUiState.Connected, isConnected = ui.connection is ConnectionUiState.Connected,
handRaised = handRaised, handRaised = handRaised,
onHandRaisedChange = onHandRaisedChange, onHandRaisedChange = onHandRaisedChange,
onShowReactionPicker = onShowReactionPicker,
onLeave = onLeave,
roomNote = roomNote, roomNote = roomNote,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
onLeave = onLeave,
) )
} }
} }
@@ -377,11 +376,10 @@ private fun EndCluster(
isConnected: Boolean, isConnected: Boolean,
handRaised: Boolean, handRaised: Boolean,
onHandRaisedChange: (Boolean) -> Unit, onHandRaisedChange: (Boolean) -> Unit,
onShowReactionPicker: () -> Unit,
onLeave: () -> Unit,
roomNote: AddressableNote, roomNote: AddressableNote,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
onLeave: () -> Unit,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -399,11 +397,32 @@ private fun EndCluster(
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, 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( Icon(
symbol = MaterialSymbols.EmojiEmotions, symbol = MaterialSymbols.EmojiEmotions,
contentDescription = stringRes(R.string.nest_reactions_button), 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)) Spacer(Modifier.width(4.dp))
LeaveRoomButton(onClick = onLeave) 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.EditNestSheet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.edit.EditNestViewModel 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.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.AudienceGrid
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.HandRaiseQueueSection import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.HandRaiseQueueSection
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage.StageGrid 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 showEditSheet by rememberSaveable { mutableStateOf(false) }
var showHostMenu by rememberSaveable { mutableStateOf(false) } var showHostMenu by rememberSaveable { mutableStateOf(false) }
var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) } var showHostLeaveConfirm by rememberSaveable { mutableStateOf(false) }
var showReactionPicker by rememberSaveable { mutableStateOf(false) }
var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) } var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
// Summary is collapsed by default; tapping the top-bar title // Summary is collapsed by default; tapping the top-bar title
// toggles it so the user can preview the room description without // toggles it so the user can preview the room description without
@@ -276,7 +274,9 @@ internal fun NestFullScreen(
isOnStage = isOnStageMe, isOnStage = isOnStageMe,
handRaised = handRaised, handRaised = handRaised,
onHandRaisedChange = onHandRaisedChange, onHandRaisedChange = onHandRaisedChange,
onShowReactionPicker = { showReactionPicker = true }, roomNote = roomNote,
accountViewModel = accountViewModel,
nav = actionBarNav,
onLeave = { onLeave = {
if (isHost) { if (isHost) {
showHostLeaveConfirm = true showHostLeaveConfirm = true
@@ -284,9 +284,6 @@ internal fun NestFullScreen(
onLeave() onLeave()
} }
}, },
roomNote = roomNote,
accountViewModel = accountViewModel,
nav = actionBarNav,
) )
NestTabRow( NestTabRow(
tabs = tabs, tabs = tabs,
@@ -321,6 +318,8 @@ internal fun NestFullScreen(
onLongPressParticipant = onLongPressParticipant, onLongPressParticipant = onLongPressParticipant,
onTapParticipant = onLongPressParticipant, onTapParticipant = onLongPressParticipant,
myPubkey = myPubkey, myPubkey = myPubkey,
reactionsByPubkey = reactionsByPubkey,
zapsByPubkey = zapsByPubkey,
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
@@ -360,13 +359,6 @@ internal fun NestFullScreen(
) )
} }
if (showReactionPicker) {
RoomReactionPickerSheet(
onPick = { emoji -> accountViewModel.reactToOrDelete(roomNote, emoji) },
onDismiss = { showReactionPicker = false },
)
}
if (showHostLeaveConfirm) { if (showHostLeaveConfirm) {
AlertDialog( AlertDialog(
onDismissRequest = { showHostLeaveConfirm = false }, onDismissRequest = { showHostLeaveConfirm = false },
@@ -306,6 +306,8 @@ internal fun AudienceGrid(
onLongPressParticipant: ((String) -> Unit)? = null, onLongPressParticipant: ((String) -> Unit)? = null,
onTapParticipant: ((String) -> Unit)? = null, onTapParticipant: ((String) -> Unit)? = null,
myPubkey: String? = null, myPubkey: String? = null,
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
zapsByPubkey: Map<String, List<RoomZap>> = emptyMap(),
) { ) {
if (members.isEmpty()) { if (members.isEmpty()) {
Box( Box(
@@ -344,7 +346,8 @@ internal fun AudienceGrid(
audioLevel = 0f, audioLevel = 0f,
isConnecting = false, isConnecting = false,
showMicBadge = false, showMicBadge = false,
reactions = emptyList(), reactions = reactionsByPubkey[member.pubkey].orEmpty(),
zaps = zapsByPubkey[member.pubkey].orEmpty(),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant, onLongPressParticipant = onLongPressParticipant,
onTapParticipant = onTapParticipant, onTapParticipant = onTapParticipant,
@@ -528,9 +531,53 @@ private fun MemberCell(
isConnecting = isConnecting, isConnecting = isConnecting,
showMicBadge = showMicBadge, showMicBadge = showMicBadge,
isSpeaking = isSpeaking, 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( UsernameDisplay(
baseUser = user, baseUser = user,
@@ -553,8 +600,6 @@ private fun AvatarAndBadges(
isConnecting: Boolean, isConnecting: Boolean,
showMicBadge: Boolean, showMicBadge: Boolean,
isSpeaking: Boolean, isSpeaking: Boolean,
reactions: List<RoomReaction>,
zaps: List<RoomZap> = emptyList(),
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
ClickableUserPicture( ClickableUserPicture(
@@ -597,34 +642,6 @@ private fun AvatarAndBadges(
modifier = Modifier.align(Alignment.BottomCenter), 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 package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 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 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 * Floating-emoji overlay drawn under a speaker's avatar. Each
* is keyed by emoji content; bursts of the same emoji collapse into * incoming `kind-7` becomes its own independent chip — two
* a single `🔥 ×3` chip whose count tracks live as new reactions land. * 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 * Reactions live for [REACTION_WINDOW_SEC] seconds in the
* chip lives for [REACTION_WINDOW_SEC] seconds: the moment its * aggregator; each chip animates over [REACTION_RISE_MS] and then
* youngest reaction arrives the chip fades+scales in, and over that * stays invisible (alpha=0) until the aggregator evicts it. We cap
* window it slowly drifts upward and fades out before the eviction * the visible-chip count at [MAX_VISIBLE_CHIPS] so a flurry of
* tick removes it from the underlying list. * reactions doesn't bleed the Row into the next column.
*/ */
@Composable @Composable
internal fun SpeakerReactionOverlay( internal fun SpeakerReactionOverlay(
@@ -70,24 +77,37 @@ internal fun SpeakerReactionOverlay(
exit = fadeOut() + scaleOut(targetScale = 0.6f), exit = fadeOut() + scaleOut(targetScale = 0.6f),
modifier = modifier, modifier = modifier,
) { ) {
// Group by content so duplicate emojis collapse into a single // Most-recent first; capped so the layered Box doesn't grow
// "🔥 ×N" chip. Order by most recent so a fresh reaction lands // an unbounded set of overlapping chips. Older reactions drop
// at the front of the row. // off as new ones arrive (the aggregator still tracks them
val byContent = reactions.groupBy { it.content } // for eviction but they're not drawn once past the cap).
val ordered = val visible =
byContent.toList().sortedByDescending { (_, list) -> remember(reactions) {
list.maxOf { it.createdAtSec } reactions
.sortedByDescending { it.createdAtSec }
.take(MAX_VISIBLE_CHIPS)
} }
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { // Box, not Row — every chip stacks at the same right-anchored
ordered.forEach { (content, list) -> // spot so the X position stays fixed throughout the chip's
// Newest createdAt drives the chip's lifecycle — // lifecycle. The previous Row layout slid older chips
// a fresh reaction restarts the upward-drift+fade. // leftward each time a new reaction arrived (sortedByDescending
val youngestSec = list.maxOf { it.createdAtSec } // put the newest at index 0). With overlapping chips, each
ReactionChip( // animates independently in place; the newest is drawn on
content = content, // top by virtue of being last in the iteration.
count = list.size, Box(contentAlignment = androidx.compose.ui.Alignment.BottomEnd) {
youngestSec = youngestSec, // 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 @Composable
private fun ReactionChip( private fun ReactionChip(
content: String, content: String,
count: Int,
youngestSec: Long, youngestSec: Long,
) { ) {
// Tick a [progress] state from 0f → 1f over the eviction window // Manual frame-clock animation. Earlier passes using
// so the chip can drift + fade in lockstep with how close it is // `Animatable.animateTo(...)` and `animateFloatAsState(...)` never
// to falling out of the aggregator. Reset whenever a fresher // produced visible motion in this site — five iterations of
// reaction lands by re-keying on [youngestSec]. // tuning didn't help. Driving the progress State directly from
var progress by remember(youngestSec) { mutableStateOf(0f) } // `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) { LaunchedEffect(youngestSec) {
// Re-sync against wall-clock so a chip whose window started val startNanos = withFrameNanos { it }
// before this composable mounted (e.g. user rotated the while (true) {
// device mid-burst) still ages correctly. val nowNanos = withFrameNanos { it }
val ageMs = (System.currentTimeMillis() / 1000L - youngestSec).coerceAtLeast(0L) * 1000L val elapsedMs = (nowNanos - startNanos) / 1_000_000f
val remaining = (REACTION_WINDOW_MS - ageMs).coerceAtLeast(0L) val raw = (elapsedMs / REACTION_RISE_MS).coerceIn(0f, 1f)
progress = (ageMs.toFloat() / REACTION_WINDOW_MS).coerceIn(0f, 1f) // FastOutSlowInEasing — quick start, slow tail. Pulled
if (remaining <= 0L) return@LaunchedEffect // by hand so we don't import the animation-core easing
// 100 ms ticks keep the drift smooth without burning a frame // for one call site.
// budget — the avatar is small and the chip's motion is progress = FastOutSlowInEasing.transform(raw)
// sub-pixel between ticks anyway. if (raw >= 1f) break
val steps = (remaining / 100L).coerceAtLeast(1L)
repeat(steps.toInt()) {
delay(100L)
progress =
((System.currentTimeMillis() / 1000L - youngestSec).toFloat() * 1000f / REACTION_WINDOW_MS)
.coerceIn(0f, 1f)
} }
progress = 1f
} }
// Drift up by ~16 dp over the window so the chip reads as // The chip drifts up, grows, and fades out — three concurrent
// "rising and dissipating", and fade out over the second half // tracks driven by the same `progress` value, all set inside
// so the first half stays solidly readable. // the graphicsLayer lambda so the read tracks at draw time
val driftDp = (-16f * progress).dp // (per frame, no full recomposition needed):
val animatedAlpha by animateFloatAsState( // * translationY: 0 → -DRIFT_MAX_DP. Capped within the cell's
targetValue = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f), // headroom (otherwise the chip clips the avatar above on a
animationSpec = tween(durationMillis = 100, easing = LinearEasing), // 75 dp grid cell).
label = "reaction-chip-alpha", // * 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
Surface( // 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 =
Modifier Modifier
.offset(y = driftDp) .size(CHIP_SIZE)
.alpha(animatedAlpha), .graphicsLayer {
shape = MaterialTheme.shapes.small, val p = progress
tonalElevation = 2.dp, translationY = -DRIFT_MAX_DP.dp.toPx() * p
shadowElevation = 1.dp, 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 RenderReactionContent(content)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
} }
} }
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="group_picture">群聊图片</string>
<string name="explicit_content">露骨内容</string> <string name="explicit_content">露骨内容</string>
<string name="relay_notice">中继通知</string> <string name="relay_notice">中继通知</string>
<string name="duplicated_post">重复的贴文</string> <string name="duplicated_post">重复的帖子</string>
<string name="spam">垃圾信息</string> <string name="spam">垃圾信息</string>
<string name="spam_description">来自该中继的垃圾事件数量</string> <string name="spam_description">来自该中继的垃圾事件数量</string>
<string name="impersonation">冒充</string> <string name="impersonation">冒充</string>
@@ -121,7 +121,7 @@
<string name="about_us">"关于我们.. "</string> <string name="about_us">"关于我们.. "</string>
<string name="what_s_on_your_mind">你在想什么?</string> <string name="what_s_on_your_mind">你在想什么?</string>
<string name="write_a_message">写一条消息…</string> <string name="write_a_message">写一条消息…</string>
<string name="post">贴文</string> <string name="post">帖子</string>
<string name="save">保存</string> <string name="save">保存</string>
<string name="create">创建</string> <string name="create">创建</string>
<string name="rename">重命名</string> <string name="rename">重命名</string>
@@ -201,7 +201,7 @@
<string name="known">私聊</string> <string name="known">私聊</string>
<string name="new_requests">新请求</string> <string name="new_requests">新请求</string>
<string name="blocked_users">已隐藏用户</string> <string name="blocked_users">已隐藏用户</string>
<string name="new_threads">动态</string> <string name="new_threads">主题帖</string>
<string name="conversations">会话</string> <string name="conversations">会话</string>
<string name="feed"></string> <string name="feed"></string>
<string name="mod_queue">Mod 队列</string> <string name="mod_queue">Mod 队列</string>
@@ -372,6 +372,8 @@
<string name="quick_action_block_dialog_btn">阻止</string> <string name="quick_action_block_dialog_btn">阻止</string>
<string name="quick_action_delete_dialog_btn">删除</string> <string name="quick_action_delete_dialog_btn">删除</string>
<string name="quick_action_block">隐藏</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_report">举报</string>
<string name="quick_action_delete_button">删除</string> <string name="quick_action_delete_button">删除</string>
<string name="quick_action_dont_show_again_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="max_hashtag_limit_explainer">隐藏话题超过此限制的帖子。设为 0 表示禁用</string>
<string name="hide_community_rules_violations_title">隐藏违反社区规则的帖子</string> <string name="hide_community_rules_violations_title">隐藏违反社区规则的帖子</string>
<string name="hide_community_rules_violations_explainer">当社区发布了一份NIP-9B规则文档而一个事件会让它失灵时,丢弃社区的帖子。 当社区没有结构化规则时没有任何影响。</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="new_reaction_symbol">新回应符号</string>
<string name="no_reaction_type_setup_long_press_to_change">未选择回应类型。长按可更改</string> <string name="no_reaction_type_setup_long_press_to_change">未选择回应类型。长按可更改</string>
<string name="zapraiser">Zapraiser</string> <string name="zapraiser">Zapraiser</string>
@@ -1305,6 +1312,7 @@
<string name="spamming_users">垃圾邮件</string> <string name="spamming_users">垃圾邮件</string>
<string name="muted_button">静音。点击取消静音</string> <string name="muted_button">静音。点击取消静音</string>
<string name="mute_button">声音开启。点击静音</string> <string name="mute_button">声音开启。点击静音</string>
<string name="action_unmute">取消静音</string>
<string name="skip_back">后退 %d 秒</string> <string name="skip_back">后退 %d 秒</string>
<string name="skip_forward">前进 %d 秒</string> <string name="skip_forward">前进 %d 秒</string>
<string name="picture_in_picture">画中画</string> <string name="picture_in_picture">画中画</string>
@@ -1321,9 +1329,9 @@
<string name="geohash_title">将位置显示为 </string> <string name="geohash_title">将位置显示为 </string>
<string name="geohash_explainer">将你所在位置的地理位置添加到帖子。公众会知道你在当前位置的5公里之内(3英里)</string> <string name="geohash_explainer">将你所在位置的地理位置添加到帖子。公众会知道你在当前位置的5公里之内(3英里)</string>
<string name="geohash_exclusive">位置限定帖子</string> <string name="geohash_exclusive">位置限定帖子</string>
<string name="geohash_exclusive_explainer">只有处于同一地理位置的关注者才能看到贴文。其他追随者无法看到。</string> <string name="geohash_exclusive_explainer">只有处于同一地理位置的关注者才能看到贴帖子。一般关注者无法看到。</string>
<string name="hashtag_exclusive">话题限定贴文</string> <string name="hashtag_exclusive">话题限定</string>
<string name="hashtag_exclusive_explainer">只有关注了话题的人才能看到贴文,您的普通关注者无法看到。</string> <string name="hashtag_exclusive_explainer">只有关注了话题的人才能看到帖子,您的普通关注者无法看到。</string>
<string name="long_form_reading_minutes">需 %1$d 分钟读完</string> <string name="long_form_reading_minutes">需 %1$d 分钟读完</string>
<string name="loading_location">加载位置中</string> <string name="loading_location">加载位置中</string>
<string name="lack_location_permissions">没有位置信息权限</string> <string name="lack_location_permissions">没有位置信息权限</string>
@@ -1428,6 +1436,9 @@
<string name="no_blossom_apps_found_description">找不到Blossom应用。请安装本地Blossom应用查看此文件</string> <string name="no_blossom_apps_found_description">找不到Blossom应用。请安装本地Blossom应用查看此文件</string>
<string name="hidden_words">隐藏单词</string> <string name="hidden_words">隐藏单词</string>
<string name="hide_new_word_label">隐藏新单词或句子</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">个人头像</string>
<string name="automatically_show_profile_picture_description">显示个人头像</string> <string name="automatically_show_profile_picture_description">显示个人头像</string>
<string name="select_an_option">选择一个选项</string> <string name="select_an_option">选择一个选项</string>
@@ -1659,6 +1670,12 @@
<string name="home_tabs_settings">主页标签</string> <string name="home_tabs_settings">主页标签</string>
<string name="home_tabs_settings_description">选择主屏幕上出现的标签页。当只有一个标签处于活动状态时,标签栏将被隐藏。</string> <string name="home_tabs_settings_description">选择主屏幕上出现的标签页。当只有一个标签处于活动状态时,标签栏将被隐藏。</string>
<string name="home_tab_everything">全部</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">回应设置</string>
<string name="reactions_settings_description">配置显示的回应按钮、按钮顺序及是否显示回应计数。</string> <string name="reactions_settings_description">配置显示的回应按钮、按钮顺序及是否显示回应计数。</string>
<string name="reactions_settings_enabled">已启用</string> <string name="reactions_settings_enabled">已启用</string>
@@ -1789,7 +1806,7 @@
<string name="proxy_section_explainer">聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。</string> <string name="proxy_section_explainer">聚合器中继是应用获取资讯源时使用的中继(例如:filter.nostr.wine),设置这个列表将覆盖信箱模型的行为,使得应用只会连接到这个列表中的中继以获取内容。</string>
<string name="broadcast_relays_title">广播中继</string> <string name="broadcast_relays_title">广播中继</string>
<string name="broadcast_section">广播中继</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_relays_title">索引器中继</string>
<string name="indexer_section">索引器中继</string> <string name="indexer_section">索引器中继</string>
<string name="indexer_section_explainer">专门保存每个人的账户元数据的中继(例如:purplepag.es),应用会使用这些中继来查找不在你列表中的账户元数据。</string> <string name="indexer_section_explainer">专门保存每个人的账户元数据的中继(例如:purplepag.es),应用会使用这些中继来查找不在你列表中的账户元数据。</string>
@@ -2500,6 +2517,9 @@
<string name="ai_writing_setting_description">使用设备上的 AI 模型来提出文本更正和音调更改。</string> <string name="ai_writing_setting_description">使用设备上的 AI 模型来提出文本更正和音调更改。</string>
<string name="tracked_broadcasts_setting_title">已跟踪的广播</string> <string name="tracked_broadcasts_setting_title">已跟踪的广播</string>
<string name="tracked_broadcasts_setting_description">在发送事件时使用已跟踪的广播。在广播时显示实时进度和每个中继的状态。</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_use_this">使用它</string>
<string name="ai_writing_dismiss">忽略</string> <string name="ai_writing_dismiss">忽略</string>
<string name="ai_tone_correct">更正</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_send">Send</string>
<string name="nest_chat_placeholder">Say something…</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_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_reactions_button">React</string>
<string name="nest_edit_title">Edit room</string> <string name="nest_edit_title">Edit room</string>
<string name="nest_edit_save">Save</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_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_force_mute_confirm">Force-mute</string>
<string name="nest_confirm_cancel">Cancel</string> <string name="nest_confirm_cancel">Cancel</string>
<string name="nest_toast_action_title">Audio room</string> <string name="nest_toast_host_action_failed_title">Action failed</string>
<string name="nest_toast_follow_sent">Following %1$s.</string> <string name="nest_toast_host_action_failed_template_null">The action could not be prepared.</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_share_action">Share room</string> <string name="nest_share_action">Share room</string>
<string name="nest_minimize">Minimize</string> <string name="nest_minimize">Minimize</string>
<string name="nest_minimize_description">Minimize to keep listening</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 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 pdfExt = listOf("pdf")
val imageExtensions = imageExt + imageExt.map { it.uppercase() } val imageExtensions = imageExt + imageExt.map { it.uppercase() }
@@ -556,6 +556,7 @@ val mimeTypeMap: Map<String, String> =
"wav" to "audio/wav", "wav" to "audio/wav",
"ogg" to "audio/ogg", "ogg" to "audio/ogg",
"m4a" to "audio/mp4", "m4a" to "audio/mp4",
"f4a" to "audio/mp4",
"aac" to "audio/aac", "aac" to "audio/aac",
"flac" to "audio/flac", "flac" to "audio/flac",
// Documents // Documents
@@ -69,6 +69,16 @@ data class ParticipantGrid(
* AND whose latest presence advertises `onstage != false`. A * AND whose latest presence advertises `onstage != false`. A
* speaker who explicitly emitted `onstage=0` ("step off the * speaker who explicitly emitted `onstage=0` ("step off the
* stage") drops to audience without losing their role tag. * 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 * * Audience: every pubkey with recent presence that isn't on
* stage, plus participant-tagged users who haven't emitted * stage, plus participant-tagged users who haven't emitted
* presence yet (rendered as `absent = true`). * presence yet (rendered as `absent = true`).
@@ -86,6 +96,7 @@ fun buildParticipantGrid(
participants: List<ParticipantTag>, participants: List<ParticipantTag>,
presences: Map<String, RoomPresence>, presences: Map<String, RoomPresence>,
hostPubkey: HexKey? = null, hostPubkey: HexKey? = null,
roleGrantSec: Long = 0L,
): ParticipantGrid { ): ParticipantGrid {
val effectiveParticipants = val effectiveParticipants =
if (hostPubkey != null && participants.none { it.pubKey == hostPubkey }) { if (hostPubkey != null && participants.none { it.pubKey == hostPubkey }) {
@@ -103,7 +114,10 @@ fun buildParticipantGrid(
val pres = presences[p.pubKey] val pres = presences[p.pubKey]
val role = p.effectiveRole() val role = p.effectiveRole()
val canSpeak = p.canSpeak() 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 = val member =
RoomMember( RoomMember(
pubkey = p.pubKey, pubkey = p.pubKey,
@@ -78,9 +78,6 @@ class RoomReactionsAggregator {
// event id so the same kind-7 from two relays only counts once. // event id so the same kind-7 from two relays only counts once.
private val byEventId = LinkedHashMap<String, RoomReaction>() 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. */ /** Apply one reaction and return the post-evict snapshot. */
fun apply( fun apply(
event: ReactionEvent, event: ReactionEvent,
@@ -102,13 +99,24 @@ class RoomReactionsAggregator {
* cadence (typically every second so the floating-up animation * cadence (typically every second so the floating-up animation
* frame rate is set by the eviction tick rather than by a * frame rate is set by the eviction tick rather than by a
* per-Composable timer). * 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>> { fun evictAndSnapshot(olderThanSec: Long): Map<String, List<RoomReaction>> {
val it = byEventId.entries.iterator() val it = byEventId.entries.iterator()
while (it.hasNext()) { while (it.hasNext()) {
if (it.next().value.createdAtSec < olderThanSec) it.remove() 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. */ /** Whether the aggregator currently holds any unevicted reactions. */
@@ -24,22 +24,22 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
/** /**
* One in-flight kind-9735 zap to render as a floating overlay on a * One in-flight kind-9735 zap to render as a floating overlay on the
* participant's avatar (or as a room-wide pulse). Same ephemeral * zapper's avatar. Same ephemeral contract as [RoomReaction]: the
* contract as [RoomReaction]: the aggregator drops entries older * aggregator drops entries older than the staleness window so the
* than the staleness window so the overlay clears itself without * overlay clears itself without per-component animation bookkeeping.
* per-component animation bookkeeping.
* *
* `targetPubkey == null` means the zap targets the room itself (no * `targetPubkey == null` means the receipt carried no `p` tag — the
* `p` tag on the receipt); the UI can render those room-wide. * zap still floats from the zapper's avatar, so the target is only
* informational.
*/ */
@Immutable @Immutable
data class RoomZap( data class RoomZap(
/** Zap receipt event id — used for dedup across re-emits. */ /** Zap receipt event id — used for dedup across re-emits. */
val eventId: String, 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, 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?, val targetPubkey: String?,
/** Amount in sats, or `null` if the invoice couldn't be parsed. */ /** Amount in sats, or `null` if the invoice couldn't be parsed. */
val amountSats: Long?, val amountSats: Long?,
@@ -47,14 +47,16 @@ data class RoomZap(
) { ) {
companion object { companion object {
/** /**
* Project a kind-9735 [LnZapEvent] into a [RoomZap]. The target * Project a kind-9735 [LnZapEvent] into a [RoomZap]. The
* pubkey is the FIRST `p` tag — the participant a zap message * source pubkey is the embedded kind-9734 request author —
* names; an empty list means the zap is room-wide. * 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 = fun from(event: LnZapEvent): RoomZap =
RoomZap( RoomZap(
eventId = event.id, eventId = event.id,
sourcePubkey = event.pubKey, sourcePubkey = event.zapRequest?.pubKey ?: event.pubKey,
targetPubkey = event.zappedAuthor().firstOrNull(), targetPubkey = event.zappedAuthor().firstOrNull(),
amountSats = event.amount?.toLong(), amountSats = event.amount?.toLong(),
createdAtSec = event.createdAt, createdAtSec = event.createdAt,
@@ -65,8 +67,8 @@ data class RoomZap(
/** /**
* Sliding-window aggregator for room zaps. Mirrors * Sliding-window aggregator for room zaps. Mirrors
* [RoomReactionsAggregator] — same dedup-by-event-id rule and * [RoomReactionsAggregator] — same dedup-by-event-id rule and
* groupBy-target-pubkey output shape so the UI can use one overlay * group-by-sender output shape so the UI can use one overlay
* component for both streams. * placement convention for both streams.
* *
* Not thread-safe — call from the VM's single coroutine. * 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. // event id so the same receipt from two relays only counts once.
private val byEventId = LinkedHashMap<String, RoomZap>() 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. */ /** Apply one zap and return the post-evict snapshot. */
fun apply( fun apply(
event: LnZapEvent, event: LnZapEvent,
@@ -96,12 +95,20 @@ class RoomZapsAggregator {
* — typically every second so the floating-up animation frame * — typically every second so the floating-up animation frame
* rate is set by the eviction tick rather than by a * rate is set by the eviction tick rather than by a
* per-Composable timer. * 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>> { fun evictAndSnapshot(olderThanSec: Long): Map<String, List<RoomZap>> {
val it = byEventId.entries.iterator() val it = byEventId.entries.iterator()
while (it.hasNext()) { while (it.hasNext()) {
if (it.next().value.createdAtSec < olderThanSec) it.remove() 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 @Test
fun onReactionEventGroupsByTargetAndEvictsOnTick() = fun onReactionEventGroupsBySenderAndEvictsOnTick() =
runVmTest { runVmTest {
val vm = newViewModel { FakeNestsListener() } val vm = newViewModel { FakeNestsListener() }
val alice = "a".repeat(64) val alice = "a".repeat(64)
@@ -339,10 +339,13 @@ class NestViewModelTest {
sig = "0".repeat(128), 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, "🔥", 100L), nowSec = 100L)
vm.onReactionEvent(rxn(alice, bob, "👏", 105L), nowSec = 105L) 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 // LocalCache.observeEvents re-emits the full matching list
// on every cache mutation; the same kind-7 must collapse // 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)) val replayed = rxn(alice, bob, "🔥", 100L, id = "f".repeat(64))
vm.onReactionEvent(replayed, nowSec = 105L) vm.onReactionEvent(replayed, nowSec = 105L)
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. // Tick advances past the window — all reactions evicted.
vm.evictReactions(olderThanSec = 200L) vm.evictReactions(olderThanSec = 200L)
@@ -168,6 +168,50 @@ class ParticipantGridTest {
assertEquals(ROLE.HOST, hostRow?.role) 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 @Test
fun explicitHostInPTagsIsNotDuplicated() { fun explicitHostInPTagsIsNotDuplicated() {
// The author already tagged themselves with role=host. The // 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue
class RoomReactionsStateTest { class RoomReactionsStateTest {
private val alice = "a".repeat(64) private val alice = "a".repeat(64)
@@ -71,14 +70,19 @@ class RoomReactionsStateTest {
} }
@Test @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() val agg = RoomReactionsAggregator()
agg.apply(reaction(alice, bob, "🔥", 100L), nowSec = 100L, windowSec = 30L) agg.apply(reaction(alice, bob, "🔥", 100L), nowSec = 100L, windowSec = 30L)
val snap = agg.apply(reaction(charlie, bob, "👏", 100L), nowSec = 100L, windowSec = 30L) val snap = agg.apply(reaction(charlie, bob, "👏", 100L), nowSec = 100L, windowSec = 30L)
// Two reactions on bob. assertEquals(setOf(alice, charlie), snap.keys)
assertEquals(setOf(bob), snap.keys) assertEquals(1, snap[alice]!!.size)
assertEquals(2, snap[bob]!!.size) assertEquals("🔥", snap[alice]!![0].content)
assertEquals(1, snap[charlie]!!.size)
assertEquals("👏", snap[charlie]!![0].content)
} }
@Test @Test
@@ -89,20 +93,22 @@ class RoomReactionsStateTest {
// Fresh reaction (T=105) — inside the window. // Fresh reaction (T=105) — inside the window.
val snap = agg.apply(reaction(charlie, bob, "👏", 105L), nowSec = 110L, windowSec = 30L) val snap = agg.apply(reaction(charlie, bob, "👏", 105L), nowSec = 110L, windowSec = 30L)
// bob has only the fresh one left. // alice's reaction is evicted, charlie's stays.
assertEquals(1, snap[bob]!!.size) assertNull(snap[alice])
assertEquals("👏", snap[bob]!![0].content) assertEquals(1, snap[charlie]!!.size)
assertEquals("👏", snap[charlie]!![0].content)
} }
@Test @Test
fun aggregatorRoomWideReactionsKeyedByEmptyString() { fun aggregatorRoomWideReactionsKeyedBySender() {
val agg = RoomReactionsAggregator() val agg = RoomReactionsAggregator()
val snap = agg.apply(reaction(alice, null, "🎉", 100L), nowSec = 100L, windowSec = 30L) val snap = agg.apply(reaction(alice, null, "🎉", 100L), nowSec = 100L, windowSec = 30L)
// Room-wide reactions land under the empty-string key so the // A reaction with no `p`-tag (room-wide) still groups under
// map's value-type stays uniform; the UI can split them on render. // the sender's key, so it floats from the reactor's avatar
assertTrue(snap.containsKey("")) // exactly the same way a speaker-targeted reaction does.
assertEquals(1, snap[""]!!.size) assertEquals(setOf(alice), snap.keys)
assertEquals(1, snap[alice]!!.size)
} }
@Test @Test
@@ -129,6 +135,7 @@ class RoomReactionsStateTest {
agg.apply(replay, nowSec = 100L, windowSec = 30L) agg.apply(replay, nowSec = 100L, windowSec = 30L)
val snap = 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue
class RoomZapsStateTest { class RoomZapsStateTest {
private val alice = "a".repeat(64) private val alice = "a".repeat(64)
@@ -34,11 +33,13 @@ class RoomZapsStateTest {
private var nextEventId = 1 private var nextEventId = 1
/** /**
* Builds a kind-9735 zap receipt tagged for a room (`a`) and, * Builds a kind-9735 zap receipt with no embedded `description`
* optionally, a target participant (`p`). No `bolt11` tag is * request, so [RoomZap.from] falls back to the receipt's own
* attached, so [LnZapEvent.amount] resolves to null — the * `pubKey` as the sender — i.e. [from] is the grouping key. A
* aggregator's grouping / eviction / dedup logic does not depend * `p` tag for [to] is attached when non-null (informational
* on the amount, so that keeps the fixtures invoice-free. * `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( private fun zap(
from: String, from: String,
@@ -62,7 +63,7 @@ class RoomZapsStateTest {
} }
@Test @Test
fun fromEventGroupsByTargetPubkey() { fun fromEventUsesSenderAndTarget() {
val zap = RoomZap.from(zap(alice, bob, 100L)) val zap = RoomZap.from(zap(alice, bob, 100L))
assertEquals(alice, zap.sourcePubkey) assertEquals(alice, zap.sourcePubkey)
assertEquals(bob, zap.targetPubkey) assertEquals(bob, zap.targetPubkey)
@@ -74,17 +75,21 @@ class RoomZapsStateTest {
fun fromEventNullTargetWhenNoPTag() { fun fromEventNullTargetWhenNoPTag() {
val zap = RoomZap.from(zap(alice, null, 100L)) val zap = RoomZap.from(zap(alice, null, 100L))
assertNull(zap.targetPubkey) 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 @Test
fun aggregatorGroupsByParticipant() { fun aggregatorGroupsBySender() {
val agg = RoomZapsAggregator() 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) 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(alice), snap.keys)
assertEquals(setOf(bob), snap.keys) assertEquals(2, snap[alice]!!.size)
assertEquals(2, snap[bob]!!.size)
} }
@Test @Test
@@ -93,25 +98,14 @@ class RoomZapsStateTest {
// Old zap (T=70) — outside the window when now=110, windowSec=30. // Old zap (T=70) — outside the window when now=110, windowSec=30.
agg.apply(zap(alice, bob, 70L), nowSec = 70L, windowSec = 30L) agg.apply(zap(alice, bob, 70L), nowSec = 70L, windowSec = 30L)
// Fresh zap (T=105) — inside the window. // 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(1, snap[bob]!!.size)
assertEquals(105L, snap[bob]!![0].createdAtSec) 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 @Test
fun evictAndSnapshotIsIdempotentWhenNothingChanged() { fun evictAndSnapshotIsIdempotentWhenNothingChanged() {
val agg = RoomZapsAggregator() val agg = RoomZapsAggregator()
@@ -136,6 +130,6 @@ class RoomZapsStateTest {
agg.apply(replay, nowSec = 100L, windowSec = 30L) agg.apply(replay, nowSec = 100L, windowSec = 30L)
val snap = 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)
} }
} }