mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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)))
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user