feat(buzz): live typing indicators (kind 20002)

Brings Buzz to typing-indicator parity with Concord, verified against buzz-core
kind.rs (KIND_TYPING_INDICATOR=20002, requires_h_channel_scope=false).

- commons BuzzTypingState: process-wide, lock-guarded channel->typist->heartbeat
  registry with stale pruning + future-clamp (6 unit tests).
- LocalCache records 20002 heartbeats into it (still no feed row; own typing
  filtered in the UI).
- RELAY_GROUP_OPEN_TAIL_KINDS requests 20002 on the open channel's live tail only
  (ephemeral, scoped to the room on screen, never the joined fleet).
- Account.sendBuzzTyping fires a throttled heartbeat to the host relay; the
  composer sends it on text change (gated to Buzz relays).
- BuzzTypingIndicator: an animated three-dot '… is typing' row above the composer
  that slides in/out and ages typists out on a timer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-22 04:15:50 +00:00
parent 5d9efc800a
commit a0d50c69f6
10 changed files with 428 additions and 3 deletions

View File

@@ -153,6 +153,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
@@ -2903,6 +2904,17 @@ class Account(
follow(channel)
}
/**
* Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral
* (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter
* our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS].
*/
suspend fun sendBuzzTyping(channel: RelayGroupChannel) {
if (!isWriteable()) return
val signed = signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
client.publish(signed, setOf(channel.groupId.relayUrl))
}
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
val template = LeaveRequestEvent.build(channel.groupId.id)

View File

@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
@@ -4666,7 +4667,13 @@ object LocalCache : ILocalCache, ICacheProvider {
// Buzz ephemeral signals: transient by definition (20000-29999) — do not
// pollute the note store, and do NOT mark the dialect from them (they are
// never stored, so the verified-mark gate has nothing to check).
is TypingIndicatorEvent -> false
is TypingIndicatorEvent -> {
// Live "who is typing" side-effect only — record the heartbeat into the
// process-wide typing state (the channel view collects it) and return
// false so it never becomes a feed row. Own-typing is filtered in the UI.
event.channelId()?.let { BuzzTypingState.record(it, event.pubKey, event.createdAt, TimeUtils.now()) }
false
}
is ObserverFrameEvent -> false
is HuddleReactionEvent -> false
// Pairing (24134) is deliberately dialect-neutral: it flows during device

View File

@@ -685,6 +685,11 @@ class AccountViewModel(
account.sendConcordTyping(communityId, channelIdHex)
}
fun sendBuzzTyping(channel: RelayGroupChannel) =
viewModelScope.launch(Dispatchers.IO) {
account.sendBuzzTyping(channel)
}
@Immutable
data class NoteComposeReportState(
val isPostHidden: Boolean = false,

View File

@@ -0,0 +1,187 @@
/*
* 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.chats.publicChannels.relayGroup
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
/**
* A live "… is typing" row for a Buzz workspace channel, driven by the ephemeral
* kind-20002 heartbeats collected into [BuzzTypingState]. Placed just above the composer,
* it slides in when someone starts typing and out when they stop (heartbeats age out of
* the freshness window). Own typing is filtered by [myPubkey].
*
* The three animated dots are a small modern flourish; the wording collapses to a count
* once more than two people type at once so the row never overflows.
*/
@Composable
fun BuzzTypingIndicator(
channelId: String,
myPubkey: HexKey,
accountViewModel: AccountViewModel,
) {
val typingByChannel by BuzzTypingState.flow.collectAsStateWithLifecycle()
// Re-evaluate on a light timer so typists fade out when their heartbeats go stale,
// but only while this channel actually has any — an idle channel never wakes the loop.
var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) }
val raw = typingByChannel[channelId]
LaunchedEffect(channelId, raw) {
if (raw.isNullOrEmpty()) return@LaunchedEffect
while (true) {
nowSecs = TimeUtils.now()
if (raw.values.none { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }) break
delay(2000L)
}
}
val typers =
remember(raw, myPubkey, nowSecs) {
(raw ?: emptyMap())
.filterKeys { it != myPubkey }
.filterValues { nowSecs - it <= BuzzTypingState.TYPING_STALE_SECS }
.keys
.sorted()
}
AnimatedVisibility(
visible = typers.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TypingDots()
Spacer(Modifier.width(8.dp))
Text(
text = typingLabel(typers, accountViewModel),
style = MaterialTheme.typography.labelSmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
}
}
}
@Composable
private fun typingLabel(
typers: List<HexKey>,
accountViewModel: AccountViewModel,
): String =
when (typers.size) {
1 -> stringRes(R.string.buzz_typing_one, rememberTypistName(typers[0], accountViewModel))
2 ->
stringRes(
R.string.buzz_typing_two,
rememberTypistName(typers[0], accountViewModel),
rememberTypistName(typers[1], accountViewModel),
)
else -> stringRes(R.string.buzz_typing_many)
}
/** Three dots that bounce in a staggered wave — a lightweight, always-on micro-animation. */
@Composable
private fun TypingDots() {
val transition = rememberInfiniteTransition(label = "buzz-typing")
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
repeat(3) { index ->
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 900, delayMillis = index * 150),
repeatMode = RepeatMode.Reverse,
),
label = "dot-$index",
)
Spacer(
modifier =
Modifier
.size(5.dp)
.graphicsLayer {
translationY = -3f * phase
val s = 0.7f + 0.3f * phase
scaleX = s
scaleY = s
}.alpha(0.4f + 0.6f * phase)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
}
/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */
@Composable
private fun rememberTypistName(
hex: HexKey,
accountViewModel: AccountViewModel,
): String {
val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) }
val info by observeUserInfo(user, accountViewModel)
return info?.info?.bestName() ?: remember(user) { user.pubkeyDisplayHex() }
}

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
@@ -252,6 +253,17 @@ private fun ChannelView(
)
}
// Buzz workspaces surface a live "… is typing" row just above the composer, fed by
// ephemeral kind-20002 heartbeats. Only rendered on a Buzz-dialect relay (the kind is
// Buzz-only); a no-op otherwise.
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
BuzzTypingIndicator(
channelId = channel.groupId.id,
myPubkey = accountViewModel.userProfile().pubkeyHex,
accountViewModel = accountViewModel,
)
}
Spacer(modifier = DoubleVertSpacer)
// NIP-29 relays reject writes from non-members, so only show the composer when the

View File

@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.buzz.jobs.JobErrorEvent
import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.stream.CanvasEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
@@ -146,6 +147,15 @@ val BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS =
/** The timeline kinds requested for every relay-group REQ (NIP-29 + Buzz; see above). */
val RELAY_GROUP_ALL_TIMELINE_KINDS = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS
/**
* Kinds requested on the **open channel's live tail only** — the timeline set plus the
* ephemeral kind-20002 typing indicator. Typing is scoped to the one channel on screen
* (not the whole joined fleet) because it's a live "someone is typing" signal, never
* stored (20000-29999) and never a feed row (`LocalCache` records it into `BuzzTypingState`
* and drops it). It matches nothing on a vanilla relay.
*/
val RELAY_GROUP_OPEN_TAIL_KINDS = RELAY_GROUP_ALL_TIMELINE_KINDS + TypingIndicatorEvent.KIND
/** Forum-thread kinds shown in a group's Threads tab. */
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND)
@@ -223,7 +233,7 @@ fun buildRelayGroupOpenChatTailFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_ALL_TIMELINE_KINDS,
kinds = RELAY_GROUP_OPEN_TAIL_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
since = sinceEpoch,
),

View File

@@ -34,6 +34,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -43,6 +44,9 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
@@ -62,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.FlowPreview
@@ -156,9 +161,28 @@ fun EditFieldRow(
)
}
// Client-side throttle for the Buzz kind-20002 typing heartbeat (below).
val lastTypingSecs = remember { longArrayOf(0L) }
ThinPaddingTextField(
state = channelScreenModel.message,
onTextChanged = { channelScreenModel.onMessageChanged() },
onTextChanged = {
channelScreenModel.onMessageChanged()
// Buzz typing indicator: fire a throttled heartbeat while composing in a
// Buzz workspace channel, so other members see "… is typing". No-op on any
// other chat (the kind is Buzz-only and the relay would ignore it anyway).
val channel = channelScreenModel.channel
if (channel is RelayGroupChannel &&
BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) &&
channelScreenModel.message.text.isNotEmpty()
) {
val now = TimeUtils.now()
if (now - lastTypingSecs[0] >= BuzzTypingState.TYPING_HEARTBEAT_SECS) {
lastTypingSecs[0] = now
accountViewModel.sendBuzzTyping(channel)
}
}
},
onContentReceived = { uri, mimeType ->
channelScreenModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType)))
},

View File

@@ -3296,6 +3296,9 @@
<string name="buzz_canvas_empty">No canvas has been shared in this workspace yet.</string>
<string name="buzz_edit_message">Edit</string>
<string name="buzz_editing_banner">Editing message</string>
<string name="buzz_typing_one">%1$s is typing…</string>
<string name="buzz_typing_two">%1$s and %2$s are typing…</string>
<string name="buzz_typing_many">Several people are typing…</string>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>

View File

@@ -0,0 +1,84 @@
/*
* 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.model.buzz
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Live "who is typing" state for Buzz workspace channels, fed by the ephemeral
* kind-20002 typing indicator (`TypingIndicatorEvent`). Buzz relays never store these
* (20000-29999), so this is pure in-memory live state — the exact analog of
* `ConcordCommunitySession.typing`, but keyed by the channel's `h` UUID because Buzz
* channels are relay-authoritative NIP-29 groups rather than encrypted planes.
*
* Shape: `channelId -> (typistPubKey -> lastHeartbeatUnixSecs)`. A typist is "active"
* while their last heartbeat is within [TYPING_STALE_SECS]; the UI ages them out on a
* timer. Mutations are lock-guarded because `LocalCache` consume runs on several relay
* reader threads and an unsynchronized check-then-act could drop a fresh heartbeat.
*
* Process-wide singleton like [BuzzRelayDialect] / `BuzzWorkspaceStates`.
*/
object BuzzTypingState {
private val lock = KmpLock()
private val typingByChannel = HashMap<String, HashMap<HexKey, Long>>()
private val mutableTyping = MutableStateFlow<Map<String, Map<HexKey, Long>>>(emptyMap())
/** `channelId -> (typist -> lastHeartbeatSecs)`; the UI collects this and ages entries out. */
val flow: StateFlow<Map<String, Map<HexKey, Long>>> = mutableTyping
/**
* Records a typing heartbeat from [typist] in [channelId] at [atSecs] (clamped to
* [nowSecs] so a future-dated peer can't wedge the freshness window), then prunes
* anything already stale. No-op for a stale-on-arrival heartbeat.
*/
fun record(
channelId: String,
typist: HexKey,
atSecs: Long,
nowSecs: Long,
) = lock.withLock {
val stamp = minOf(atSecs, nowSecs)
if (nowSecs - stamp > TYPING_STALE_SECS) return@withLock
val perChannel = typingByChannel.getOrPut(channelId) { HashMap() }
val prev = perChannel[typist]
if (prev == null || stamp > prev) perChannel[typist] = stamp
perChannel.entries.retainAll { nowSecs - it.value <= TYPING_STALE_SECS }
if (perChannel.isEmpty()) typingByChannel.remove(channelId)
mutableTyping.value = typingByChannel.mapValues { it.value.toMap() }
}
/** Test-only: clears all typing state so unit tests don't leak into each other. */
fun clearForTesting() =
lock.withLock {
typingByChannel.clear()
mutableTyping.value = emptyMap()
}
/** A peer must heartbeat at least this often to stay "typing"; also the UI fade window. */
const val TYPING_STALE_SECS = 8L
/** Client-side throttle: send at most one typing heartbeat this often while composing. */
const val TYPING_HEARTBEAT_SECS = 4L
}

View File

@@ -0,0 +1,81 @@
/*
* 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.model.buzz
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class BuzzTypingStateTest {
private val channel = "chan-1"
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
@BeforeTest fun setup() = BuzzTypingState.clearForTesting()
@AfterTest fun teardown() = BuzzTypingState.clearForTesting()
@Test
fun recordsAFreshHeartbeat() {
BuzzTypingState.record(channel, alice, atSecs = 100, nowSecs = 100)
assertEquals(setOf(alice), BuzzTypingState.flow.value[channel]?.keys)
}
@Test
fun staleOnArrivalIsDropped() {
// atSecs is older than nowSecs by more than the window → never recorded.
BuzzTypingState.record(channel, alice, atSecs = 100, nowSecs = 100 + BuzzTypingState.TYPING_STALE_SECS + 1)
assertNull(BuzzTypingState.flow.value[channel])
}
@Test
fun aNewerHeartbeatWins_andStaleTypistsArePruned() {
BuzzTypingState.record(channel, alice, atSecs = 100, nowSecs = 100)
BuzzTypingState.record(channel, bob, atSecs = 101, nowSecs = 101)
assertEquals(setOf(alice, bob), BuzzTypingState.flow.value[channel]?.keys)
// A heartbeat far in the future ages both prior ones out of the window; only the
// fresh one survives, and the newer stamp for bob is what's kept.
val later = 101 + BuzzTypingState.TYPING_STALE_SECS + 5
BuzzTypingState.record(channel, bob, atSecs = later, nowSecs = later)
assertEquals(setOf(bob), BuzzTypingState.flow.value[channel]?.keys)
assertEquals(later, BuzzTypingState.flow.value[channel]?.get(bob))
}
@Test
fun futureDatedHeartbeatIsClampedToNow() {
// A peer claiming a wildly-future createdAt must not become un-expirable.
BuzzTypingState.record(channel, alice, atSecs = 10_000, nowSecs = 100)
assertEquals(100L, BuzzTypingState.flow.value[channel]?.get(alice))
}
@Test
fun channelsAreIsolated() {
BuzzTypingState.record("c1", alice, atSecs = 100, nowSecs = 100)
BuzzTypingState.record("c2", bob, atSecs = 100, nowSecs = 100)
assertEquals(setOf(alice), BuzzTypingState.flow.value["c1"]?.keys)
assertEquals(setOf(bob), BuzzTypingState.flow.value["c2"]?.keys)
assertTrue(BuzzTypingState.flow.value.size == 2)
}
}