Wren
2026-08-13 13:10:55 -04:00
parent a96af89526
commit 37e056ed03
12 changed files with 265 additions and 63 deletions
+2 -1
View File
@@ -263,7 +263,8 @@ pub async fn get_thread_replies(
cursor.as_ref(),
);
let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?;
let events =
crate::relay::query_relay_interactive(&state, &[serde_json::Value::Object(filter)]).await?;
// A full page implies there may be more; hand back the last event's
// composite key as the next cursor (the DB returns replies strictly after
@@ -815,7 +815,8 @@ pub(crate) async fn submit_engram_event(
// Wait before signing: the relay enforces NIP-98 freshness (±60s) and the
// gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the
// wait produces a stale `created_at` that the relay will reject.
crate::relay_admission::wait_for_rate_limit().await;
let principal = agent_keys.public_key().to_hex();
crate::relay_admission::wait_for_rate_limit_for(&principal).await;
let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?;
let mut request = state
.http_client
@@ -832,7 +833,7 @@ pub(crate) async fn submit_engram_event(
.map_err(|e| crate::relay::classify_request_error(&e))?;
if !response.status().is_success() {
let msg = crate::relay::relay_error_message(response).await;
let msg = crate::relay::relay_error_message_for(response, &principal).await;
return Err(format!("relay rejected engram: {msg}"));
}
@@ -910,7 +910,8 @@ pub(crate) async fn submit_engram_event(
// Wait before signing: the relay enforces NIP-98 freshness (±60s) and the
// gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the
// wait produces a stale `created_at` that the relay will reject.
crate::relay_admission::wait_for_rate_limit().await;
let principal = agent_keys.public_key().to_hex();
crate::relay_admission::wait_for_rate_limit_for(&principal).await;
let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?;
let mut request = state
.http_client
@@ -927,7 +928,7 @@ pub(crate) async fn submit_engram_event(
.map_err(|e| crate::relay::classify_request_error(&e))?;
if !response.status().is_success() {
let msg = crate::relay::relay_error_message(response).await;
let msg = crate::relay::relay_error_message_for(response, &principal).await;
return Err(format!("relay rejected engram: {msg}"));
}
+29 -14
View File
@@ -4,25 +4,19 @@ use reqwest::Method;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use sha2::{Digest, Sha256};
// nostr 0.36 alias — required for cross-version bridging with buzz-sdk.
use crate::app_state::AppState;
const DEFAULT_RELAY_WS_URL: &str = "ws://localhost:3000";
// A reached-but-malformed 2xx body is NOT a connectivity failure, so this
// message must never carry the "relay unreachable:" prefix the frontend
// classifier keys on. Extracted to a const so a test can pin that contract.
const MALFORMED_RESPONSE_MESSAGE: &str = "relay returned malformed response: not valid JSON";
fn configured_env_var(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn relay_ws_url() -> String {
configured_env_var("BUZZ_RELAY_URL")
.or_else(|| option_env!("BUZZ_DESKTOP_BUILD_RELAY_URL").map(str::to_string))
@@ -242,10 +236,18 @@ fn extract_retry_in_hint(body: &str) -> Option<u64> {
}
pub async fn relay_error_message(response: reqwest::Response) -> String {
relay_error_message_for(response, crate::relay_admission::WORKSPACE_PRINCIPAL).await
}
pub(crate) async fn relay_error_message_for(
response: reqwest::Response,
principal: &str,
) -> String {
let status = response.status();
// Check for intercepted/proxy responses before reading the body.
let final_host = response.url().host_str().unwrap_or("").to_string();
let endpoint = response.url().path().to_string();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
@@ -274,7 +276,7 @@ pub async fn relay_error_message(response: reqwest::Response) -> String {
// must see the same capped value — a single policy point prevents the TS
// gate from receiving an uncapped hint from an untrusted relay.
let capped_hint = hint.map(|s| s.min(crate::relay_admission::MAX_HINT_SECONDS));
crate::relay_admission::activate_rate_limit(capped_hint);
crate::relay_admission::activate_rate_limit_for_endpoint(principal, &endpoint, capped_hint);
if let Some(secs) = capped_hint {
return format!("relay rate-limited: retry in {secs}s");
}
@@ -318,6 +320,14 @@ pub async fn query_relay_at(
filters: &[serde_json::Value],
) -> Result<Vec<nostr::Event>, String> {
crate::relay_admission::wait_for_rate_limit().await;
query_relay_at_after_admission(state, api_base_url, filters).await
}
pub(super) async fn query_relay_at_after_admission(
state: &AppState,
api_base_url: &str,
filters: &[serde_json::Value],
) -> Result<Vec<nostr::Event>, String> {
let url = format!("{}/query", api_base_url);
let body_bytes =
serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?;
@@ -347,7 +357,8 @@ pub async fn query_relay_at_with_keys(
keys: &Keys,
auth_tag: Option<&str>,
) -> Result<Vec<nostr::Event>, String> {
crate::relay_admission::wait_for_rate_limit().await;
let principal = keys.public_key().to_hex();
crate::relay_admission::wait_for_rate_limit_for(&principal).await;
let url = format!("{}/query", api_base_url);
let body_bytes =
serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?;
@@ -366,7 +377,7 @@ pub async fn query_relay_at_with_keys(
.await
.map_err(|e| classify_request_error(&e))?;
if !response.status().is_success() {
return Err(relay_error_message(response).await);
return Err(relay_error_message_for(response, &principal).await);
}
parse_json_response(response).await
}
@@ -445,7 +456,8 @@ pub async fn sync_managed_agent_profile(
avatar_url: Option<&str>,
auth_tag: Option<&str>, // NIP-OA auth tag JSON
) -> Result<(), String> {
crate::relay_admission::wait_for_rate_limit().await;
let principal = agent_keys.public_key().to_hex();
crate::relay_admission::wait_for_rate_limit_for(&principal).await;
// Build a signed kind:0 profile event (with optional NIP-OA auth tag).
let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?;
let event_json = event.as_json();
@@ -470,7 +482,7 @@ pub async fn sync_managed_agent_profile(
.map_err(|e| classify_request_error(&e))?;
if !response.status().is_success() {
let msg = relay_error_message(response).await;
let msg = relay_error_message_for(response, &principal).await;
return Err(format!(
"Could not sync the agent's profile metadata: {msg}"
));
@@ -532,6 +544,8 @@ pub struct AgentProfileInfo {
// ── Signed-event submission ─────────────────────────────────────────────────
mod interactive;
pub use interactive::query_relay_interactive;
mod submit;
pub use submit::{
submit_event, submit_event_at_with_keys, submit_signed_event_at_with_keys, SubmitEventResponse,
@@ -564,7 +578,8 @@ pub async fn submit_signed_event_with_keys(
if event.pubkey != keys.public_key() {
return Err("signed event does not match the publishing identity".to_string());
}
crate::relay_admission::wait_for_rate_limit().await;
let principal = keys.public_key().to_hex();
crate::relay_admission::wait_for_rate_limit_for(&principal).await;
let url = format!("{}/events", relay_api_base_url_with_override(state));
let body_bytes = event.as_json().into_bytes();
crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "signed event submit (keys)")?;
@@ -586,7 +601,7 @@ pub async fn submit_signed_event_with_keys(
.map_err(|e| classify_request_error(&e))?;
if !response.status().is_success() {
return Err(relay_error_message(response).await);
return Err(relay_error_message_for(response, &principal).await);
}
let result: SubmitEventResponse = parse_json_response(response).await?;
@@ -646,7 +661,7 @@ mod tests {
// ── relay_error_message: hint capping ────────────────────────────────────
//
// Verify that an oversized relay hint is capped in the returned message
// string, not just inside `activate_rate_limit()`. This guarantees every
// string, not just inside `activate_rate_limit_for()`. This guarantees every
// consumer — including the TS gate via `applyTauriRateLimitIfNeeded` —
// receives the capped value rather than the raw untrusted relay value.
@@ -0,0 +1,13 @@
use super::{query_relay_at_after_admission, relay_api_base_url_with_override};
use crate::app_state::AppState;
/// Execute an owner-authenticated relay query for a user-facing surface.
/// Unlike background work, this returns after a short admission wait so the UI
/// can show relay back-pressure rather than remaining pending for minutes.
pub async fn query_relay_interactive(
state: &AppState,
filters: &[serde_json::Value],
) -> Result<Vec<nostr::Event>, String> {
crate::relay_admission::wait_for_interactive_rate_limit().await?;
query_relay_at_after_admission(state, &relay_api_base_url_with_override(state), filters).await
}
+3 -2
View File
@@ -22,7 +22,8 @@ pub async fn submit_signed_event_at_with_keys(
if event.pubkey != keys.public_key() {
return Err("signed event does not match the publishing identity".to_string());
}
crate::relay_admission::wait_for_rate_limit().await;
let principal = keys.public_key().to_hex();
crate::relay_admission::wait_for_rate_limit_for(&principal).await;
let url = format!("{}/events", api_base_url.trim_end_matches('/'));
let body_bytes = event.as_json().into_bytes();
crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?;
@@ -39,7 +40,7 @@ pub async fn submit_signed_event_at_with_keys(
.map_err(|e| classify_request_error(&e))?;
if !response.status().is_success() {
return Err(relay_error_message(response).await);
return Err(super::relay_error_message_for(response, &principal).await);
}
let result: SubmitEventResponse = parse_json_response(response).await?;
+163 -36
View File
@@ -1,31 +1,32 @@
//! Admission gate for relay HTTP bridge requests.
//!
//! When the relay answers 429, every relay-backed HTTP request must hold new
//! sends until the quota window clears — matching the TS-side gate in
//! `relayRateLimitGate.ts` that already governs WebSocket operations.
//! Relay quotas are scoped to the authenticated principal. When the relay
//! answers 429, requests authenticated as that same principal must hold new
//! sends until its quota window clears — matching the TS-side gate in
//! `relayRateLimitGate.ts` that already governs owner WebSocket operations.
//!
//! **Coverage:** all entry points in `relay.rs` (`query_relay_at`,
//! `submit_event`, `submit_signed_event`, `submit_signed_event_with_keys`,
//! `sync_managed_agent_profile`) and the three previously-direct senders
//! (`submit_engram_event` in snapshot import + team_snapshot, huddle STT)
//! all call `wait_for_rate_limit()` before `.send()`.
//! **Coverage:** owner-authenticated entry points use `wait_for_rate_limit()`;
//! explicit-key entry points use `wait_for_rate_limit_for(pubkey)`. Every 429
//! must arm the gate under the same principal used by its request.
//!
//! **Media upload/download and `/info`** call `relay_error_message()` on
//! non-200 responses, so their 429s arm the shared gate as conservative
//! back-off (any relay overload signal is worth honouring across domains).
//! They do not call `wait_for_rate_limit()` themselves — their operations
//! are driven by user-initiated file transfers rather than bridge event flow,
//! and they have independent retry logic.
//! **Media upload/download and `/info`** are owner-authenticated and call
//! `relay_error_message()` on non-200 responses, so their 429s arm the owner
//! gate as conservative back-off. They do not wait themselves because their
//! operations are user-initiated file transfers with independent retry logic.
//!
//! **Community scope:** the gate is reset on every `apply_workspace` call,
//! mirroring the TS gate's `resetRateLimitGate()` on community switch in
//! **Community scope:** all principal gates are reset on every `apply_workspace`
//! call, mirroring the TS gate's `resetRateLimitGate()` on community switch in
//! `useCommunityInit.ts`. A 429 from community A cannot stall community B.
//!
//! Mirrors the TS gate's semantics: overlapping hints never shrink the window,
//! and a hint-less 429 arms the same 10-second default.
//! Mirrors the TS gate's semantics: overlapping hints never shrink a
//! principal's window, and a hint-less 429 arms the same 10-second default.
use std::sync::Mutex;
use tokio::time::{sleep_until, Duration, Instant};
use std::{collections::HashMap, sync::Mutex};
use tokio::time::{sleep_until, timeout, Duration, Instant};
/// Interactive reads should surface relay back-pressure instead of leaving a
/// panel apparently empty for the relay's full quota window.
const INTERACTIVE_WAIT_LIMIT: Duration = Duration::from_secs(5);
/// Minimum gate duration when the relay provides no `retry in Ns` hint.
/// Deliberately equal to `DEFAULT_RATE_LIMIT_SECONDS` in `relayRateLimitGate.ts`
@@ -40,9 +41,25 @@ const DEFAULT_RATE_LIMIT_SECONDS: u64 = 10;
/// `applyTauriRateLimitIfNeeded`) sees the same capped value.
pub const MAX_HINT_SECONDS: u64 = 300;
static GATE_EXPIRY: Mutex<Option<Instant>> = Mutex::new(None);
static GATE_EXPIRIES: std::sync::LazyLock<Mutex<HashMap<String, Instant>>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
// The gate is process-wide, so every test that can arm it must serialize.
fn principal_key(principal: &str) -> String {
principal.to_ascii_lowercase()
}
pub(crate) const WORKSPACE_PRINCIPAL: &str = "workspace-owner";
#[cfg(test)]
pub fn activate_rate_limit(retry_in_seconds: Option<u64>) {
activate_rate_limit_for(WORKSPACE_PRINCIPAL, retry_in_seconds);
}
pub async fn wait_for_rate_limit() {
wait_for_rate_limit_for(WORKSPACE_PRINCIPAL).await;
}
// The principal map is process-wide, so every test that can arm it must serialize.
#[cfg(test)]
pub(crate) static TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
@@ -53,7 +70,16 @@ pub(crate) static TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::cons
/// `DEFAULT_RATE_LIMIT_SECONDS`. The expiry only ever moves forward: a shorter
/// hint arriving under a longer active window is ignored, so overlapping 429s
/// never schedule a premature retry.
pub fn activate_rate_limit(retry_in_seconds: Option<u64>) {
#[cfg(test)]
pub fn activate_rate_limit_for(principal: &str, retry_in_seconds: Option<u64>) {
activate_rate_limit_for_endpoint(principal, "unknown", retry_in_seconds);
}
pub fn activate_rate_limit_for_endpoint(
principal: &str,
endpoint: &str,
retry_in_seconds: Option<u64>,
) {
let secs = match retry_in_seconds {
Some(s) if s > 0 => s.min(MAX_HINT_SECONDS),
_ => DEFAULT_RATE_LIMIT_SECONDS,
@@ -61,24 +87,37 @@ pub fn activate_rate_limit(retry_in_seconds: Option<u64>) {
let new_expiry = Instant::now()
.checked_add(Duration::from_secs(secs))
.unwrap_or_else(|| Instant::now() + Duration::from_secs(DEFAULT_RATE_LIMIT_SECONDS));
let mut guard = GATE_EXPIRY.lock().unwrap_or_else(|e| e.into_inner());
match *guard {
Some(current) if new_expiry <= current => {}
_ => *guard = Some(new_expiry),
let mut guard = GATE_EXPIRIES.lock().unwrap_or_else(|e| e.into_inner());
let expiry = guard.entry(principal_key(principal)).or_insert(new_expiry);
if new_expiry > *expiry {
*expiry = new_expiry;
}
eprintln!(
"buzz-desktop: relay rate-limit gate armed: endpoint={endpoint} principal={} retry_in_seconds={secs}",
if principal == WORKSPACE_PRINCIPAL {
"workspace-owner"
} else {
"explicit-key"
},
);
}
/// Wait until the admission gate is clear.
/// Wait until this relay principal's admission gate is clear.
///
/// Returns immediately when no gate is active. Loops after sleeping because a
/// concurrent 429 may extend the expiry while this caller is parked.
pub async fn wait_for_rate_limit() {
pub async fn wait_for_rate_limit_for(principal: &str) {
let principal = principal_key(principal);
loop {
let expiry = {
let guard = GATE_EXPIRY.lock().unwrap_or_else(|e| e.into_inner());
match *guard {
let mut guard = GATE_EXPIRIES.lock().unwrap_or_else(|e| e.into_inner());
match guard.get(&principal).copied() {
Some(expiry) if expiry > Instant::now() => Some(expiry),
_ => None,
Some(_) => {
guard.remove(&principal);
None
}
None => None,
}
};
match expiry {
@@ -88,27 +127,58 @@ pub async fn wait_for_rate_limit() {
}
}
/// Wait briefly for the workspace owner's gate, then return a typed error so
/// an interactive surface can render back-pressure instead of hanging.
pub async fn wait_for_interactive_rate_limit() -> Result<(), String> {
match timeout(INTERACTIVE_WAIT_LIMIT, wait_for_rate_limit()).await {
Ok(()) => Ok(()),
Err(_) => {
let remaining = remaining_seconds_for(WORKSPACE_PRINCIPAL).unwrap_or_default();
Err(format!(
"relay rate-limited: retry in {remaining}s; try again shortly"
))
}
}
}
fn remaining_seconds_for(principal: &str) -> Option<u64> {
let principal = principal_key(principal);
let now = Instant::now();
let mut guard = GATE_EXPIRIES.lock().unwrap_or_else(|e| e.into_inner());
match guard.get(&principal).copied() {
Some(expiry) if expiry > now => Some((expiry - now).as_secs().max(1)),
Some(_) => {
guard.remove(&principal);
None
}
None => None,
}
}
/// Reset the gate on a workspace/community change.
///
/// Called by `apply_workspace` to ensure a 429 from community A does not stall
/// requests to community B. Mirrors `resetRateLimitGate()` in
/// `useCommunityInit.ts`.
pub fn reset_gate_for_workspace_change() {
*GATE_EXPIRY.lock().unwrap_or_else(|e| e.into_inner()) = None;
GATE_EXPIRIES
.lock()
.unwrap_or_else(|e| e.into_inner())
.clear();
}
/// Reset the gate. Test-only: production never clears an armed window early
/// except via `reset_gate_for_workspace_change`.
#[cfg(test)]
pub fn reset_rate_limit_gate() {
*GATE_EXPIRY.lock().unwrap_or_else(|e| e.into_inner()) = None;
reset_gate_for_workspace_change();
}
#[cfg(test)]
mod tests {
use super::*;
// The gate is a process-wide static shared by every test in this binary,
// The principal map is a process-wide static shared by every test in this binary,
// so all tests that arm it serialize on one async lock to keep expiries
// from bleeding between parallel test threads.
@@ -125,6 +195,33 @@ mod tests {
);
}
#[tokio::test(start_paused = true)]
async fn interactive_wait_returns_typed_error_after_five_seconds() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
activate_rate_limit(Some(60));
let start = Instant::now();
let error = wait_for_interactive_rate_limit().await.unwrap_err();
assert_eq!(Instant::now() - start, INTERACTIVE_WAIT_LIMIT);
assert_eq!(error, "relay rate-limited: retry in 55s; try again shortly");
reset_rate_limit_gate();
}
#[tokio::test(start_paused = true)]
async fn interactive_wait_succeeds_when_gate_clears_within_limit() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
activate_rate_limit(Some(2));
let start = Instant::now();
wait_for_interactive_rate_limit().await.unwrap();
assert_eq!(Instant::now() - start, Duration::from_secs(2));
reset_rate_limit_gate();
}
#[tokio::test(start_paused = true)]
async fn hintless_429_arms_the_ten_second_default() {
let _serial = TEST_SERIAL.lock().await;
@@ -257,6 +354,36 @@ mod tests {
);
}
#[tokio::test(start_paused = true)]
async fn agent_gate_does_not_block_workspace_owner_reads() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
activate_rate_limit_for("agent-pubkey", Some(60));
let start = Instant::now();
wait_for_rate_limit().await;
assert_eq!(
Instant::now(),
start,
"an agent's HTTP quota window must not park owner-authenticated thread reads"
);
reset_rate_limit_gate();
}
#[tokio::test(start_paused = true)]
async fn same_principal_still_waits_for_its_gate() {
let _serial = TEST_SERIAL.lock().await;
reset_rate_limit_gate();
activate_rate_limit_for("agent-pubkey", Some(5));
let start = Instant::now();
wait_for_rate_limit_for("AGENT-PUBKEY").await;
assert_eq!(Instant::now() - start, Duration::from_secs(5));
reset_rate_limit_gate();
}
/// A 429 on one admission-gated path withholds sends on a different path
/// until the hinted window expires.
///
@@ -323,7 +450,7 @@ mod tests {
/// then rechecks, finds the gate clear, and proceeds.
///
/// This documents the contract: `sleep_until` is already scheduled against
/// A's expiry; the reset clears `GATE_EXPIRY` but cannot cancel an in-flight
/// A's expiry; the reset clears `GATE_EXPIRIES` but cannot cancel an in-flight
/// sleep. The recheck loop in `wait_for_rate_limit` then sees `None` and
/// returns. Net effect: the waiter observes at most one full window, which
/// is the same bound as if the workspace had not changed.
@@ -150,7 +150,9 @@ export const ChannelPane = React.memo(function ChannelPane({
threadAllMessages,
threadHeadMessage,
threadMessages,
threadMessagesError = null,
threadMessagesPending = false,
onRetryThreadMessages,
threadPanelWidthPx,
threadScrollTargetId,
threadTypingPubkeys,
@@ -817,7 +819,9 @@ export const ChannelPane = React.memo(function ChannelPane({
videoReviewPresentation={threadVideoReviewPresentation}
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
threadRepliesError={threadMessagesError}
threadRepliesPending={threadMessagesPending}
onRetryThreadReplies={onRetryThreadMessages}
threadUnreadCount={threadUnreadCounts?.get(
threadHeadMessage.id,
)}
@@ -160,7 +160,9 @@ export type ChannelPaneProps = {
threadHeadMessage: TimelineMessage | null;
threadAllMessages: TimelineMessage[];
threadMessages: MainTimelineEntry[];
threadMessagesError?: string | null;
threadMessagesPending?: boolean;
onRetryThreadMessages?: () => void;
threadPanelWidthPx: number;
threadTypingPubkeys: string[];
threadReplyTargetMessage: TimelineMessage | null;
@@ -944,7 +944,15 @@ export function ChannelScreen({
threadAllMessages={displayedThreadAllMessages}
threadHeadMessage={displayedThreadHeadMessage}
threadMessages={displayedThreadMessages}
threadMessagesError={
threadRepliesQuery.error instanceof Error
? threadRepliesQuery.error.message
: null
}
threadMessagesPending={threadRepliesQuery.isPending}
onRetryThreadMessages={() => {
void threadRepliesQuery.refetch();
}}
threadPanelWidthPx={threadPanelWidthPx}
threadTypingPubkeys={threadTypingPubkeys}
threadReplyTargetMessage={displayedThreadReplyTargetMessage}
@@ -1,6 +1,5 @@
import * as React from "react";
import { ArrowDown } from "lucide-react";
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
import { HuddleTranscriptIntro } from "@/features/huddle/components/HuddleTranscriptIntro";
import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys";
@@ -43,6 +42,7 @@ import { Separator } from "@/shared/ui/separator";
import { ComposerActivityAccessory } from "./ComposerActivityAccessory";
import { ComposerDockBackdrop } from "./ComposerDockBackdrop";
import { MessageComposer } from "./MessageComposer";
import { ThreadRepliesLoadState } from "./ThreadRepliesLoadState";
import { ThreadMessageSkeleton } from "./MessageThreadPanelSkeleton";
import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
@@ -52,7 +52,6 @@ import { useComposerHeightPadding } from "./useComposerHeightPadding";
import { useStableSendToChannel } from "./useStableSendToChannel";
import { useAnchoredScroll } from "./useAnchoredScroll";
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
type MessageThreadPanelProps = ThreadPanelLayoutProps & {
channel: Channel | null;
channelId: string | null;
@@ -109,7 +108,9 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
scrollTargetId: string | null;
threadHead: TimelineMessage | null;
threadReplies: MainTimelineEntry[];
threadRepliesError?: string | null;
threadRepliesPending?: boolean;
onRetryThreadReplies?: () => void;
threadUnreadCount?: number;
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
threadTypingPubkeys: string[];
@@ -131,10 +132,8 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
/** Called when the thread-composer auto-submit fires so the parent can clear the trigger. */
onAutoSubmitComplete?: () => void;
};
const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = [];
const THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM = 0;
function hasLaterVisibleSibling(
entries: readonly MainTimelineEntry[],
entryIndex: number,
@@ -185,7 +184,6 @@ function getActiveContinuationDepths({
return depths;
}
export function MessageThreadPanel({
channel,
channelId,
@@ -230,7 +228,9 @@ export function MessageThreadPanel({
threadHead,
videoReviewPresentation,
threadReplies,
threadRepliesError = null,
threadRepliesPending = false,
onRetryThreadReplies,
threadUnreadCount,
threadReplyUnreadCounts,
threadTypingPubkeys,
@@ -647,7 +647,12 @@ export function MessageThreadPanel({
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-0")}
data-testid="message-thread-replies"
>
{threadRepliesPending && !isHuddleTranscript ? (
{threadRepliesError && !isHuddleTranscript ? (
<ThreadRepliesLoadState
error={threadRepliesError}
onRetry={onRetryThreadReplies}
/>
) : threadRepliesPending && !isHuddleTranscript ? (
<div
className="space-y-2.5 pt-1"
data-testid="message-thread-replies-loading"
@@ -0,0 +1,24 @@
import { Button } from "@/shared/ui/button";
export function ThreadRepliesLoadState({
error,
onRetry,
}: {
error: string;
onRetry?: () => void;
}) {
return (
<div
className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm"
data-testid="message-thread-replies-error"
role="alert"
>
<p>{error}</p>
{onRetry ? (
<Button className="mt-2" onClick={onRetry} size="sm" variant="outline">
Try again
</Button>
) : null}
</div>
);
}