mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Merge branch 'main' of https://github.com/vitorpamplona/amethyst
This commit is contained in:
+3
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterA
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
|
||||
@@ -109,6 +110,7 @@ class RelaySubscriptionsCoordinator(
|
||||
val liveStreams = LiveStreamsFilterAssembler(client)
|
||||
val nests = NestsFilterAssembler(client)
|
||||
val nestRoom = NestRoomFilterAssembler(client)
|
||||
val nestRoomLiveness = NestRoomLivenessAssembler(client)
|
||||
val longs = LongsFilterAssembler(client)
|
||||
val articles = ArticlesFilterAssembler(client)
|
||||
val badges = BadgesFilterAssembler(client)
|
||||
@@ -135,6 +137,7 @@ class RelaySubscriptionsCoordinator(
|
||||
liveStreams,
|
||||
nests,
|
||||
nestRoom,
|
||||
nestRoomLiveness,
|
||||
longs,
|
||||
articles,
|
||||
badges,
|
||||
|
||||
@@ -331,6 +331,12 @@ class AccountSessionManager(
|
||||
accountInfo: AccountInfo,
|
||||
routeBuilder: ((account: Account) -> Route?)? = null,
|
||||
) {
|
||||
// The Nest audio-room activity reads the active AccountViewModel
|
||||
// through a process-singleton bridge. Drop the previous user's
|
||||
// ref before swapping so a stale ref can't survive into the new
|
||||
// session — see [NestBridge].
|
||||
com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
|
||||
.clear()
|
||||
localPreferences.switchToAccount(accountInfo)
|
||||
loginWithDefaultAccount(routeBuilder)
|
||||
}
|
||||
@@ -351,6 +357,11 @@ class AccountSessionManager(
|
||||
fun logOff(accountInfo: AccountInfo) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountInfo.npub == currentAccountNPub()) {
|
||||
// Drop the Nest bridge ref before tearing down the
|
||||
// current account so the audio-room activity can't
|
||||
// pick up a stale AccountViewModel — see [NestBridge].
|
||||
com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
|
||||
.clear()
|
||||
// log off and relogin with the 0 account
|
||||
localPreferences.deleteAccount(accountInfo)
|
||||
accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey())
|
||||
|
||||
+10
-10
@@ -78,7 +78,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53L
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.PrivateFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.LoadParticipants
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessProbeSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -300,14 +300,14 @@ private fun RenderLiveOrEndedFromPresence(
|
||||
|
||||
/**
|
||||
* Most recent kind-10312 presence `createdAt` cached for [address],
|
||||
* or null until one arrives. Reuses the room's existing assembler
|
||||
* subscription (`NestRoomFilterAssemblerSubscription`) — same
|
||||
* subscription that warms up when the user actually opens the room
|
||||
* — and reads version notes off the room's [LocalCache] channel,
|
||||
* which `LocalCache.consume(MeetingRoomPresenceEvent, …)` attaches
|
||||
* by `#a=room`. Trade-off: that REQ also pulls chat + reactions, so
|
||||
* a thumbnail probe pays a popular room's full message history. If
|
||||
* that load matters, swap to a dedicated limit:1 assembler.
|
||||
* or null until one arrives. Uses the dedicated
|
||||
* [NestRoomLivenessProbeSubscription] — `kinds=[10312], #a=[roomA],
|
||||
* limit=1` per outbox relay — so a feed of N rooms doesn't pay
|
||||
* each popular room's chat-history pull just to render a LIVE /
|
||||
* ENDED badge. The assembler reads version notes off the room's
|
||||
* [LocalCache] channel, which `LocalCache.consume(MeetingRoomPresenceEvent, …)`
|
||||
* attaches by `#a=room`. The full chat / reaction / admin REQs
|
||||
* stay gated behind the join flow (see [NestRoomFilterAssemblerSubscription]).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
@@ -315,7 +315,7 @@ private fun observeRoomLatestPresence(
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
): State<Long?> {
|
||||
NestRoomFilterAssemblerSubscription(note, accountViewModel)
|
||||
NestRoomLivenessProbeSubscription(note, accountViewModel)
|
||||
|
||||
val channel = remember(note.idHex) { LocalCache.getOrCreateLiveChannel(note.address) }
|
||||
val flow =
|
||||
|
||||
+19
-17
@@ -331,23 +331,25 @@ private fun ScheduleStartPicker(
|
||||
TextButton(onClick = {
|
||||
val dayMillisUtc = datePickerState.selectedDateMillis
|
||||
if (dayMillisUtc != null) {
|
||||
// DatePicker hands back UTC midnight of the selected
|
||||
// calendar date. Add the picked hour:minute (local)
|
||||
// to its seconds, then subtract the local offset to
|
||||
// land on the correct UTC unix second — same shape
|
||||
// as ExpirationDatePicker.
|
||||
val localSeconds =
|
||||
dayMillisUtc / 1000L +
|
||||
timePickerState.hour * 3600L +
|
||||
timePickerState.minute * 60L
|
||||
val offsetSec =
|
||||
java.time.ZoneId
|
||||
.systemDefault()
|
||||
.rules
|
||||
.getOffset(java.time.Instant.now())
|
||||
.totalSeconds
|
||||
.toLong()
|
||||
onChange(localSeconds - offsetSec)
|
||||
// DatePicker hands back UTC midnight of the
|
||||
// selected calendar date. Reinterpret that
|
||||
// calendar date as local + the picked
|
||||
// hour:minute, then convert to UTC seconds
|
||||
// using the zone offset AT THE PICKED INSTANT.
|
||||
// The previous code used `Instant.now()`'s
|
||||
// offset, which was off by one hour for rooms
|
||||
// scheduled across a DST transition.
|
||||
val zone = java.time.ZoneId.systemDefault()
|
||||
val localDate =
|
||||
java.time.Instant
|
||||
.ofEpochMilli(dayMillisUtc)
|
||||
.atZone(java.time.ZoneOffset.UTC)
|
||||
.toLocalDate()
|
||||
val pickedZdt =
|
||||
localDate
|
||||
.atTime(timePickerState.hour, timePickerState.minute)
|
||||
.atZone(zone)
|
||||
onChange(pickedZdt.toEpochSecond())
|
||||
}
|
||||
showTime = false
|
||||
}) { Text(stringRes(R.string.nest_create_submit)) }
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||
|
||||
/**
|
||||
* Per-room state for the lightweight liveness probe — used by feed
|
||||
* thumbnail cards to flip the LIVE / ENDED badge based on whether
|
||||
* any speaker has published a fresh kind-10312 presence.
|
||||
*
|
||||
* Distinct from [NestRoomQueryState] (which fans out to chat,
|
||||
* presence, reactions, and admin commands for an actively-joined
|
||||
* room) — the feed should not pay a popular room's full chat
|
||||
* history just to render a status pill.
|
||||
*/
|
||||
@Stable
|
||||
class NestRoomLivenessQueryState(
|
||||
val note: AddressableNote,
|
||||
val account: Account,
|
||||
)
|
||||
|
||||
/**
|
||||
* Single per-room sub-assembler for the feed thumbnail liveness
|
||||
* probe. Issues ONE narrow [Filter] per outbox relay:
|
||||
*
|
||||
* `kinds=[10312]`, `#a=[roomATag]`, `limit=1`
|
||||
*
|
||||
* Net wire change: the previous code reused [NestRoomFilterSubAssembler]
|
||||
* for the badge probe, which subscribes to chat + presence + reactions
|
||||
* (kinds 1311, 10312, 7) — so a feed of N rooms cost N popular-room
|
||||
* chat-history pulls per relay. This probe asks for the single
|
||||
* latest presence per room and nothing else.
|
||||
*/
|
||||
class NestRoomLivenessSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<NestRoomLivenessQueryState>,
|
||||
) : PerUniqueIdEoseManager<NestRoomLivenessQueryState, String>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: NestRoomLivenessQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
val relays =
|
||||
key.account.outboxRelays.flow.value +
|
||||
key.note.relays +
|
||||
((key.note.event as? MeetingSpaceEvent)?.relays()?.toSet() ?: emptySet())
|
||||
|
||||
return relays.map { relay ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(MeetingRoomPresenceEvent.KIND),
|
||||
tags = mapOf("a" to listOf(key.note.idHex)),
|
||||
// limit=1: the badge only needs the freshest
|
||||
// heartbeat — older ones don't change the
|
||||
// verdict. since is still threaded so the
|
||||
// EOSE manager can resume after a reconnect.
|
||||
limit = 1,
|
||||
since = since?.get(relay)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun id(key: NestRoomLivenessQueryState): String = key.note.idHex
|
||||
}
|
||||
|
||||
@Stable
|
||||
class NestRoomLivenessAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<NestRoomLivenessQueryState>() {
|
||||
private val sub = NestRoomLivenessSubAssembler(client, ::allKeys)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = sub.invalidateFilters()
|
||||
|
||||
override fun destroy() = sub.destroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle-aware subscription. Stays open while the feed card is in
|
||||
* the tree (LazyColumn auto-disposes off-screen cards); closes on
|
||||
* dispose. Use this from feed thumbnails — the join flow uses
|
||||
* [NestRoomFilterAssemblerSubscription] which is heavier.
|
||||
*/
|
||||
@Composable
|
||||
fun NestRoomLivenessProbeSubscription(
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
) = NestRoomLivenessProbeSubscription(
|
||||
note,
|
||||
accountViewModel.account,
|
||||
accountViewModel.dataSources().nestRoomLiveness,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun NestRoomLivenessProbeSubscription(
|
||||
note: AddressableNote,
|
||||
account: Account,
|
||||
filterAssembler: NestRoomLivenessAssembler,
|
||||
) {
|
||||
val state =
|
||||
remember(note, account.pubKey) {
|
||||
NestRoomLivenessQueryState(note, account)
|
||||
}
|
||||
KeyDataSourceSubscription(state, filterAssembler)
|
||||
}
|
||||
+119
-32
@@ -21,6 +21,15 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -28,6 +37,11 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
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.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
||||
@@ -46,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.PipBri
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.rememberNestViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen.NestFullScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen.NestPipScreen
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.nestsclient.NestsRoomConfig
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
@@ -71,45 +86,117 @@ internal fun NestActivityContent(
|
||||
) {
|
||||
val parsedAddress = remember(addressValue) { Address.parse(addressValue) }
|
||||
if (parsedAddress == null) {
|
||||
// Malformed address — bail. The Activity will receive a Closed
|
||||
// signal through the VM teardown when the user finishes; until
|
||||
// then we just render nothing.
|
||||
// Malformed address — show the unjoinable state so the user
|
||||
// gets a clear path back instead of staring at a black screen
|
||||
// while the Activity sits behind a useless extra.
|
||||
UnjoinableRoomState(onLeave = onLeave)
|
||||
return
|
||||
}
|
||||
|
||||
LoadAddressableNote(parsedAddress, accountViewModel) { addressableNote ->
|
||||
addressableNote ?: return@LoadAddressableNote
|
||||
if (addressableNote == null) {
|
||||
// Note hasn't resolved yet — relay subscription is in
|
||||
// flight. Render a centered spinner so the user sees that
|
||||
// we're working on it (audit Android #6).
|
||||
LoadingRoomState()
|
||||
return@LoadAddressableNote
|
||||
}
|
||||
val event by observeNoteEvent<MeetingSpaceEvent>(addressableNote, accountViewModel)
|
||||
|
||||
event?.let {
|
||||
val service = it.service()
|
||||
val endPoint = it.endpoint()
|
||||
if (service != null && endPoint != null) {
|
||||
val viewModel =
|
||||
rememberNestViewModel(
|
||||
NestsRoomConfig(
|
||||
authBaseUrl = service,
|
||||
endpoint = endPoint,
|
||||
hostPubkey = it.pubKey,
|
||||
roomId = it.dTag(),
|
||||
kind = it.kind,
|
||||
),
|
||||
accountViewModel.account.signer,
|
||||
)
|
||||
val current = event
|
||||
if (current == null) {
|
||||
LoadingRoomState()
|
||||
return@LoadAddressableNote
|
||||
}
|
||||
val service = current.service()
|
||||
val endPoint = current.endpoint()
|
||||
if (service.isNullOrBlank() || endPoint.isNullOrBlank()) {
|
||||
UnjoinableRoomState(onLeave = onLeave)
|
||||
return@LoadAddressableNote
|
||||
}
|
||||
val viewModel =
|
||||
rememberNestViewModel(
|
||||
NestsRoomConfig(
|
||||
authBaseUrl = service,
|
||||
endpoint = endPoint,
|
||||
hostPubkey = current.pubKey,
|
||||
roomId = current.dTag(),
|
||||
kind = current.kind,
|
||||
),
|
||||
accountViewModel.account.signer,
|
||||
)
|
||||
|
||||
NestActivityBody(
|
||||
event = it,
|
||||
roomNote = addressableNote,
|
||||
viewModel = viewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
isInPipMode = isInPipMode,
|
||||
isPipSupported = isPipSupported,
|
||||
onMuteState = onMuteState,
|
||||
onConnectedChange = onConnectedChange,
|
||||
pipMuteSignal = pipMuteSignal,
|
||||
onLeave = onLeave,
|
||||
onMinimize = onMinimize,
|
||||
)
|
||||
NestActivityBody(
|
||||
event = current,
|
||||
roomNote = addressableNote,
|
||||
viewModel = viewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
isInPipMode = isInPipMode,
|
||||
isPipSupported = isPipSupported,
|
||||
onMuteState = onMuteState,
|
||||
onConnectedChange = onConnectedChange,
|
||||
pipMuteSignal = pipMuteSignal,
|
||||
onLeave = onLeave,
|
||||
onMinimize = onMinimize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered spinner shown between activity launch and the first
|
||||
* resolved kind-30312 event. Replaces the previous black screen so
|
||||
* the user knows the room is being loaded, not stuck.
|
||||
*/
|
||||
@Composable
|
||||
private fun LoadingRoomState() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
text = stringRes(R.string.nest_loading_room),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal state for rooms whose kind-30312 doesn't carry the
|
||||
* service/endpoint pair the audio plane needs (or whose address is
|
||||
* malformed). Renders a clear explanation + a Back button so the
|
||||
* user can leave instead of force-killing the activity.
|
||||
*/
|
||||
@Composable
|
||||
private fun UnjoinableRoomState(onLeave: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.nest_unjoinable_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.nest_unjoinable_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
OutlinedButton(onClick = onLeave) {
|
||||
Text(stringRes(R.string.nest_unjoinable_back))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.experimental.nests.admin.AdminCommandEvent
|
||||
@@ -144,7 +146,15 @@ private fun ReactionsCollector(
|
||||
|
||||
@Composable
|
||||
private fun ReactionsEvictionTicker(viewModel: NestViewModel) {
|
||||
LaunchedEffect(viewModel) {
|
||||
// Only tick while there are reactions to evict. The aggregator's
|
||||
// last eviction empties [NestViewModel.recentReactions], which
|
||||
// flips [hasReactions] to false and cancels the loop until the
|
||||
// next reaction arrives. A perpetually-quiet room costs no
|
||||
// scheduled work.
|
||||
val reactions by viewModel.recentReactions.collectAsState()
|
||||
val hasReactions = reactions.values.any { it.isNotEmpty() }
|
||||
LaunchedEffect(viewModel, hasReactions) {
|
||||
if (!hasReactions) return@LaunchedEffect
|
||||
while (isActive) {
|
||||
delay(REACTIONS_TICK_MS)
|
||||
viewModel.evictReactions(System.currentTimeMillis() / 1000 - REACTION_WINDOW_SEC_LOCAL)
|
||||
@@ -215,4 +225,4 @@ private const val REACTIONS_TICK_MS = 1_000L
|
||||
// — the platform layer doesn't import from commons here, and a
|
||||
// duplicate constant is cheaper than another import. If these
|
||||
// drift, the 1-s tick still self-heals within one window length.
|
||||
private const val REACTION_WINDOW_SEC_LOCAL = 30L
|
||||
private const val REACTION_WINDOW_SEC_LOCAL = 10L
|
||||
|
||||
+5
-1
@@ -47,7 +47,11 @@ internal class NestViewModelFactory(
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
NestViewModel(
|
||||
httpClient = OkHttpNestsClient(Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForVideo),
|
||||
// Named parameter — `OkHttpNestsClient`'s primary constructor
|
||||
// declares `callTimeoutMs` first with a default, so the
|
||||
// function reference must be passed explicitly to the
|
||||
// `httpClient` slot rather than as the first positional arg.
|
||||
httpClient = OkHttpNestsClient(httpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForVideo),
|
||||
transport = QuicWebTransportFactory(),
|
||||
decoderFactory = { MediaCodecOpusDecoder() },
|
||||
playerFactory = { AudioTrackPlayer() },
|
||||
|
||||
+4
@@ -242,6 +242,10 @@ private fun OnStageControls(
|
||||
|
||||
is BroadcastUiState.Broadcasting -> {
|
||||
MicMuteToggle(isMuted = broadcast.isMuted, onToggle = viewModel::setMicMuted)
|
||||
// Stop sending audio without leaving the stage — viewer
|
||||
// can pause the mic and resume later via Talk. Distinct
|
||||
// from [LeaveStageButton], which also vacates the slot.
|
||||
StopBroadcastButton(onClick = viewModel::stopBroadcast)
|
||||
LeaveStageButton(onClick = leaveStage)
|
||||
}
|
||||
|
||||
|
||||
+62
-30
@@ -24,22 +24,29 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomPresence
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants.RoomParticipantActions
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -68,54 +75,79 @@ internal fun HandRaiseQueueSection(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val presences by viewModel.presences.collectAsState()
|
||||
// Memoize the on-stage gate so a heartbeat that doesn't change
|
||||
// the kind-30312 doesn't re-build the set every recompose. The
|
||||
// event reference is stable across recompositions until the host
|
||||
// republishes; presence updates run through the second remember.
|
||||
val onStageKeys =
|
||||
event
|
||||
.participants()
|
||||
.filter { it.canSpeak() }
|
||||
.map { it.pubKey }
|
||||
.toSet()
|
||||
remember(event) {
|
||||
event
|
||||
.participants()
|
||||
.filter { it.canSpeak() }
|
||||
.map { it.pubKey }
|
||||
.toSet()
|
||||
}
|
||||
val hands =
|
||||
presences.values
|
||||
.filter { it.handRaised && it.pubkey !in onStageKeys }
|
||||
.sortedBy { it.updatedAtSec }
|
||||
remember(presences, onStageKeys) {
|
||||
presences.values
|
||||
.filter { it.handRaised && it.pubkey !in onStageKeys }
|
||||
.sortedBy { it.updatedAtSec }
|
||||
}
|
||||
|
||||
if (hands.isEmpty()) return
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
Column(modifier = modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Column(modifier = modifier.fillMaxSize().padding(top = 12.dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.nest_hand_raise_queue_title),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
hands.forEach { hand ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ClickableUserPicture(
|
||||
baseUserHex = hand.pubkey,
|
||||
size = Size35dp,
|
||||
// LazyColumn so a flood of raised hands doesn't render every
|
||||
// row every recompose. Stable key by pubkey lets Compose
|
||||
// animate row entry/exit instead of teardown-rebuild.
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(top = 6.dp)) {
|
||||
items(items = hands, key = { it.pubkey }) { hand ->
|
||||
HandRaiseRow(
|
||||
hand = hand,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
Text(
|
||||
text = hand.pubkey.take(8),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
onApprove = {
|
||||
scope.launch {
|
||||
val template = RoomParticipantActions.setRole(event, hand.pubkey, ROLE.SPEAKER)
|
||||
template?.let { runCatching { accountViewModel.account.signAndComputeBroadcast(it) } }
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.nest_hand_raise_approve))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HandRaiseRow(
|
||||
hand: RoomPresence,
|
||||
accountViewModel: AccountViewModel,
|
||||
onApprove: () -> Unit,
|
||||
) {
|
||||
val user = remember(hand.pubkey) { LocalCache.getOrCreateUser(hand.pubkey) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ClickableUserPicture(
|
||||
baseUserHex = hand.pubkey,
|
||||
size = Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
UsernameDisplay(
|
||||
baseUser = user,
|
||||
weight = Modifier.weight(1f),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Button(onClick = onApprove) {
|
||||
Text(stringRes(R.string.nest_hand_raise_approve))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
-15
@@ -21,39 +21,49 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.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.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.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. Aggregates
|
||||
* the same emoji into one chip with a count badge so a burst of
|
||||
* "🔥🔥🔥" reads as `🔥 ×3` rather than three stacked chips.
|
||||
* 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.
|
||||
*
|
||||
* Hides itself when there are no reactions in the window — the
|
||||
* 30-s sliding-window aggregator in
|
||||
* [com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel.recentReactions]
|
||||
* drops stale entries on the 1-s tick.
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SpeakerReactionOverlay(
|
||||
reactions: List<RoomReaction>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Wrap the row in AnimatedVisibility so the burst scales-and-fades
|
||||
// in instead of snapping; a fading-out tail also smooths the
|
||||
// 30 s eviction sweep instead of having chips just disappear.
|
||||
AnimatedVisibility(
|
||||
visible = reactions.isNotEmpty(),
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.6f),
|
||||
@@ -64,10 +74,20 @@ internal fun SpeakerReactionOverlay(
|
||||
// "🔥 ×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 } }
|
||||
val ordered =
|
||||
byContent.toList().sortedByDescending { (_, list) ->
|
||||
list.maxOf { it.createdAtSec }
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ordered.forEach { (content, list) ->
|
||||
ReactionChip(content = content, count = list.size)
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,11 +97,49 @@ internal fun SpeakerReactionOverlay(
|
||||
private fun ReactionChip(
|
||||
content: String,
|
||||
count: Int,
|
||||
youngestSec: Long,
|
||||
) {
|
||||
// Tonal Surface picks up the right elevation tint in both light
|
||||
// and dark themes — softer than the flat secondaryContainer fill
|
||||
// and keeps a tiny shadow so the chip reads as floating.
|
||||
// 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) }
|
||||
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)
|
||||
}
|
||||
progress = 1f
|
||||
}
|
||||
|
||||
// Drift up by ~16 dp over the window so the chip reads as
|
||||
// "rising and dissipating", and fade out over the second half
|
||||
// so the first half stays solidly readable.
|
||||
val driftDp = (-16f * progress).dp
|
||||
val animatedAlpha by animateFloatAsState(
|
||||
targetValue = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f),
|
||||
animationSpec = tween(durationMillis = 100, easing = LinearEasing),
|
||||
label = "reaction-chip-alpha",
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.offset(y = driftDp)
|
||||
.alpha(animatedAlpha),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 1.dp,
|
||||
@@ -95,3 +153,5 @@ private fun ReactionChip(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val REACTION_WINDOW_MS = REACTION_WINDOW_SEC * 1000L
|
||||
|
||||
@@ -591,6 +591,10 @@
|
||||
<string name="nest_minimize">Minimize</string>
|
||||
<string name="nest_minimize_description">Minimize to keep listening</string>
|
||||
<string name="nest_audio_dropped">Audio dropped</string>
|
||||
<string name="nest_loading_room">Loading room…</string>
|
||||
<string name="nest_unjoinable_title">This room can\'t be opened</string>
|
||||
<string name="nest_unjoinable_body">The room is missing an audio service or endpoint. The host needs to update its details before listeners can connect.</string>
|
||||
<string name="nest_unjoinable_back">Back</string>
|
||||
<string name="nest_create_fab">Start space</string>
|
||||
<string name="nest_no_server_title">Set up a nest server</string>
|
||||
<string name="nest_no_server_body">You haven\'t picked a nest server yet. Add %1$s to your server list and continue?\n\nYou can change this later in Settings.</string>
|
||||
|
||||
+20
-12
@@ -139,10 +139,11 @@ class NestViewModel(
|
||||
|
||||
/**
|
||||
* Chat ledger for the live-activities chat panel (#1) — every
|
||||
* kind-1311 ([LiveActivitiesChatMessageEvent])
|
||||
* tagged with this room's `a`-pointer, ordered by `created_at`
|
||||
* ascending so the newest message is at the end (the panel
|
||||
* auto-scrolls to it).
|
||||
* kind-1311 ([LiveActivitiesChatMessageEvent]) tagged with this
|
||||
* room's `a`-pointer, ordered by [DefaultFeedOrder] (descending
|
||||
* `created_at`, then by id for stability). The chat panel renders
|
||||
* with `LazyColumn(reverseLayout = true)`, which puts data index 0
|
||||
* (the newest message) at the bottom of the viewport.
|
||||
*
|
||||
* Dedupes by event id — a relay re-emit on reconnect can't
|
||||
* produce a duplicate row.
|
||||
@@ -155,8 +156,8 @@ class NestViewModel(
|
||||
/**
|
||||
* Recent kind-7 reactions for the floating speaker-avatar overlay
|
||||
* (#3). Keyed by target pubkey; room-wide reactions land under the
|
||||
* empty-string key. Sliding 30 s window driven by the platform
|
||||
* layer's tick (typically every 1 s).
|
||||
* empty-string key. Sliding [REACTION_WINDOW_SEC] window driven
|
||||
* by the platform layer's tick (typically every 1 s).
|
||||
*/
|
||||
private val reactionsAgg = RoomReactionsAggregator()
|
||||
private val _recentReactions = MutableStateFlow<Map<String, List<RoomReaction>>>(emptyMap())
|
||||
@@ -371,7 +372,11 @@ class NestViewModel(
|
||||
*/
|
||||
fun onPresenceEvent(event: com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent) {
|
||||
if (closed) return
|
||||
_presences.value = presenceAgg.apply(event)
|
||||
// Skip the StateFlow write when the aggregator says nothing
|
||||
// changed — saves a snapshot copy + an O(N) map equality check
|
||||
// per replay in big rooms (LocalCache.observeEvents re-emits
|
||||
// the full list on every cache mutation).
|
||||
presenceAgg.applyOrNull(event)?.let { _presences.value = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -401,8 +406,8 @@ class NestViewModel(
|
||||
/**
|
||||
* Apply one kind-7 reaction event to the sliding-window aggregator.
|
||||
* Caller passes [nowSec] (so tests can be deterministic) and the
|
||||
* fixed 30-s window. Mirror of [evictReactions] for the per-tick
|
||||
* cleanup.
|
||||
* fixed [REACTION_WINDOW_SEC] window. Mirror of [evictReactions]
|
||||
* for the per-tick cleanup.
|
||||
*/
|
||||
fun onReactionEvent(
|
||||
event: com.vitorpamplona.quartz.nip25Reactions.ReactionEvent,
|
||||
@@ -1222,10 +1227,13 @@ const val LEVEL_TICK_MS: Long = 100L
|
||||
/**
|
||||
* How long a kind-7 reaction stays in
|
||||
* [NestViewModel.recentReactions] before the eviction sweep
|
||||
* drops it. Matches the duration of the floating-up animation in the
|
||||
* SpeakerReactionOverlay.
|
||||
* drops it. Reactions are about what the speaker is talking about
|
||||
* RIGHT NOW — a 10 s window keeps the floating-emoji overlay
|
||||
* timely (a reaction to one sentence shouldn't bleed into the
|
||||
* next paragraph) and matches the duration of the fade-up
|
||||
* animation in the SpeakerReactionOverlay.
|
||||
*/
|
||||
const val REACTION_WINDOW_SEC: Long = 30L
|
||||
const val REACTION_WINDOW_SEC: Long = 10L
|
||||
|
||||
/**
|
||||
* Indirection over the top-level `connectNestsListener` so tests can drive
|
||||
|
||||
+22
-4
@@ -79,13 +79,31 @@ data class RoomPresence(
|
||||
class RoomPresenceAggregator {
|
||||
private val byPubkey = mutableMapOf<String, RoomPresence>()
|
||||
|
||||
/** Apply one presence event. Returns the updated snapshot. */
|
||||
fun apply(event: MeetingRoomPresenceEvent): Map<String, RoomPresence> {
|
||||
/**
|
||||
* Apply one presence event. Returns a fresh snapshot when state
|
||||
* actually changed, or `null` when the event was a no-op (older
|
||||
* or equal to the cached presence for the same pubkey).
|
||||
*
|
||||
* `LocalCache.observeEvents` re-emits the full matching list on
|
||||
* every cache mutation, so in a 200-peer room the VM forwards
|
||||
* 200 events for a single new heartbeat. Returning `null` for
|
||||
* the 199 no-op replays lets the VM skip the
|
||||
* `_presences.value = ...` write — saves both the snapshot copy
|
||||
* and the StateFlow equality check (both O(N) per call).
|
||||
*/
|
||||
fun applyOrNull(event: MeetingRoomPresenceEvent): Map<String, RoomPresence>? {
|
||||
val incoming = RoomPresence.from(event)
|
||||
val current = byPubkey[incoming.pubkey]
|
||||
if (current == null || current.updatedAtSec < incoming.updatedAtSec) {
|
||||
byPubkey[incoming.pubkey] = incoming
|
||||
if (current != null && current.updatedAtSec >= incoming.updatedAtSec) {
|
||||
return null
|
||||
}
|
||||
byPubkey[incoming.pubkey] = incoming
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
/** Apply-or-no-op variant kept for tests + callers that always want a snapshot. */
|
||||
fun apply(event: MeetingRoomPresenceEvent): Map<String, RoomPresence> {
|
||||
applyOrNull(event)
|
||||
return snapshot()
|
||||
}
|
||||
|
||||
|
||||
+27
-9
@@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
*/
|
||||
@Immutable
|
||||
data class RoomReaction(
|
||||
/** Source event id — used for dedup across re-emits. */
|
||||
val eventId: String,
|
||||
/** Who reacted. */
|
||||
val sourcePubkey: String,
|
||||
/** Speaker the reaction is aimed at, or `null` for room-wide. */
|
||||
@@ -52,6 +54,7 @@ data class RoomReaction(
|
||||
*/
|
||||
fun from(event: ReactionEvent): RoomReaction =
|
||||
RoomReaction(
|
||||
eventId = event.id,
|
||||
sourcePubkey = event.pubKey,
|
||||
targetPubkey = event.originalAuthor().firstOrNull(),
|
||||
content = event.content,
|
||||
@@ -61,16 +64,19 @@ data class RoomReaction(
|
||||
}
|
||||
|
||||
/**
|
||||
* Sliding-window aggregator. Holds reactions keyed by target pubkey
|
||||
* (with `null` lumped under the empty-string key so the map's
|
||||
* value-type is uniform); the room screen reads
|
||||
* `byTarget()` per render and the UI naturally fades as the window
|
||||
* slides.
|
||||
* Sliding-window aggregator. Holds reactions keyed by event id (so
|
||||
* re-emits from `LocalCache.observeEvents` — which republishes the
|
||||
* full matching list on every cache mutation — collapse into a
|
||||
* single overlay entry instead of stacking duplicates), and surfaces
|
||||
* them grouped by target pubkey for the UI (with `null` lumped under
|
||||
* the empty-string key so the map's value-type is uniform).
|
||||
*
|
||||
* Not thread-safe — call from the VM's single coroutine.
|
||||
*/
|
||||
class RoomReactionsAggregator {
|
||||
private val all = mutableListOf<RoomReaction>()
|
||||
// Insertion-ordered so groupBy preserves arrival order; keyed by
|
||||
// event id so the same kind-7 from two relays only counts once.
|
||||
private val byEventId = LinkedHashMap<String, RoomReaction>()
|
||||
|
||||
/** Stable key for room-wide reactions in the returned map. */
|
||||
private val roomWideKey = ""
|
||||
@@ -81,7 +87,13 @@ class RoomReactionsAggregator {
|
||||
nowSec: Long,
|
||||
windowSec: Long,
|
||||
): Map<String, List<RoomReaction>> {
|
||||
all += RoomReaction.from(event)
|
||||
val incoming = RoomReaction.from(event)
|
||||
// Dedup: a relay re-delivery (or LocalCache.observeEvents's
|
||||
// full-list re-emit) of the same kind-7 must not stack.
|
||||
if (byEventId.put(incoming.eventId, incoming) != null) {
|
||||
// Re-emit of an existing reaction: just return the post-
|
||||
// evict snapshot without growing the map.
|
||||
}
|
||||
return evictAndSnapshot(nowSec - windowSec)
|
||||
}
|
||||
|
||||
@@ -92,7 +104,13 @@ class RoomReactionsAggregator {
|
||||
* per-Composable timer).
|
||||
*/
|
||||
fun evictAndSnapshot(olderThanSec: Long): Map<String, List<RoomReaction>> {
|
||||
all.removeAll { it.createdAtSec < olderThanSec }
|
||||
return all.groupBy { it.targetPubkey ?: roomWideKey }
|
||||
val it = byEventId.entries.iterator()
|
||||
while (it.hasNext()) {
|
||||
if (it.next().value.createdAtSec < olderThanSec) it.remove()
|
||||
}
|
||||
return byEventId.values.groupBy { it.targetPubkey ?: roomWideKey }
|
||||
}
|
||||
|
||||
/** Whether the aggregator currently holds any unevicted reactions. */
|
||||
fun isEmpty(): Boolean = byEventId.isEmpty()
|
||||
}
|
||||
|
||||
+16
-2
@@ -286,13 +286,19 @@ class NestViewModelTest {
|
||||
val alice = "a".repeat(64)
|
||||
val bob = "b".repeat(64)
|
||||
|
||||
// Vary id per reaction so the dedup gate doesn't collapse
|
||||
// distinct kind-7s; the relay-replay dedup case has its own
|
||||
// assertion below.
|
||||
var nextId = 1
|
||||
|
||||
fun rxn(
|
||||
from: String,
|
||||
to: String,
|
||||
content: String,
|
||||
createdAt: Long,
|
||||
id: String = "%064x".format(nextId++),
|
||||
) = com.vitorpamplona.quartz.nip25Reactions.ReactionEvent(
|
||||
id = "0".repeat(64),
|
||||
id = id,
|
||||
pubKey = from,
|
||||
createdAt = createdAt,
|
||||
tags = arrayOf(arrayOf("a", "30312:host:room"), arrayOf("p", to)),
|
||||
@@ -305,7 +311,15 @@ class NestViewModelTest {
|
||||
vm.onReactionEvent(rxn(alice, bob, "👏", 105L), nowSec = 105L)
|
||||
assertEquals(2, vm.recentReactions.value[bob]!!.size)
|
||||
|
||||
// Tick advances past the window — both reactions evicted.
|
||||
// LocalCache.observeEvents re-emits the full matching list
|
||||
// on every cache mutation; the same kind-7 must collapse
|
||||
// into one overlay entry instead of stacking on each replay.
|
||||
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)
|
||||
|
||||
// Tick advances past the window — all reactions evicted.
|
||||
vm.evictReactions(olderThanSec = 200L)
|
||||
assertEquals(emptyMap(), vm.recentReactions.value)
|
||||
}
|
||||
|
||||
+20
-1
@@ -31,11 +31,14 @@ class RoomReactionsStateTest {
|
||||
private val bob = "b".repeat(64)
|
||||
private val charlie = "c".repeat(64)
|
||||
|
||||
private var nextEventId = 1
|
||||
|
||||
private fun reaction(
|
||||
from: String,
|
||||
to: String?,
|
||||
content: String,
|
||||
createdAt: Long,
|
||||
id: String = "%064x".format(nextEventId++),
|
||||
): ReactionEvent {
|
||||
val tags =
|
||||
buildList<Array<String>> {
|
||||
@@ -43,7 +46,7 @@ class RoomReactionsStateTest {
|
||||
if (to != null) add(arrayOf("p", to))
|
||||
}.toTypedArray()
|
||||
return ReactionEvent(
|
||||
id = "0".repeat(64),
|
||||
id = id,
|
||||
pubKey = from,
|
||||
createdAt = createdAt,
|
||||
tags = tags,
|
||||
@@ -112,4 +115,20 @@ class RoomReactionsStateTest {
|
||||
// RoomReaction so the StateFlow doesn't re-emit on no-op ticks).
|
||||
assertEquals(a, b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aggregatorDedupsRepeatedEventIds() {
|
||||
val agg = RoomReactionsAggregator()
|
||||
// LocalCache.observeEvents re-emits the full matching list on
|
||||
// every cache mutation; the same kind-7 must collapse into one
|
||||
// overlay entry instead of stacking on each replay.
|
||||
val sharedId = "f".repeat(64)
|
||||
val first = reaction(alice, bob, "🔥", 100L, id = sharedId)
|
||||
val replay = reaction(alice, bob, "🔥", 100L, id = sharedId)
|
||||
agg.apply(first, nowSec = 100L, windowSec = 30L)
|
||||
agg.apply(replay, nowSec = 100L, windowSec = 30L)
|
||||
val snap = agg.apply(replay, nowSec = 100L, windowSec = 30L)
|
||||
|
||||
assertEquals(1, snap[bob]!!.size)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user