mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
perf: eliminate rendering jank across desktop, relay, and ACP (#106)
This commit is contained in:
@@ -604,12 +604,15 @@ pub async fn run_prompt_task(
|
||||
return;
|
||||
};
|
||||
|
||||
// 💬 — awaited inline so it completes before the prompt fires.
|
||||
// This guarantees add-before-remove ordering for 💬: the guard's
|
||||
// cleanup (spawned on drop) always runs after this returns.
|
||||
// 👀 is fire-and-forget from main.rs (see race note in guard docs).
|
||||
// 💬 — fire-and-forget so the prompt fires immediately.
|
||||
// The guard's cleanup (spawned on drop) removes 💬 after the turn completes.
|
||||
// A brief race where 💬 appears slightly after the agent starts is acceptable.
|
||||
if !reaction_ids.is_empty() {
|
||||
react_working(&ctx.rest_client, &reaction_ids).await;
|
||||
let rest = ctx.rest_client.clone();
|
||||
let ids = reaction_ids.clone();
|
||||
tokio::spawn(async move {
|
||||
react_working(&rest, &ids).await;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Send the actual prompt ────────────────────────────────────────────
|
||||
@@ -1035,8 +1038,8 @@ fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) {
|
||||
///
|
||||
/// ## Ordering
|
||||
///
|
||||
/// 💬 (`react_working`) is awaited inline before the prompt fires, so it is
|
||||
/// guaranteed to precede cleanup. No race possible.
|
||||
/// 💬 (`react_working`) is fire-and-forget (spawned before the prompt fires).
|
||||
/// A brief race where 💬 appears slightly after the agent starts is acceptable.
|
||||
///
|
||||
/// 👀 (`react_seen`) is fire-and-forget from `main.rs` at queue-push time.
|
||||
/// On rare fast-failure paths (e.g., `session_new` error on an idle agent),
|
||||
|
||||
@@ -37,7 +37,7 @@ pub use event::EventQuery;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
use sqlx::{MySqlPool, Row};
|
||||
use sqlx::{MySqlPool, QueryBuilder, Row};
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -78,31 +78,42 @@ pub async fn insert_mentions(
|
||||
let channel_id_bytes = channel_id.map(|id| id.as_bytes().to_vec());
|
||||
let kind = event.kind.as_u16() as u32;
|
||||
|
||||
for pubkey_hex in p_tags {
|
||||
// Validate: must be exactly 64 hex characters (32-byte pubkey)
|
||||
if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
tracing::debug!(
|
||||
event_id = %event.id,
|
||||
invalid_ptag = pubkey_hex,
|
||||
"skipping malformed p-tag in mentions insert"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Normalize to lowercase — queries use hex::encode which produces lowercase.
|
||||
let pubkey_lower = pubkey_hex.to_ascii_lowercase();
|
||||
sqlx::query(
|
||||
"INSERT IGNORE INTO event_mentions \
|
||||
(pubkey_hex, event_id, event_created_at, channel_id, event_kind) \
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&pubkey_lower)
|
||||
.bind(event_id_bytes.as_slice())
|
||||
.bind(created_at)
|
||||
.bind(channel_id_bytes.as_deref())
|
||||
.bind(kind)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// Validate and normalize pubkeys, logging any malformed ones.
|
||||
let valid_pubkeys: Vec<String> = p_tags
|
||||
.into_iter()
|
||||
.filter(|pk| {
|
||||
if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
tracing::debug!(
|
||||
event_id = %event.id,
|
||||
invalid_ptag = pk,
|
||||
"skipping malformed p-tag in mentions insert"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.map(|pk| pk.to_ascii_lowercase())
|
||||
.collect();
|
||||
|
||||
if valid_pubkeys.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Single multi-row INSERT IGNORE — one round-trip regardless of mention count.
|
||||
let mut qb: QueryBuilder<'_, sqlx::MySql> = QueryBuilder::new(
|
||||
"INSERT IGNORE INTO event_mentions \
|
||||
(pubkey_hex, event_id, event_created_at, channel_id, event_kind) ",
|
||||
);
|
||||
|
||||
qb.push_values(&valid_pubkeys, |mut b, pubkey| {
|
||||
b.push_bind(pubkey.as_str())
|
||||
.push_bind(event_id_bytes.as_slice())
|
||||
.push_bind(created_at)
|
||||
.push_bind(channel_id_bytes.as_deref())
|
||||
.push_bind(kind);
|
||||
});
|
||||
|
||||
qb.build().execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -100,22 +100,15 @@ pub(crate) async fn dispatch_persistent_event(
|
||||
|
||||
// Skip search indexing for NIP-17 gift wraps — content is ciphertext,
|
||||
// and indexing would leak #p tag metadata into the search index.
|
||||
if kind_u32 != KIND_GIFT_WRAP {
|
||||
let search = Arc::clone(&state.search);
|
||||
let stored_for_search = stored_event.clone();
|
||||
tokio::spawn(async move {
|
||||
let t = std::time::Instant::now();
|
||||
match search.index_event(&stored_for_search).await {
|
||||
Ok(()) => {
|
||||
metrics::histogram!("sprout_search_index_seconds")
|
||||
.record(t.elapsed().as_secs_f64());
|
||||
}
|
||||
Err(e) => {
|
||||
metrics::counter!("sprout_search_index_errors_total").increment(1);
|
||||
error!(event_id = %stored_for_search.event.id.to_hex(), "Search index failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
// Use the bounded mpsc channel to prevent OOM if Typesense is slow/down.
|
||||
if kind_u32 != KIND_GIFT_WRAP
|
||||
&& state
|
||||
.search_index_tx
|
||||
.try_send(stored_event.clone())
|
||||
.is_err()
|
||||
{
|
||||
metrics::counter!("sprout_search_index_errors_total").increment(1);
|
||||
warn!(event_id = %event_id_hex, "Search index channel full — dropping event");
|
||||
}
|
||||
|
||||
let audit = Arc::clone(&state.audit);
|
||||
@@ -651,8 +644,39 @@ async fn check_channel_membership(
|
||||
conn_id: uuid::Uuid,
|
||||
event_id_hex: &str,
|
||||
) -> Result<(), String> {
|
||||
let cache_key = (ch_id, pubkey_bytes.to_vec());
|
||||
|
||||
// Check cache first.
|
||||
if let Some(is_member) = state.membership_cache.get(&cache_key) {
|
||||
if is_member {
|
||||
return Ok(());
|
||||
}
|
||||
// Cached as non-member — still need to check open channel fallback.
|
||||
let is_open = match state.db.get_channel(ch_id).await {
|
||||
Ok(ch) => ch.visibility == "open",
|
||||
Err(e) => {
|
||||
tracing::warn!(conn_id = %conn_id, channel = %ch_id, error = %e,
|
||||
"channel lookup failed during membership fallback, denying");
|
||||
false
|
||||
}
|
||||
};
|
||||
return if is_open {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(RelayMessage::ok(
|
||||
event_id_hex,
|
||||
false,
|
||||
"restricted: not a channel member",
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
// Cache miss — query DB.
|
||||
match state.db.is_member(ch_id, pubkey_bytes).await {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(true) => {
|
||||
state.membership_cache.insert(cache_key, true);
|
||||
Ok(())
|
||||
}
|
||||
Ok(false) => {
|
||||
let is_open = match state.db.get_channel(ch_id).await {
|
||||
Ok(ch) => ch.visibility == "open",
|
||||
@@ -663,8 +687,11 @@ async fn check_channel_membership(
|
||||
}
|
||||
};
|
||||
if is_open {
|
||||
// Don't cache — non-members in open channels are always allowed,
|
||||
// and caching false would force a redundant get_channel on every hit.
|
||||
Ok(())
|
||||
} else {
|
||||
state.membership_cache.insert(cache_key, false);
|
||||
Err(RelayMessage::ok(
|
||||
event_id_hex,
|
||||
false,
|
||||
@@ -890,10 +917,12 @@ async fn resolve_nip10_thread_meta(
|
||||
let parent_bytes =
|
||||
hex::decode(&parent_hex).map_err(|_| "invalid parent event ID hex".to_string())?;
|
||||
|
||||
let parent_event = state
|
||||
.db
|
||||
.get_event_by_id(&parent_bytes)
|
||||
.await
|
||||
let (parent_event_result, parent_meta_result) = tokio::join!(
|
||||
state.db.get_event_by_id(&parent_bytes),
|
||||
state.db.get_thread_metadata_by_event(&parent_bytes),
|
||||
);
|
||||
|
||||
let parent_event = parent_event_result
|
||||
.map_err(|e| format!("db error looking up parent: {e}"))?
|
||||
.ok_or_else(|| "reply parent not found".to_string())?;
|
||||
|
||||
@@ -913,11 +942,8 @@ async fn resolve_nip10_thread_meta(
|
||||
let client_root_bytes =
|
||||
hex::decode(&root_hex).map_err(|_| "invalid root event ID hex".to_string())?;
|
||||
|
||||
let parent_meta = state
|
||||
.db
|
||||
.get_thread_metadata_by_event(&parent_bytes)
|
||||
.await
|
||||
.map_err(|e| format!("db error looking up thread metadata: {e}"))?;
|
||||
let parent_meta =
|
||||
parent_meta_result.map_err(|e| format!("db error looking up thread metadata: {e}"))?;
|
||||
|
||||
let (final_root_bytes, root_created, depth) = match parent_meta {
|
||||
Some(meta) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ use uuid::Uuid;
|
||||
use deadpool_redis;
|
||||
use sprout_audit::AuditService;
|
||||
use sprout_auth::AuthService;
|
||||
use sprout_core::event::StoredEvent;
|
||||
use sprout_db::Db;
|
||||
use sprout_media::MediaStorage;
|
||||
use sprout_pubsub::PubSubManager;
|
||||
@@ -151,6 +152,13 @@ pub struct AppState {
|
||||
/// consumer skips them to avoid double delivery. Entries expire after
|
||||
/// 60 seconds via moka's TTL eviction — bounded regardless of subscriber health.
|
||||
pub local_event_ids: Arc<moka::sync::Cache<[u8; 32], ()>>,
|
||||
/// Membership cache: (channel_id, pubkey_bytes) → is_member.
|
||||
/// Short TTL (10s) — membership changes are rare but must propagate.
|
||||
pub membership_cache: Arc<moka::sync::Cache<(Uuid, Vec<u8>), bool>>,
|
||||
|
||||
/// Bounded channel for search indexing — prevents OOM if Typesense is slow/down.
|
||||
/// Capacity 1000: at ~1KB/event that's ~1MB of backlog before we start dropping.
|
||||
pub search_index_tx: mpsc::Sender<StoredEvent>,
|
||||
/// Media storage client (S3/MinIO).
|
||||
pub media_storage: Arc<MediaStorage>,
|
||||
/// Set to `true` on SIGTERM — readiness probe returns 503.
|
||||
@@ -176,6 +184,30 @@ impl AppState {
|
||||
) -> Self {
|
||||
let max_connections = config.max_connections;
|
||||
let max_concurrent_handlers = config.max_concurrent_handlers;
|
||||
let search_arc = Arc::new(search);
|
||||
|
||||
let (search_index_tx, mut search_index_rx) = mpsc::channel::<StoredEvent>(1000);
|
||||
let search_for_worker = Arc::clone(&search_arc);
|
||||
tokio::spawn(async move {
|
||||
while let Some(stored_event) = search_index_rx.recv().await {
|
||||
let t = std::time::Instant::now();
|
||||
match search_for_worker.index_event(&stored_event).await {
|
||||
Ok(()) => {
|
||||
metrics::histogram!("sprout_search_index_seconds")
|
||||
.record(t.elapsed().as_secs_f64());
|
||||
}
|
||||
Err(e) => {
|
||||
metrics::counter!("sprout_search_index_errors_total").increment(1);
|
||||
tracing::error!(
|
||||
event_id = %stored_event.event.id.to_hex(),
|
||||
"Search index failed: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::warn!("search index worker exited (expected on shutdown)");
|
||||
});
|
||||
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
db,
|
||||
@@ -183,7 +215,7 @@ impl AppState {
|
||||
audit: Arc::new(audit),
|
||||
pubsub,
|
||||
auth: Arc::new(auth),
|
||||
search: Arc::new(search),
|
||||
search: search_arc,
|
||||
sub_registry: Arc::new(SubscriptionRegistry::new()),
|
||||
conn_manager: Arc::new(ConnectionManager::new()),
|
||||
conn_semaphore: Arc::new(Semaphore::new(max_connections)),
|
||||
@@ -198,6 +230,14 @@ impl AppState {
|
||||
.time_to_live(std::time::Duration::from_secs(60))
|
||||
.build(),
|
||||
),
|
||||
membership_cache: Arc::new(
|
||||
moka::sync::Cache::builder()
|
||||
.max_capacity(10_000)
|
||||
.time_to_live(std::time::Duration::from_secs(10))
|
||||
.build(),
|
||||
),
|
||||
|
||||
search_index_tx,
|
||||
media_storage: Arc::new(media_storage),
|
||||
shutting_down: Arc::new(AtomicBool::new(false)),
|
||||
started_at: Instant::now(),
|
||||
|
||||
@@ -150,8 +150,9 @@ export function useChannelsQuery() {
|
||||
return useQuery({
|
||||
queryKey: channelsQueryKey,
|
||||
queryFn: async () => sortChannels(await getChannels()),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 15_000,
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 60_000,
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,13 @@ import {
|
||||
KIND_SYSTEM_MESSAGE,
|
||||
} from "@/shared/constants/kinds";
|
||||
|
||||
const HEX_RE = /^[0-9a-f]+$/i;
|
||||
|
||||
const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
function isTimelineContentEvent(event: RelayEvent) {
|
||||
return (
|
||||
event.kind === KIND_STREAM_MESSAGE ||
|
||||
@@ -32,7 +39,7 @@ function getDeletionTargets(tags: string[][]) {
|
||||
tag[0] === "e" &&
|
||||
typeof tag[1] === "string" &&
|
||||
tag[1].length === 64 &&
|
||||
/^[0-9a-f]+$/i.test(tag[1]),
|
||||
HEX_RE.test(tag[1]),
|
||||
)
|
||||
.map((tag) => tag[1]);
|
||||
}
|
||||
@@ -44,7 +51,7 @@ function getReactionTargetId(tags: string[][]) {
|
||||
tag?.[0] === "e" &&
|
||||
typeof tag[1] === "string" &&
|
||||
tag[1].length === 64 &&
|
||||
/^[0-9a-f]+$/i.test(tag[1])
|
||||
HEX_RE.test(tag[1])
|
||||
) {
|
||||
return tag[1];
|
||||
}
|
||||
@@ -250,10 +257,7 @@ export function formatTimelineMessages(
|
||||
currentUserAvatarUrl,
|
||||
profiles,
|
||||
}),
|
||||
time: new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(event.created_at * 1_000)),
|
||||
time: TIME_FORMATTER.format(new Date(event.created_at * 1_000)),
|
||||
body: event.content,
|
||||
parentId: thread.parentId,
|
||||
rootId: thread.rootId,
|
||||
|
||||
@@ -31,7 +31,7 @@ function getHostname(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function DiffMessage({
|
||||
export default function DiffMessage({
|
||||
content,
|
||||
repoUrl,
|
||||
filePath,
|
||||
|
||||
@@ -231,7 +231,6 @@ export function MessageComposer({
|
||||
const cursorPosition = textarea.selectionStart ?? content.length;
|
||||
const existingMention = detectMentionQuery(content, cursorPosition);
|
||||
if (existingMention) {
|
||||
pendingSelectionRef.current = cursorPosition;
|
||||
setMentionStartIndex(existingMention.startIndex);
|
||||
setMentionQuery(existingMention.query);
|
||||
setMentionSelectedIndex(0);
|
||||
@@ -503,6 +502,7 @@ export function MessageComposer({
|
||||
],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: content triggers height recalc and pending selection restore
|
||||
React.useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
@@ -528,7 +528,7 @@ export function MessageComposer({
|
||||
textarea.setSelectionRange(pendingSelection, pendingSelection);
|
||||
pendingSelectionRef.current = null;
|
||||
}
|
||||
});
|
||||
}, [content]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!replyTarget || disabled) {
|
||||
|
||||
@@ -8,288 +8,323 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { DiffMessage } from "./DiffMessage";
|
||||
import { MessageActionBar } from "./MessageActionBar";
|
||||
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
|
||||
export function MessageRow({
|
||||
activeReplyTargetId = null,
|
||||
message,
|
||||
onToggleReaction,
|
||||
onReply,
|
||||
}: {
|
||||
activeReplyTargetId?: string | null;
|
||||
message: TimelineMessage;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
}) {
|
||||
const [hasAvatarError, setHasAvatarError] = React.useState(false);
|
||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [reactionErrorMessage, setReactionErrorMessage] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [reactionPending, setReactionPending] = React.useState(false);
|
||||
const visibleDepth = Math.min(message.depth, 6);
|
||||
const indentPx = visibleDepth * 28;
|
||||
const initials = message.author
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
export const MessageRow = React.memo(
|
||||
function MessageRow({
|
||||
activeReplyTargetId = null,
|
||||
highlighted = false,
|
||||
message,
|
||||
onToggleReaction,
|
||||
onReply,
|
||||
}: {
|
||||
activeReplyTargetId?: string | null;
|
||||
highlighted?: boolean;
|
||||
message: TimelineMessage;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
}) {
|
||||
const [hasAvatarError, setHasAvatarError] = React.useState(false);
|
||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [reactionErrorMessage, setReactionErrorMessage] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [reactionPending, setReactionPending] = React.useState(false);
|
||||
const visibleDepth = Math.min(message.depth, 6);
|
||||
const indentPx = visibleDepth * 28;
|
||||
const initials = message.author
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const getTag = (name: string) =>
|
||||
message.tags?.find((tag) => tag[0] === name)?.[1];
|
||||
const getTag = (name: string) =>
|
||||
message.tags?.find((tag) => tag[0] === name)?.[1];
|
||||
|
||||
const renderBody = () => {
|
||||
switch (message.kind) {
|
||||
case KIND_STREAM_MESSAGE_DIFF:
|
||||
return (
|
||||
<DiffMessage
|
||||
commitSha={getTag("commit")}
|
||||
content={message.body}
|
||||
description={getTag("description")}
|
||||
filePath={getTag("file")}
|
||||
onExpand={() => {
|
||||
setExpandedDiffId(message.id);
|
||||
}}
|
||||
repoUrl={getTag("repo")}
|
||||
truncated={getTag("truncated") === "true"}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Markdown className="max-w-3xl" content={message.body} tight />;
|
||||
}
|
||||
};
|
||||
|
||||
const reactions = [...(message.reactions ?? [])].sort((left, right) => {
|
||||
if (left.count !== right.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
|
||||
return left.emoji.localeCompare(right.emoji);
|
||||
});
|
||||
const canToggleReactions = Boolean(onToggleReaction && !message.pending);
|
||||
|
||||
const handleReactionSelect = React.useCallback(
|
||||
async (emoji: string) => {
|
||||
if (!onToggleReaction || reactionPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remove = reactions.some(
|
||||
(reaction) => reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||
);
|
||||
|
||||
setReactionErrorMessage(null);
|
||||
setReactionPending(true);
|
||||
|
||||
try {
|
||||
await onToggleReaction(message, emoji, remove);
|
||||
} catch (error) {
|
||||
const nextMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update the reaction.";
|
||||
setReactionErrorMessage(nextMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setReactionPending(false);
|
||||
}
|
||||
},
|
||||
[message, onToggleReaction, reactionPending, reactions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
||||
>
|
||||
{message.depth > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute bottom-1.5 left-3 top-1.5 rounded-full border-l border-border/70"
|
||||
style={{ left: `${Math.max(indentPx - 14, 12)}px` }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<article
|
||||
className={cn(
|
||||
"group/message flex gap-2.5 rounded-2xl px-2 py-1 transition-colors",
|
||||
message.highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
|
||||
activeReplyTargetId === message.id
|
||||
? "bg-muted/60 ring-1 ring-border"
|
||||
: "",
|
||||
)}
|
||||
data-message-id={message.id}
|
||||
data-testid="message-row"
|
||||
>
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
className="shrink-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-8 w-8 rounded-lg bg-secondary object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
|
||||
message.accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-8 w-8 shrink-0 rounded-lg bg-secondary object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
|
||||
message.accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
className="truncate rounded text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{message.author}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{message.author}
|
||||
</h3>
|
||||
)}
|
||||
{message.role ? (
|
||||
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{message.role}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<MessageActionBar
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
message={message}
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onReply={onReply}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactionPending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
{message.pending ? (
|
||||
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
|
||||
Sending
|
||||
</p>
|
||||
) : null}
|
||||
<p className="whitespace-nowrap">{message.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderBody()}
|
||||
{reactions.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{reactions.map((reaction: TimelineReaction) => (
|
||||
<button
|
||||
aria-label={`Toggle ${reaction.emoji} reaction`}
|
||||
aria-pressed={reaction.reactedByCurrentUser}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
|
||||
reaction.reactedByCurrentUser
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border/70 bg-muted/70 text-foreground/90",
|
||||
canToggleReactions
|
||||
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
: "cursor-default",
|
||||
)}
|
||||
disabled={!canToggleReactions || reactionPending}
|
||||
key={`${message.id}-${reaction.emoji}`}
|
||||
onClick={() => {
|
||||
if (!canToggleReactions) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleReactionSelect(reaction.emoji).catch(() => {
|
||||
return;
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>{reaction.emoji}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{reaction.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-1.5 text-xs text-destructive">
|
||||
{reactionErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{expandedDiffId === message.id ? (
|
||||
const renderBody = () => {
|
||||
switch (message.kind) {
|
||||
case KIND_STREAM_MESSAGE_DIFF:
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
Loading diff viewer…
|
||||
Loading diff…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DiffMessageExpanded
|
||||
<DiffMessage
|
||||
commitSha={getTag("commit")}
|
||||
content={message.body}
|
||||
description={getTag("description")}
|
||||
filePath={getTag("file")}
|
||||
onClose={() => {
|
||||
setExpandedDiffId(null);
|
||||
onExpand={() => {
|
||||
setExpandedDiffId(message.id);
|
||||
}}
|
||||
repoUrl={getTag("repo")}
|
||||
truncated={getTag("truncated") === "true"}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Markdown className="max-w-3xl" content={message.body} tight />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const reactions = [...(message.reactions ?? [])].sort((left, right) => {
|
||||
if (left.count !== right.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
|
||||
return left.emoji.localeCompare(right.emoji);
|
||||
});
|
||||
const canToggleReactions = Boolean(onToggleReaction && !message.pending);
|
||||
|
||||
const handleReactionSelect = React.useCallback(
|
||||
async (emoji: string) => {
|
||||
if (!onToggleReaction || reactionPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remove = reactions.some(
|
||||
(reaction) =>
|
||||
reaction.emoji === emoji && reaction.reactedByCurrentUser,
|
||||
);
|
||||
|
||||
setReactionErrorMessage(null);
|
||||
setReactionPending(true);
|
||||
|
||||
try {
|
||||
await onToggleReaction(message, emoji, remove);
|
||||
} catch (error) {
|
||||
const nextMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update the reaction.";
|
||||
setReactionErrorMessage(nextMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setReactionPending(false);
|
||||
}
|
||||
},
|
||||
[message, onToggleReaction, reactionPending, reactions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
||||
>
|
||||
{message.depth > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute bottom-1.5 left-3 top-1.5 rounded-full border-l border-border/70"
|
||||
style={{ left: `${Math.max(indentPx - 14, 12)}px` }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<article
|
||||
className={cn(
|
||||
"group/message flex gap-2.5 rounded-2xl px-2 py-1 transition-colors",
|
||||
highlighted ? "bg-primary/10 ring-1 ring-primary/30" : "",
|
||||
activeReplyTargetId === message.id
|
||||
? "bg-muted/60 ring-1 ring-border"
|
||||
: "",
|
||||
)}
|
||||
data-message-id={message.id}
|
||||
data-testid="message-row"
|
||||
>
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
className="shrink-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-8 w-8 rounded-lg bg-secondary object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
|
||||
message.accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : message.avatarUrl && !hasAvatarError ? (
|
||||
<img
|
||||
alt={`${message.author} avatar`}
|
||||
className="h-8 w-8 shrink-0 rounded-lg bg-secondary object-cover shadow-sm"
|
||||
data-testid="message-avatar-image"
|
||||
onError={() => {
|
||||
setHasAvatarError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={message.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
|
||||
message.accent
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
data-testid="message-avatar-fallback"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
className="truncate rounded text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{message.author}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{message.author}
|
||||
</h3>
|
||||
)}
|
||||
{message.role ? (
|
||||
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{message.role}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<MessageActionBar
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
message={message}
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onReply={onReply}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactionPending={reactionPending}
|
||||
reactions={reactions}
|
||||
/>
|
||||
{message.pending ? (
|
||||
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
|
||||
Sending
|
||||
</p>
|
||||
) : null}
|
||||
<p className="whitespace-nowrap">{message.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderBody()}
|
||||
{reactions.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{reactions.map((reaction: TimelineReaction) => (
|
||||
<button
|
||||
aria-label={`Toggle ${reaction.emoji} reaction`}
|
||||
aria-pressed={reaction.reactedByCurrentUser}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
|
||||
reaction.reactedByCurrentUser
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border/70 bg-muted/70 text-foreground/90",
|
||||
canToggleReactions
|
||||
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
: "cursor-default",
|
||||
)}
|
||||
disabled={!canToggleReactions || reactionPending}
|
||||
key={`${message.id}-${reaction.emoji}`}
|
||||
onClick={() => {
|
||||
if (!canToggleReactions) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleReactionSelect(reaction.emoji).catch(() => {
|
||||
return;
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span>{reaction.emoji}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{reaction.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-1.5 text-xs text-destructive">
|
||||
{reactionErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{expandedDiffId === message.id ? (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="p-3 text-sm text-muted-foreground">
|
||||
Loading diff viewer…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DiffMessageExpanded
|
||||
content={message.body}
|
||||
filePath={getTag("file")}
|
||||
onClose={() => {
|
||||
setExpandedDiffId(null);
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
// Callbacks (onReply, onToggleReaction) intentionally excluded: inline arrows
|
||||
// from parent create new refs every render — including them defeats memo.
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.message.id === next.message.id &&
|
||||
prev.message.pubkey === next.message.pubkey &&
|
||||
prev.message.body === next.message.body &&
|
||||
prev.message.author === next.message.author &&
|
||||
prev.message.avatarUrl === next.message.avatarUrl &&
|
||||
prev.message.accent === next.message.accent &&
|
||||
prev.message.time === next.message.time &&
|
||||
prev.message.depth === next.message.depth &&
|
||||
prev.message.kind === next.message.kind &&
|
||||
prev.message.pending === next.message.pending &&
|
||||
prev.message.reactions === next.message.reactions &&
|
||||
prev.message.tags === next.message.tags &&
|
||||
prev.message.role === next.message.role &&
|
||||
prev.highlighted === next.highlighted &&
|
||||
prev.activeReplyTargetId === next.activeReplyTargetId,
|
||||
);
|
||||
|
||||
MessageRow.displayName = "MessageRow";
|
||||
|
||||
@@ -109,11 +109,9 @@ export function MessageTimeline({
|
||||
) : (
|
||||
<MessageRow
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
highlighted={message.id === highlightedMessageId}
|
||||
key={message.id}
|
||||
message={{
|
||||
...message,
|
||||
highlighted: message.id === highlightedMessageId,
|
||||
}}
|
||||
message={message}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
/>
|
||||
|
||||
@@ -136,15 +136,15 @@ export function SearchDialog({
|
||||
onOpenResult,
|
||||
}: SearchDialogProps) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = React.useState("");
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const deferredQuery = React.useDeferredValue(query.trim());
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const channelLookup = React.useMemo(
|
||||
() => new Map(channels.map((channel) => [channel.id, channel])),
|
||||
[channels],
|
||||
);
|
||||
|
||||
const searchQuery = useSearchMessagesQuery(deferredQuery, {
|
||||
const searchQuery = useSearchMessagesQuery(debouncedQuery, {
|
||||
enabled: open,
|
||||
limit: 12,
|
||||
});
|
||||
@@ -166,6 +166,22 @@ export function SearchDialog({
|
||||
[onOpenChange, onOpenResult],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length < MIN_QUERY_LENGTH) {
|
||||
setDebouncedQuery("");
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setDebouncedQuery(trimmed);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
@@ -190,6 +206,7 @@ export function SearchDialog({
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setDebouncedQuery("");
|
||||
setSelectedIndex(0);
|
||||
return;
|
||||
}
|
||||
@@ -276,7 +293,7 @@ export function SearchDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{deferredQuery.length < MIN_QUERY_LENGTH ? (
|
||||
{debouncedQuery.length < MIN_QUERY_LENGTH ? (
|
||||
<SearchState
|
||||
description="Type at least two characters to search the relay-backed history for streams, forums, DMs, approvals, and agent updates."
|
||||
icon={MessagesSquare}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
const RECONNECT_BASE_DELAY_MS = 1_000;
|
||||
const RECONNECT_MAX_DELAY_MS = 30_000;
|
||||
const RECONNECT_REPLAY_SKEW_SECS = 5;
|
||||
const EVENT_BATCH_MS = 16;
|
||||
|
||||
export class RelayClient {
|
||||
private wsId: number | null = null;
|
||||
@@ -38,6 +39,8 @@ export class RelayClient {
|
||||
} | null = null;
|
||||
private subscriptions = new Map<string, RelaySubscription>();
|
||||
private pendingEvents = new Map<string, PendingEvent>();
|
||||
private eventBuffer: Array<{ subId: string; event: RelayEvent }> = [];
|
||||
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectListeners = new Set<() => void>();
|
||||
private hasConnectedOnce = false;
|
||||
private notifyReconnectListeners = false;
|
||||
@@ -494,7 +497,26 @@ export class RelayClient {
|
||||
subscription.lastSeenCreatedAt ?? 0,
|
||||
event.created_at,
|
||||
);
|
||||
subscription.onEvent(event);
|
||||
|
||||
this.eventBuffer.push({ subId, event });
|
||||
this.flushTimeout ??= window.setTimeout(
|
||||
() => this.flushEventBuffer(),
|
||||
EVENT_BATCH_MS,
|
||||
);
|
||||
}
|
||||
|
||||
private flushEventBuffer() {
|
||||
this.flushTimeout = null;
|
||||
const buffer = this.eventBuffer;
|
||||
this.eventBuffer = [];
|
||||
|
||||
// Re-lookup: subscriptions removed during batch window are intentionally skipped.
|
||||
for (const { subId, event } of buffer) {
|
||||
const subscription = this.subscriptions.get(subId);
|
||||
if (subscription?.mode === "live") {
|
||||
subscription.onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleEose(subId: string) {
|
||||
@@ -648,6 +670,10 @@ export class RelayClient {
|
||||
reconnect?: boolean;
|
||||
},
|
||||
) {
|
||||
if (this.flushTimeout !== null) window.clearTimeout(this.flushTimeout);
|
||||
this.flushTimeout = null;
|
||||
this.eventBuffer = [];
|
||||
|
||||
if (options?.reconnect !== false && this.hasConnectedOnce) {
|
||||
this.notifyReconnectListeners = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user