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