Merge remote-tracking branch 'origin/main' into kennylopez-agent-catalog-sharing

This commit is contained in:
kenny lopez
2026-07-23 12:52:23 -07:00
38 changed files with 3666 additions and 645 deletions
+4 -1
View File
@@ -475,13 +475,16 @@ class instances, cached promises) survive across remounts. Every community-scope
singleton needs a reset function wired into `resetCommunityState()` in
`desktop/src/features/communities/useCommunityInit.ts`.
Current singletons that are reset on community switch:
Current singletons that are reset on relay boundary changes (same-relay
reconnects preserve pending avatar verification work):
- `relayClient.disconnect()` — WebSocket teardown + promise rejection
- `resetRateLimitGate()` — clears any active rate-limit window from the old relay
- `clearAllDrafts()` — message draft cache
- `resetAgentObserverStore()` — agent observer relay store
- `resetActiveAgentTurnsStore()` — active agent turn timers
- `resetAgentWorkingSignal()` — agent working indicator signal
- `resetAvatarProfileSync()` — pending verified-avatar profile writes
- `resetAvatarPresentations()` — avatar probes, previews, and Retry toasts
- `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state
- `resetMediaCaches()` — proxy port and relay origin caches
- `resetVideoPlayerState()` — video player singleton
Generated
+1
View File
@@ -899,6 +899,7 @@ dependencies = [
"nostr",
"rand 0.10.1",
"reqwest 0.13.4",
"rustls",
"serde",
"serde_json",
"sha2 0.11.0",
+7
View File
@@ -76,6 +76,13 @@ dirs = "6"
# WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay)
buzz-ws-client = { path = "../buzz-ws-client" }
# Explicit rustls dep with ring provider — required to install the process-level
# CryptoProvider at startup. Without this the standalone `buzz` binary panics when
# a multi-package release build (buzz-acp + buzz-dev-mcp + buzz-cli in one cargo
# invocation) unifies both ring and aws-lc-rs features, leaving rustls unable to
# auto-select a provider. See crates/buzz-acp/Cargo.toml for the same dependency.
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
# Random number generation — full jitter for exponential backoff in with_retry
rand = { workspace = true }
+12
View File
@@ -25,6 +25,18 @@ where
I: IntoIterator<Item = S>,
S: Into<std::ffi::OsString> + Clone,
{
// Install ring as the process-level rustls CryptoProvider. Required because the
// release workflow builds all binaries in one cargo invocation, which unifies
// features across the workspace and enables *both* ring (from buzz-acp/buzz-dev-mcp)
// and aws-lc-rs (from reqwest's rustls feature via hyper-rustls). With both on,
// rustls cannot auto-select a provider, and any code that reaches
// ClientConfig::builder() — specifically the WSS path in publish_ephemeral_event
// used by `agents draft-create`, `agents draft-update`, and `users set-presence`
// — panics at rustls crypto/mod.rs. The `let _ =` swallow is intentional: when
// buzz-dev-mcp delegates to run_from_args, it has already installed ring; the
// double-install returns Err and is harmless.
let _ = rustls::crypto::ring::default_provider().install_default();
let cli = match Cli::try_parse_from(args) {
Ok(cli) => cli,
Err(e) => {
+134 -1
View File
@@ -7,9 +7,13 @@ use tauri::State;
use crate::{
app_state::AppState,
events,
managed_agents::persona_events::monotonic_created_at,
models::{ProfileInfo, SearchUsersResponse, UserNotesResponse, UsersBatchResponse},
nostr_convert,
relay::{query_relay, submit_event},
relay::{
query_relay, query_relay_at_with_keys, relay_http_base_url, submit_event,
submit_event_at_with_keys,
},
};
#[tauri::command]
@@ -94,6 +98,85 @@ pub async fn update_profile(
.unwrap_or_else(|| empty_profile_info(&current_pubkey_hex_unwrap(&state))))
}
#[tauri::command]
pub async fn update_profile_at_relay(
relay_url: String,
expected_pubkey: String,
expected_avatar_url: Option<String>,
avatar_url: String,
state: State<'_, AppState>,
) -> Result<ProfileInfo, String> {
let signer = capture_expected_signer(&state, &expected_pubkey)?;
let api_base_url = relay_http_base_url(&relay_url);
let filter = serde_json::json!({
"kinds": [0],
"authors": [expected_pubkey],
"limit": 1
});
let prior_events = query_relay_at_with_keys(
&state,
&api_base_url,
std::slice::from_ref(&filter),
&signer,
None,
)
.await?;
let prior_event = prior_events.first();
let current: Value = prior_event
.and_then(|event| serde_json::from_str::<Value>(&event.content).ok())
.unwrap_or(Value::Null);
let current_avatar_url = current
.get("picture")
.and_then(Value::as_str)
.map(str::to_string);
if normalized_avatar_url(current_avatar_url.as_deref())
!= normalized_avatar_url(expected_avatar_url.as_deref())
{
return Err("profile avatar changed before deferred save".to_string());
}
let builder = build_deferred_profile_event(&current, &avatar_url, prior_event)?;
submit_event_at_with_keys(builder, &state, &api_base_url, &signer).await?;
let events = query_relay_at_with_keys(&state, &api_base_url, &[filter], &signer, None).await?;
Ok(events
.first()
.map(nostr_convert::profile_info_from_event)
.transpose()?
.unwrap_or_else(|| empty_profile_info(&expected_pubkey)))
}
fn build_deferred_profile_event(
current: &Value,
avatar_url: &str,
prior_event: Option<&nostr::Event>,
) -> Result<nostr::EventBuilder, String> {
let display_name = current.get("display_name").and_then(Value::as_str);
let name = current.get("name").and_then(Value::as_str);
let about = current.get("about").and_then(Value::as_str);
let nip05 = current.get("nip05").and_then(Value::as_str);
Ok(
events::build_profile(display_name, name, Some(avatar_url), about, nip05)?
.custom_created_at(monotonic_created_at(
prior_event.map(|event| event.created_at.as_secs() as i64),
)),
)
}
fn capture_expected_signer(state: &AppState, expected_pubkey: &str) -> Result<nostr::Keys, String> {
let signer = state.signing_keys()?;
if signer.public_key().to_hex() != expected_pubkey {
return Err("profile identity changed before avatar save".to_string());
}
Ok(signer)
}
fn normalized_avatar_url(avatar_url: Option<&str>) -> Option<&str> {
avatar_url.map(str::trim).filter(|value| !value.is_empty())
}
#[tauri::command]
pub async fn get_user_profile(
pubkey: Option<String>,
@@ -335,6 +418,56 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo {
mod tests {
use super::*;
#[test]
fn deferred_profile_signer_is_captured_and_rejects_wrong_identity() {
let state = crate::app_state::build_app_state();
let original = state.signing_keys().expect("signable identity");
let original_pubkey = original.public_key().to_hex();
let captured = capture_expected_signer(&state, &original_pubkey)
.expect("matching identity should be captured");
*state.keys.lock().expect("lock keys") = nostr::Keys::generate();
assert_eq!(captured.public_key().to_hex(), original_pubkey);
assert_ne!(
state.keys.lock().expect("lock keys").public_key().to_hex(),
original_pubkey
);
assert_eq!(
capture_expected_signer(&state, &original_pubkey).unwrap_err(),
"profile identity changed before avatar save"
);
}
#[test]
fn deferred_profile_event_is_strictly_newer_than_prior_head() {
let keys = nostr::Keys::generate();
let prior_created_at = nostr::Timestamp::now().as_secs() + 60;
let prior_event = nostr::EventBuilder::new(
nostr::Kind::Metadata,
serde_json::json!({"display_name": "Larry"}).to_string(),
)
.custom_created_at(nostr::Timestamp::from(prior_created_at))
.sign_with_keys(&keys)
.expect("sign prior profile");
let builder = build_deferred_profile_event(
&serde_json::json!({"display_name": "Larry"}),
"https://example.com/avatar.png",
Some(&prior_event),
)
.expect("build deferred profile");
let event = builder
.sign_with_keys(&keys)
.expect("sign deferred profile");
assert_eq!(event.created_at.as_secs(), prior_created_at + 1);
assert_eq!(
serde_json::from_str::<Value>(&event.content).unwrap()["picture"],
"https://example.com/avatar.png"
);
}
#[test]
fn user_search_filter_requests_prefix_mode_for_typeahead() {
// Every caller of `search_users` is a typeahead surface. Whole-word
+1
View File
@@ -667,6 +667,7 @@ pub fn run() {
persist_current_identity,
get_profile,
update_profile,
update_profile_at_relay,
get_user_profile,
get_users_batch,
get_user_notes,
+3 -50
View File
@@ -347,6 +347,7 @@ 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 url = format!("{}/query", api_base_url);
let body_bytes =
serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?;
@@ -530,56 +531,8 @@ pub struct AgentProfileInfo {
// ── Signed-event submission ─────────────────────────────────────────────────
/// Response from `POST /events`.
#[derive(Debug, Deserialize, serde::Serialize)]
pub struct SubmitEventResponse {
pub event_id: String,
pub accepted: bool,
pub message: String,
}
/// Build an `EventBuilder` from the events module, sign it with the user's keys,
/// and POST the signed event to `/events` with NIP-98 auth.
pub async fn submit_event(
builder: nostr::EventBuilder,
state: &AppState,
) -> Result<SubmitEventResponse, String> {
crate::relay_admission::wait_for_rate_limit().await;
// All synchronous work (signing) must complete before any .await
// so the MutexGuard is dropped and the future remains Send.
let url = format!("{}/events", relay_api_base_url_with_override(state));
let (auth_header, body_bytes) = {
let keys = state.signing_keys()?;
let event = builder
.sign_with_keys(&keys)
.map_err(|e| format!("failed to sign event: {e}"))?;
let body = event.as_json().into_bytes();
let auth = build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body)?;
(auth, body)
}; // keys dropped here
let response = state
.http_client
.post(&url)
.header("Authorization", auth_header)
.header("Content-Type", "application/json")
.body(body_bytes)
.send()
.await
.map_err(|e| classify_request_error(&e))?;
if !response.status().is_success() {
return Err(relay_error_message(response).await);
}
let result: SubmitEventResponse = parse_json_response(response).await?;
if !result.accepted {
return Err(format!("relay rejected event: {}", result.message));
}
Ok(result)
}
mod submit;
pub use submit::{submit_event, submit_event_at_with_keys, SubmitEventResponse};
/// POST an already-signed event to `/events` with NIP-98 auth.
///
+60
View File
@@ -0,0 +1,60 @@
use super::*;
/// Response from `POST /events`.
#[derive(Debug, Deserialize, serde::Serialize)]
pub struct SubmitEventResponse {
pub event_id: String,
pub accepted: bool,
pub message: String,
}
/// Sign with an explicit identity and POST the event to an explicit relay.
///
/// The caller owns the signer lifetime. This is important for deferred work:
/// an in-process identity swap cannot retarget the event or its NIP-98 auth
/// after the caller has validated which identity the operation belongs to.
pub async fn submit_event_at_with_keys(
builder: nostr::EventBuilder,
state: &AppState,
api_base_url: &str,
keys: &nostr::Keys,
) -> Result<SubmitEventResponse, String> {
crate::relay_admission::wait_for_rate_limit().await;
let url = format!("{}/events", api_base_url.trim_end_matches('/'));
let event = builder
.sign_with_keys(keys)
.map_err(|e| format!("failed to sign event: {e}"))?;
let body_bytes = event.as_json().into_bytes();
let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?;
let response = state
.http_client
.post(&url)
.header("Authorization", auth_header)
.header("Content-Type", "application/json")
.body(body_bytes)
.send()
.await
.map_err(|e| classify_request_error(&e))?;
if !response.status().is_success() {
return Err(relay_error_message(response).await);
}
let result: SubmitEventResponse = parse_json_response(response).await?;
if !result.accepted {
return Err(format!("relay rejected event: {}", result.message));
}
Ok(result)
}
/// Build and submit an event to the currently active workspace relay.
pub async fn submit_event(
builder: nostr::EventBuilder,
state: &AppState,
) -> Result<SubmitEventResponse, String> {
let api_base_url = relay_api_base_url_with_override(state);
let keys = state.signing_keys()?;
submit_event_at_with_keys(builder, state, &api_base_url, &keys).await
}
+3
View File
@@ -44,6 +44,7 @@ import {
import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup";
import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen";
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync";
import { createBuzzQueryClient } from "@/shared/api/queryClient";
import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
import { getProfile } from "@/shared/api/tauriProfiles";
@@ -197,6 +198,8 @@ function CommunitySwitchGate() {
function CommunityQueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(createBuzzQueryClient);
useEffect(() => setAvatarProfileSyncQueryClient(queryClient), [queryClient]);
useEffect(() => {
const e2eWindow = window as Window & {
__BUZZ_E2E__?: unknown;
@@ -354,7 +354,7 @@ describe("load-older cursor advance logic", () => {
it("test_short_page_signals_archive_exhausted", () => {
// A page with fewer events than the limit signals end-of-archive.
const PAGE_SIZE = 50;
const PAGE_SIZE = 200;
const page = Array.from({ length: 30 }, (_, i) => ({
created_at: 1000 - i,
}));
@@ -367,8 +367,8 @@ describe("load-older cursor advance logic", () => {
});
it("test_full_page_signals_more_archive_available", () => {
const PAGE_SIZE = 50;
const page = Array.from({ length: 50 }, (_, i) => ({
const PAGE_SIZE = 200;
const page = Array.from({ length: 200 }, (_, i) => ({
created_at: 1000 - i,
}));
const exhausted = page.length < PAGE_SIZE;
@@ -394,6 +394,7 @@ describe("load-older cursor advance logic", () => {
import {
createArchivePagingState,
applyChannelReset,
runHydrationLoop,
} from "@/features/agents/ui/archivePagingState.ts";
describe("archive paging state reset on channel change", () => {
@@ -410,6 +411,12 @@ describe("archive paging state reset on channel change", () => {
"backfillPromise is eagerly initialized",
);
assert.equal(ps.cursor, null, "cursor starts null");
assert.equal(
ps.initialHydrationDone,
false,
"initialHydrationDone starts false",
);
assert.equal(ps.activeChannelId, null, "activeChannelId starts null");
});
it("test_channel_switch_resets_cursor_exhaustion_and_fetch_lock", () => {
@@ -419,11 +426,13 @@ describe("archive paging state reset on channel change", () => {
ps.cursor = { createdAt: 1000, id: "event-a5" };
ps.hasOlderArchived = false; // channel A exhausted
ps.isFetching = true; // mid-flight request (edge case)
ps.initialHydrationDone = true; // hydration ran for channel A
ps.activeChannelId = "chan-a";
ps.backfillStatus = "done"; // backfill ran once already
const originalPromise = ps.backfillPromise; // must survive reset
// Channel switch — this is what the useEffect([channelId]) calls.
applyChannelReset(ps);
applyChannelReset(ps, "chan-b");
assert.equal(ps.cursor, null, "cursor resets to null on channel switch");
assert.equal(
@@ -436,6 +445,16 @@ describe("archive paging state reset on channel change", () => {
false,
"isFetching resets to false on channel switch",
);
assert.equal(
ps.initialHydrationDone,
false,
"initialHydrationDone resets to false on channel switch so the new channel hydrates",
);
assert.equal(
ps.activeChannelId,
"chan-b",
"activeChannelId updates to new channel on switch",
);
// Backfill state must NOT be touched — it is identity-level and should
// survive channel switches so the backfill only runs once per identity mount.
@@ -459,10 +478,11 @@ describe("archive paging state reset on channel change", () => {
it("test_multiple_channel_switches_each_start_fresh", () => {
const ps = createArchivePagingState();
// Switch to channel A: exhaust it.
// Switch to channel A: exhaust it and complete hydration.
ps.cursor = { createdAt: 500, id: "a-oldest" };
ps.hasOlderArchived = false;
applyChannelReset(ps);
ps.initialHydrationDone = true;
applyChannelReset(ps, "chan-b");
assert.equal(ps.cursor, null, "switch A→B: cursor reset");
assert.equal(
@@ -470,11 +490,22 @@ describe("archive paging state reset on channel change", () => {
true,
"switch A→B: hasOlderArchived reset",
);
assert.equal(
ps.initialHydrationDone,
false,
"switch A→B: initialHydrationDone reset",
);
assert.equal(
ps.activeChannelId,
"chan-b",
"switch A→B: activeChannelId updated",
);
// Simulate channel B also being paged.
// Simulate channel B also being paged and hydrated.
ps.cursor = { createdAt: 200, id: "b-oldest" };
ps.hasOlderArchived = false;
applyChannelReset(ps);
ps.initialHydrationDone = true;
applyChannelReset(ps, "chan-c");
assert.equal(ps.cursor, null, "switch B→C: cursor reset again");
assert.equal(
@@ -482,6 +513,164 @@ describe("archive paging state reset on channel change", () => {
true,
"switch B→C: hasOlderArchived reset again",
);
assert.equal(
ps.initialHydrationDone,
false,
"switch B→C: initialHydrationDone reset again",
);
assert.equal(
ps.activeChannelId,
"chan-c",
"switch B→C: activeChannelId updated",
);
});
});
// ── Eager initial hydration loop logic ───────────────────────────────────────
//
// The initial hydration loop in useLoadArchivedObserverEvents calls
// fetchOlderArchived up to INITIAL_HYDRATION_BUDGET_PAGES times. The loop must:
// - Stop at budget (10 pages) even if more archive exists.
// - Stop early when the archive is exhausted (ps.hasOlderArchived → false).
// - Respect channel-switch cancellation (signal.cancelled).
//
// These tests call the PRODUCTION runHydrationLoop from archivePagingState.ts
// with mock fetchOnePage functions — so they fail if the production loop logic
// is deleted or misrouted, not just if a reimplemented copy breaks.
describe("eager initial hydration loop control flow (production runHydrationLoop)", () => {
const BUDGET = 10; // mirrors INITIAL_HYDRATION_BUDGET_PAGES
it("test_hydration_stops_at_budget_when_archive_never_exhausted", async () => {
const ps = createArchivePagingState();
applyChannelReset(ps, "chan-1");
let fetchCount = 0;
const fetchOnePage = async () => {
fetchCount++;
// archive remains non-empty — ps.hasOlderArchived stays true
};
const signal = { cancelled: false };
await runHydrationLoop(ps, fetchOnePage, BUDGET, signal);
assert.equal(
fetchCount,
BUDGET,
`production runHydrationLoop must stop after exactly ${BUDGET} pages (budget limit)`,
);
});
it("test_hydration_stops_early_when_archive_exhausted", async () => {
const ps = createArchivePagingState();
applyChannelReset(ps, "chan-1");
let fetchCount = 0;
const fetchOnePage = async () => {
fetchCount++;
if (fetchCount >= 3) {
ps.hasOlderArchived = false; // mock: archive exhausted on page 3
}
};
const signal = { cancelled: false };
await runHydrationLoop(ps, fetchOnePage, BUDGET, signal);
assert.equal(
fetchCount,
3,
"production runHydrationLoop must stop as soon as ps.hasOlderArchived is false (before budget)",
);
});
it("test_hydration_respects_cancellation_on_channel_switch", async () => {
const ps = createArchivePagingState();
applyChannelReset(ps, "chan-1");
const signal = { cancelled: false };
let fetchCount = 0;
const fetchOnePage = async () => {
fetchCount++;
if (fetchCount >= 2) {
signal.cancelled = true; // mock: channel switched mid-loop
}
};
await runHydrationLoop(ps, fetchOnePage, BUDGET, signal);
assert.equal(
fetchCount,
2,
"production runHydrationLoop must stop when signal.cancelled is true (channel switch)",
);
});
it("test_hydration_zero_iterations_when_already_exhausted", async () => {
// If ps.hasOlderArchived is already false before the loop starts (e.g.
// channel A was exhausted and reset did not run yet), the loop must not
// call fetchOnePage at all.
const ps = createArchivePagingState();
applyChannelReset(ps, "chan-1");
ps.hasOlderArchived = false; // already exhausted
let fetchCount = 0;
const fetchOnePage = async () => {
fetchCount++;
};
const signal = { cancelled: false };
await runHydrationLoop(ps, fetchOnePage, BUDGET, signal);
assert.equal(
fetchCount,
0,
"must not fetch when archive is already exhausted",
);
});
// Regression: stale React closure — the original fetchOlderArchived captured
// hasOlderArchived from React state, which was false (exhausted) for channel A
// while ps.hasOlderArchived had already been reset to true by applyChannelReset.
// The production loop uses ps.hasOlderArchived (the ref) to guard iterations,
// so the switch-then-hydrate path must call fetchOnePage for the new channel.
it("test_exhausted_channel_A_then_switch_to_B_hydrates_B", async () => {
const ps = createArchivePagingState();
// Simulate exhausting channel A.
applyChannelReset(ps, "chan-a");
ps.hasOlderArchived = false; // channel A exhausted
// Switch to channel B — resets hasOlderArchived and activeChannelId.
applyChannelReset(ps, "chan-b");
// ps.hasOlderArchived is now true; the loop should call fetchOnePage.
let fetchCount = 0;
const fetchOnePage = async () => {
fetchCount++;
ps.hasOlderArchived = false; // B also exhausted after 1 page
};
const signal = { cancelled: false };
await runHydrationLoop(ps, fetchOnePage, BUDGET, signal);
assert.equal(
fetchCount,
1,
"after switch from exhausted channel A to B, runHydrationLoop must call fetchOnePage for B (not skip due to stale exhaustion)",
);
});
// Regression: stale cursor write — an in-flight read from channel A should
// not write A's cursor into ps.cursor after the switch to B. The activeChannelId
// token (set by applyChannelReset) is what lets fetchOlderArchived detect and
// discard the stale result. This test verifies that applyChannelReset correctly
// advances the token so a pre-switch requestChannelId !== ps.activeChannelId.
it("test_activeChannelId_token_detects_stale_read_from_prior_channel", () => {
const ps = createArchivePagingState();
applyChannelReset(ps, "chan-a");
const requestChannelId = ps.activeChannelId; // captured at request start = "chan-a"
// Simulate channel switch before the Tauri read resolves.
applyChannelReset(ps, "chan-b");
// The in-flight A read checks requestChannelId !== ps.activeChannelId.
assert.notEqual(
requestChannelId,
ps.activeChannelId,
"requestChannelId from channel A must not match activeChannelId after switching to B — stale read must be discarded",
);
assert.equal(
ps.activeChannelId,
"chan-b",
"activeChannelId must reflect the current channel after switch",
);
});
});
@@ -28,6 +28,20 @@ export interface ArchivePagingState {
* Mirrors SQL ORDER BY created_at DESC, id DESC so same-second siblings are
* never skipped at a page boundary. */
cursor: { createdAt: number; id: string } | null;
/** True once the initial eager-hydration pass for the current channel has
* completed (budget reached or archive exhausted). Resets on channel change
* so switching channels triggers a fresh hydration pass. */
initialHydrationDone: boolean;
/** The channelId that this paging state is currently scoped to.
* Kept for diagnostics only; NOT used as a generation token (see
* resetGeneration). Channel equality is not a unique request identifier:
* A→B→A makes old-A channel checks pass again. */
activeChannelId: string | null;
/** Monotonically increasing counter incremented by applyChannelReset.
* Each fetch snapshots this value at request start and checks it again
* after every async boundary — a mismatch means a channel switch occurred
* mid-flight (even A→B→A), and results are discarded. */
resetGeneration: number;
}
/**
@@ -43,6 +57,9 @@ export function createArchivePagingState(): ArchivePagingState {
backfillPromise: null,
backfillResolve: null,
cursor: null,
initialHydrationDone: false,
activeChannelId: null,
resetGeneration: 0,
};
state.backfillPromise = new Promise<void>((resolve) => {
state.backfillResolve = resolve;
@@ -53,15 +70,57 @@ export function createArchivePagingState(): ArchivePagingState {
/**
* Reset per-channel paging state when the viewed channel changes.
*
* Only cursor, exhaustion flag, and fetch lock are channel-scoped. Backfill
* state is identity-level (the index covers ALL channels and needs to run only
* once per identity mount), so it is intentionally NOT touched here.
* Only cursor, exhaustion flag, fetch lock, channel label, and generation token
* are channel-scoped. Backfill state is identity-level (the index covers ALL
* channels and needs to run only once per identity mount), so it is
* intentionally NOT touched here.
*
* `resetGeneration` is incremented on every call. In-flight fetches snapshot
* the generation at start and recheck it after every async boundary — a
* mismatch (including A→B→A) means the request is stale, so results are
* discarded. Channel ID is retained for diagnostics only; it does NOT serve
* as the generation token.
*
* Called by the useEffect([channelId]) in useLoadArchivedObserverEvents.
* Exported so tests can verify the reset semantics without a React runtime.
*/
export function applyChannelReset(state: ArchivePagingState): void {
export function applyChannelReset(
state: ArchivePagingState,
newChannelId: string | null,
): void {
state.cursor = null;
state.isFetching = false;
state.hasOlderArchived = true;
state.initialHydrationDone = false;
state.activeChannelId = newChannelId;
state.resetGeneration += 1;
}
/**
* Run the eager initial-hydration paging loop.
*
* Calls `fetchOnePage()` up to `budget` times. Stops early when:
* - `ps.hasOlderArchived` is false (archive exhausted for this channel), OR
* - `signal.cancelled` is true (channel switched away mid-loop).
*
* `fetchOnePage` is the per-page read unit: it must respect `ps.isFetching`
* (lock), await backfill, perform the Tauri read, ingest results, advance
* the cursor, and set `ps.hasOlderArchived = false` when the page is short.
* The hook wires the real implementation; tests supply a mock.
*
* Exported so tests can call the production loop logic directly — passing a
* mock `fetchOnePage` — without reimplementing the control flow.
*/
export async function runHydrationLoop(
ps: ArchivePagingState,
fetchOnePage: () => Promise<void>,
budget: number,
signal: { cancelled: boolean },
): Promise<void> {
for (let page = 0; page < budget; page++) {
if (signal.cancelled || !ps.hasOlderArchived) {
break;
}
await fetchOnePage();
}
}
@@ -0,0 +1,936 @@
/**
* Mounted-hook lifecycle and race regression tests for
* useLoadArchivedObserverEvents.
*
* These tests mount the REAL production hook (including its useEffect wiring,
* fetchOlderArchived closure, runHydrationLoop call, and resetGeneration token
* checks) against a mocked Tauri IPC bridge and a real QueryClientProvider.
* They fail if any of the following is removed from the production hook:
* - the hydration effect
* - the resetGeneration checks (post-backfill, post-Tauri-read, post-ingest)
* - the generation-aware isFetching clear in finally
* - the post-backfill isFetching recheck before lock acquisition
*
* Four regressions:
* (a) exhausted-A → switch-to-B: B must read from a null cursor and ingest
* its rows. GREEN at dfb2d0385 (stale-closure was fixed in round 1).
* Fails at the pre-round-1 head where ps.hasOlderArchived was read from
* React state, not the ref.
* (b) deferred-I/O race (A→B): A is in flight (decrypt deferred), switch to B,
* resolve A's 1-row short ingest — A must NOT mark B exhausted or steal B's
* fetch lock, and B's eager loop must continue past page 1. Fails at
* dfb2d0385 (post-ingest token missing). Lock theft is asserted by calling
* fetchOlderArchived concurrently while B holds the lock: with correct
* protection the concurrent call is rejected (read count doesn't jump);
* without it, A's stale finally clears the lock and the concurrent call
* would start a duplicate read.
* (c) concurrent fetches during pending backfill: two callers both suspend on
* the backfill promise, both resume after it resolves — only one must
* acquire the lock and issue the Tauri read. Fails at 4c92a018d (no
* post-backfill isFetching recheck).
* (d) A→B→A: old-A's in-flight decrypt completes after the user returns to A.
* Old-A's generation no longer matches (each reset increments the counter),
* so it must not mark fresh-A exhausted or steal fresh-A's lock. Fails
* when resetGeneration is replaced with a channel-string equality check.
*
* ── DOM shim ─────────────────────────────────────────────────────────────────
* react-dom/client requires a minimal DOM; node has none. We install the same
* minimal shim used by MessageComposerDraftImagePersist.test.mjs.
*
* ── Tauri IPC mock ───────────────────────────────────────────────────────────
* @tauri-apps/api/core calls window.__TAURI_INTERNALS__.invoke(cmd, args).
* We install a per-test mock at globalThis.__TAURI_INTERNALS__.invoke so every
* listSaveSubscriptions / readArchivedObserverEventsForChannel / readUnindexed /
* indexObserverChannelId call is intercepted by command name without patching
* module internals.
*/
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
// ── Minimal DOM shim (matches MessageComposerDraftImagePersist.test.mjs) ──────
function installDOMShim() {
class MinimalEventTarget {
constructor() {
this._listeners = {};
}
addEventListener(type, fn) {
if (!this._listeners[type]) this._listeners[type] = [];
this._listeners[type].push(fn);
}
removeEventListener(type, fn) {
if (this._listeners[type]) {
this._listeners[type] = this._listeners[type].filter((f) => f !== fn);
}
}
dispatchEvent(e) {
for (const fn of this._listeners[e.type] ?? []) fn(e);
return true;
}
}
class MinimalNode extends MinimalEventTarget {
constructor(tagName) {
super();
this.tagName = tagName;
this.children = [];
this.childNodes = [];
this.style = {};
this.nodeType = 1;
this.parentNode = null;
}
get ownerDocument() {
return globalThis.document;
}
get firstChild() {
return this.children[0] ?? null;
}
get lastChild() {
return this.children[this.children.length - 1] ?? null;
}
get nextSibling() {
return null;
}
get nodeValue() {
return null;
}
appendChild(child) {
this.children.push(child);
this.childNodes.push(child);
child.parentNode = this;
return child;
}
removeChild(child) {
this.children = this.children.filter((c) => c !== child);
this.childNodes = this.childNodes.filter((c) => c !== child);
return child;
}
insertBefore(newNode, refNode) {
if (!refNode) return this.appendChild(newNode);
const i = this.children.indexOf(refNode);
if (i < 0) return this.appendChild(newNode);
this.children.splice(i, 0, newNode);
this.childNodes.splice(i, 0, newNode);
newNode.parentNode = this;
return newNode;
}
contains(node) {
if (!node) return false;
return this === node || this.children.some((c) => c?.contains?.(node));
}
}
class MinimalDocument extends MinimalEventTarget {
constructor() {
super();
this.nodeType = 9;
}
createElement(tagName) {
return new MinimalNode(tagName);
}
createTextNode(value) {
const n = new MinimalNode("#text");
n.nodeValue = value;
n.nodeType = 3;
return n;
}
createComment(value) {
const n = new MinimalNode("#comment");
n.nodeValue = value;
n.nodeType = 8;
return n;
}
get body() {
if (!this._body) this._body = this.createElement("body");
return this._body;
}
get activeElement() {
return null;
}
contains(node) {
return node != null;
}
}
globalThis.document = new MinimalDocument();
globalThis.HTMLIFrameElement = MinimalNode;
globalThis.HTMLElement = MinimalNode;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
if (typeof globalThis.window === "undefined") {
Object.defineProperty(globalThis, "window", {
value: globalThis,
configurable: true,
});
}
if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) {
Object.defineProperty(globalThis, "navigator", {
value: { userAgent: "node" },
configurable: true,
});
}
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
takeRecords() {
return [];
}
};
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
}
installDOMShim();
// ── Tauri IPC interceptor ─────────────────────────────────────────────────────
//
// @tauri-apps/api/core calls window.__TAURI_INTERNALS__.invoke(cmd, args).
// Install a stub now (before any module that imports tauriArchive is loaded)
// so listSaveSubscriptions, readArchivedObserverEventsForChannel, etc. can be
// controlled per-test by replacing ipcHandlers.
/** @type {Map<string, (args: unknown) => Promise<unknown>>} */
const ipcHandlers = new Map();
globalThis.__TAURI_INTERNALS__ = {
invoke: (cmd, args) => {
const handler = ipcHandlers.get(cmd);
if (handler) return handler(args);
return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`));
},
transformCallback: (_cb) => {
const id = Math.random();
return id;
},
};
function setIpcHandler(cmd, fn) {
ipcHandlers.set(cmd, fn);
}
function clearIpcHandlers() {
ipcHandlers.clear();
}
// ── Production imports (after shim, after IPC stub) ───────────────────────────
import React from "react";
import { createRoot } from "react-dom/client";
import { act } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useLoadArchivedObserverEvents } from "@/features/agents/ui/useObserverEvents.ts";
import {
resetAgentObserverStore,
_testRegisterKnownAgents,
_testGetArchivedChannelEvents,
} from "@/features/agents/observerRelayStore.ts";
// ── Constants ─────────────────────────────────────────────────────────────────
const AGENT_PUBKEY = "a".repeat(64);
const IDENTITY_PUBKEY = "c".repeat(64);
const SUB_ID = "test-hook-sub";
// ── Tauri wire-shape helpers ──────────────────────────────────────────────────
/** Returns a list_save_subscriptions response with one owner_p subscription. */
function makeOwnerPSubResponse() {
return [
{
identity_pubkey: IDENTITY_PUBKEY,
relay_url: "wss://test",
scope_type: "owner_p",
scope_value: IDENTITY_PUBKEY,
kinds: "[24200]",
created_at: 1000,
},
];
}
/** Returns a raw archived observer event row for readArchivedObserverEventsForChannel. */
function makeArchivedRow(seq, channelId = "chan-1") {
return {
id: `ev${String(seq).padStart(63, "0")}`,
pubkey: AGENT_PUBKEY,
created_at: 1000 + seq,
kind: 24200,
tags: [
["p", IDENTITY_PUBKEY],
["agent", AGENT_PUBKEY],
["frame", "telemetry"],
],
content: JSON.stringify({
seq,
timestamp: new Date(1_000_000 + seq * 1000).toISOString(),
channelId,
kind: "telemetry",
sessionId: "sess-1",
turnId: "turn-1",
payload: { method: "session/update", params: {} },
}),
sig: "s".repeat(128),
};
}
// ── React mounting helpers ────────────────────────────────────────────────────
/**
* Mount useLoadArchivedObserverEvents in a real React tree with a QueryClient
* pre-seeded with the identity. Returns { unmount, render(channelId),
* getFetchOlderArchived() }.
*
* getFetchOlderArchived() returns the latest fetchOlderArchived function from
* the hook's return value, captured on each render. Tests can call it directly
* to probe lock behaviour without going through the hydration loop.
*/
function mountHook(_initialChannelId, queryClient) {
// Capture the latest hook return values so tests can call fetchOlderArchived.
const hookReturnRef = { current: null };
function HarnessComponent({ channelId }) {
const result = useLoadArchivedObserverEvents(true, channelId);
hookReturnRef.current = result;
return null;
}
const container = document.createElement("div");
const root = createRoot(container);
const render = async (channelId) => {
await act(async () => {
root.render(
React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(HarnessComponent, { channelId }),
),
);
});
};
return {
render,
getFetchOlderArchived: () => hookReturnRef.current?.fetchOlderArchived,
unmount: async () => {
await act(async () => {
root.unmount();
});
},
};
}
/** Make a QueryClient pre-seeded with identity so useIdentityQuery resolves. */
function makeQueryClient() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(["identity"], { pubkey: IDENTITY_PUBKEY });
return qc;
}
// ── Settle helper ─────────────────────────────────────────────────────────────
//
// Flushes microtasks + a few macrotask ticks so async effects can settle.
// Uses act() so React commits state updates from effects.
async function settle(iterations = 3) {
for (let i = 0; i < iterations; i++) {
await act(async () => {
await new Promise((r) => setTimeout(r, 5));
});
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("useLoadArchivedObserverEvents — mounted hook lifecycle regressions", () => {
beforeEach(() => {
resetAgentObserverStore();
clearIpcHandlers();
_testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]);
});
/**
* Regression (a): exhausted channel A → switch to channel B.
*
* The original stale-closure bug (pre-round-1): fetchOlderArchived captured
* hasOlderArchived from React state (false for exhausted A). After the switch,
* React state was still false while ps.hasOlderArchived had been reset to true.
* The hydration loop called fetchOlderArchived up to 10 times, each returning
* immediately at the !ps.hasOlderArchived guard — B never got a read.
*
* After the fix (reading ps.hasOlderArchived from the ref), B gets at least
* one read from a null cursor and its rows are ingested.
*
* PROVENANCE: this test is GREEN at dfb2d0385 (the stale-closure was already
* fixed in round 1). It would be red at the pre-round-1 head (884ed9ba2)
* where hasOlderArchived was captured from React state in the closure.
*/
it("test_exhausted_channel_A_switch_to_B_hook_reads_B_from_null_cursor", async () => {
// Channel A: 1 page of 1 row (short page → exhausted immediately).
const aRows = [makeArchivedRow(1, "chan-a")];
// Channel B: 1 page of 1 row.
const bRows = [makeArchivedRow(2, "chan-b")];
const aCalls = [];
const bCalls = [];
setIpcHandler("list_save_subscriptions", async () =>
makeOwnerPSubResponse(),
);
setIpcHandler("read_unindexed_observer_rows", async () => []);
setIpcHandler("index_observer_channel_id", async () => null);
setIpcHandler("read_archived_observer_events_for_channel", async (args) => {
if (args.channelId === "chan-a") {
aCalls.push({ cursor: args.beforeCreatedAt ?? null });
return aRows.map((r) => JSON.stringify(r));
}
if (args.channelId === "chan-b") {
bCalls.push({ cursor: args.beforeCreatedAt ?? null });
return bRows.map((r) => JSON.stringify(r));
}
return [];
});
// decrypt_observer_event is called inside ingestArchivedObserverEvents.
// invokeTauri passes { eventJson: JSON.stringify(rawRelayEvent) }.
// The row.content is the JSON-encoded ObserverEvent — return it parsed.
setIpcHandler("decrypt_observer_event", async (args) => {
try {
const event = JSON.parse(args.eventJson);
return JSON.parse(event.content);
} catch {
return { kind: "telemetry", channelId: null };
}
});
const qc = makeQueryClient();
const { render, unmount } = mountHook("chan-a", qc);
// Mount on chan-a and let hydration settle.
await render("chan-a");
await settle(10);
// A must have been read (at least one call, from null cursor).
assert.ok(aCalls.length >= 1, `expected A reads, got ${aCalls.length}`);
assert.equal(aCalls[0].cursor, null, "A first read must use null cursor");
// Switch to chan-b.
await render("chan-b");
await settle(10);
// B must have been read from a null cursor (fresh channel, no inherited cursor).
assert.ok(bCalls.length >= 1, `expected B reads, got ${bCalls.length}`);
assert.equal(
bCalls[0].cursor,
null,
"B first read must use null cursor (not A's cursor)",
);
// B's rows must have been ingested into the archive store.
const bArchived = _testGetArchivedChannelEvents(AGENT_PUBKEY, "chan-b");
assert.ok(
bArchived.length >= 1,
`B's rows must be ingested — found ${bArchived.length} (exhausted-A stale closure bug would leave this 0)`,
);
await unmount();
});
/**
* Regression (b): deferred-I/O race — A→B: A's stale writes after ingest AND
* lock theft via stale finally.
*
* Two protections are under test independently:
*
* 1. Post-ingest exhaustion write (post-ingest token check):
* The bug at dfb2d0385: fetchOlderArchived for A checked the token BEFORE
* ingestArchivedObserverEvents but NOT after. A's deferred decrypt resumed
* after the channel switch, wrote ps.hasOlderArchived=false (short-page
* exhaustion) for B's paging state, and B's eager loop stopped after 1 page.
* Removing the post-ingest token check causes bCallCount==1.
*
* 2. Lock theft via generation-gated finally:
* If the generation guard in finally is removed, stale A's finally runs
* ps.isFetching=false while B holds the lock. We probe this directly:
* after resolving stale A (while B's first read is in flight), we call
* fetchOlderArchived() on B ourselves. With the correct guard, B holds
* the lock and the concurrent call returns immediately (bCallCount stays
* at 1 for now). Without it (stale A stole the lock), the concurrent call
* acquires the lock and starts an extra Tauri read (bCallCount jumps to 2
* prematurely, with concurrent in-flight reads — the assertion catches this
* because it fires BEFORE B's deferred first read resolves).
*
* Precise race sequence:
* 1. Mount on chan-a. A's Tauri read returns 1 row. Cursor set. Ingest starts.
* 2. A's decrypt is DEFERRED (aDecryptDeferred).
* 3. Switch to channel B.
* 4. B's hydration loop starts. B's first Tauri read is ALSO DEFERRED.
* 5. Resolve A's decrypt → A finishes ingest, hits post-ingest check.
* - At dfb2d0385 (no post-ingest check): writes ps.hasOlderArchived=false.
* Also, if finally is unguarded, ps.isFetching=false (lock stolen).
* - After fix: both writes discarded (generation mismatch).
* 6. LOCK PROBE (while B's first read is still deferred):
* call fetchOlderArchived() directly. Must return without starting a new
* Tauri read (B holds the lock; bCallCount must still be 1).
* 7. Resolve B's first Tauri read → B ingests 200 rows.
* 8. B loop continues: bCallCount >= 2.
*
* VERIFIED: removing the post-ingest token check causes bCallCount<2 (step 8).
* Removing just the finally generation guard causes bCallCount>=2 but the lock
* probe at step 6 catches the theft: bCallCount jumps to 2 before B's deferred
* first read resolves (duplicate concurrent read started while B is in flight).
*
* RED at dfb2d0385 (bCallCount==1). GREEN at current head.
*/
it("test_deferred_A_ingest_cannot_exhaust_B_or_steal_B_lock", async () => {
let resolveADecrypt;
const aDecryptDeferred = new Promise((resolve) => {
resolveADecrypt = resolve;
});
let resolveBFirstRead;
const bFirstReadDeferred = new Promise((resolve) => {
resolveBFirstRead = resolve;
});
const PAGE_SIZE = 200;
const makeBPage = (offset) =>
Array.from({ length: PAGE_SIZE }, (_, i) =>
makeArchivedRow(offset + i, "chan-b"),
);
let bCallCount = 0;
let aDecryptStarted = false;
let bFirstReadHeld = false;
setIpcHandler("list_save_subscriptions", async () =>
makeOwnerPSubResponse(),
);
setIpcHandler("read_unindexed_observer_rows", async () => []);
setIpcHandler("index_observer_channel_id", async () => null);
setIpcHandler("read_archived_observer_events_for_channel", async (args) => {
if (args.channelId === "chan-a") {
return [JSON.stringify(makeArchivedRow(1, "chan-a"))]; // 1 row = short page
}
if (args.channelId === "chan-b") {
bCallCount++;
if (bCallCount === 1 && !bFirstReadHeld) {
// Defer B's first Tauri read until we explicitly release it.
bFirstReadHeld = true;
await bFirstReadDeferred;
return makeBPage(100).map((r) => JSON.stringify(r)); // full page
}
if (bCallCount <= 4)
return makeBPage(bCallCount * 100).map((r) => JSON.stringify(r));
return [JSON.stringify(makeArchivedRow(9999, "chan-b"))]; // short = exhaust
}
return [];
});
setIpcHandler("decrypt_observer_event", async (args) => {
try {
const event = JSON.parse(args.eventJson);
const parsed = JSON.parse(event.content);
if (parsed.channelId === "chan-a" && !aDecryptStarted) {
aDecryptStarted = true;
await aDecryptDeferred; // block A's decrypt
}
return parsed;
} catch {
return { kind: "telemetry", channelId: null };
}
});
const qc = makeQueryClient();
const { render, getFetchOlderArchived, unmount } = mountHook("chan-a", qc);
// Step 1-2: Mount on chan-a. A's Tauri read completes (1 row), cursor set,
// ingest starts and blocks at A's decrypt.
await render("chan-a");
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
// Step 3: Switch to chan-b while A's decrypt/ingest is blocked.
await render("chan-b");
// Step 4: B calls its first Tauri read and blocks.
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
// B's first read must have been attempted (bCallCount >= 1).
assert.ok(
bCallCount >= 1,
`B must have started its first read before the lock probe — got bCallCount=${bCallCount}`,
);
const bCallCountBeforeAResolve = bCallCount;
// Step 5: Resolve A's decrypt. At dfb2d0385 this writes
// ps.hasOlderArchived=false (if no post-ingest check) and/or clears
// ps.isFetching (if finally is unguarded).
resolveADecrypt();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
// Step 6: LOCK PROBE — B still holds the lock (B's first read is deferred).
// Call fetchOlderArchived directly. With correct protection, B's lock is
// intact and this call returns immediately without a Tauri read — bCallCount
// must NOT increase. If A stole the lock, this call acquires it and starts
// a new Tauri read before B's deferred first read resolves (bCallCount jumps).
const fetchFn = getFetchOlderArchived();
if (fetchFn) {
await act(async () => {
await fetchFn();
});
}
assert.equal(
bCallCount,
bCallCountBeforeAResolve,
`Lock probe must not start a new B read while B holds the lock (bCallCount=${bCallCount}, expected ${bCallCountBeforeAResolve}). If A's stale finally cleared the lock, this concurrent call would start a duplicate read.`,
);
// Step 7: Now resolve B's first Tauri read.
resolveBFirstRead();
// Let B's loop run.
await settle(10);
// Step 8: B must have made at least 2 Tauri reads.
// At dfb2d0385: A corrupted ps.hasOlderArchived=false before B's first page
// resolved, so after B's first page, the loop checks and exits. bCallCount==1.
// After fix: A's write was discarded, ps.hasOlderArchived is still true,
// B continues to page 2+.
assert.ok(
bCallCount >= 2,
`B must read at least 2 pages — got ${bCallCount}. Post-ingest token missing at dfb2d0385 let A corrupt B's exhaustion state (bCallCount==1).`,
);
// A's row must NOT appear in B's channel archive.
const bArchived = _testGetArchivedChannelEvents(AGENT_PUBKEY, "chan-b");
for (const evt of bArchived) {
assert.equal(
evt.channelId,
"chan-b",
"B's archive must only contain B-channel events",
);
}
await unmount();
});
/**
* Regression (c): concurrent fetches during pending backfill — only one
* same-generation call may acquire the lock after backfill resolves.
*
* The gap at 4c92a018d: `ps.isFetching` was checked only at the top of
* fetchOlderArchived, BEFORE `await ps.backfillPromise`. Two callers
* (eager hydration loop + a concurrent scroll trigger) could both observe
* isFetching=false, both suspend on the same pending backfill promise, then
* both resume and proceed past the post-backfill guard (which only checked
* generation + exhaustion). Both would set isFetching=true and issue
* readArchivedObserverEventsForChannel from the SAME cursor — duplicating
* the first page.
*
* Fix: after `await ps.backfillPromise`, recheck `ps.isFetching` immediately
* before acquiring the lock. The second caller finds the lock already taken
* and returns without reading.
*
* The test defers the ARCHIVE response (not just backfill) so we can count
* reads while the winner's first response is still in flight. If both
* callers acquired the lock, archiveCallCount will be 2 before the first
* deferred response is released. With the fix, archiveCallCount is 1.
*
* Precise race sequence:
* 1. Mount on chan-a. Backfill is DEFERRED (backfillDeferred).
* Archive responses are also deferred until resolveArchive() is called.
* 2. Hydration loop call #1 suspends on backfill.
* 3. Inject manual call #2 — it also sees isFetching=false and suspends on
* backfill.
* 4. Resolve backfill. Both calls resume and race to acquire the lock.
* - Without fix: both pass the post-backfill guard, both set
* isFetching=true, both issue the Tauri read — archiveCallCount == 2.
* - With fix: one passes, sets isFetching=true; the other sees the lock
* taken and returns — archiveCallCount == 1.
* 5. Assert archiveCallCount == 1 before releasing archive response.
* (Archive is still deferred, so loop hasn't advanced past page 1 yet —
* any count > 1 is purely from the concurrent race, not loop progress.)
*
* VERIFIED: removing the post-backfill `ps.isFetching` recheck causes
* archiveCallCount == 2 at step 5. GREEN at current head.
*/
it("test_two_concurrent_fetches_during_backfill_only_one_proceeds", async () => {
let resolveBackfill;
const backfillDeferred = new Promise((resolve) => {
resolveBackfill = resolve;
});
// Defer ALL archive responses until we release them. This way, if two
// callers both acquire the lock, archiveCallCount jumps to 2 before we
// release the response — and we can catch it unambiguously.
let resolveArchive;
const archiveDeferred = new Promise((resolve) => {
resolveArchive = resolve;
});
let archiveCallCount = 0;
setIpcHandler("list_save_subscriptions", async () =>
makeOwnerPSubResponse(),
);
// Defer readUnindexedObserverRows to simulate a pending backfill.
setIpcHandler("read_unindexed_observer_rows", async () => {
await backfillDeferred;
return [];
});
setIpcHandler("index_observer_channel_id", async () => null);
setIpcHandler("read_archived_observer_events_for_channel", async (args) => {
if (args.channelId === "chan-a") {
archiveCallCount++;
// Hold this response until we explicitly release it so we can
// count concurrent reads before any result is returned.
await archiveDeferred;
return Array.from({ length: 200 }, (_, i) =>
JSON.stringify(makeArchivedRow(i, "chan-a")),
);
}
return [];
});
setIpcHandler("decrypt_observer_event", async (args) => {
try {
const event = JSON.parse(args.eventJson);
return JSON.parse(event.content);
} catch {
return { kind: "telemetry", channelId: null };
}
});
const qc = makeQueryClient();
const { render, getFetchOlderArchived, unmount } = mountHook("chan-a", qc);
// Step 1-2: Mount on chan-a. Backfill is in flight (deferred).
// Hydration loop call #1 enters fetchOlderArchived and suspends on backfill.
await render("chan-a");
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
// Step 3: Inject call #2 while backfill is still pending and call #1 is
// suspended. Both observe isFetching=false here.
const fetchFn = getFetchOlderArchived();
let call2Promise;
if (fetchFn) {
// Do NOT await yet — let it run concurrently with call #1.
call2Promise = fetchFn();
}
// Let call #2 reach its backfill await before we resolve backfill.
await act(async () => {
await new Promise((r) => setTimeout(r, 5));
});
// Step 4: Resolve backfill. Both calls resume and race to acquire lock.
resolveBackfill();
// Yield to let both calls advance past the post-backfill guard and issue
// their Tauri reads (or be blocked by the lock recheck).
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
// Step 5: Archive responses are still deferred — loop cannot have advanced
// past page 1. Any archiveCallCount > 1 here is purely from concurrent
// reads racing through the backfill await without a lock recheck.
assert.equal(
archiveCallCount,
1,
`Exactly one archive read must start after backfill resolves — got ${archiveCallCount}. Without the post-backfill isFetching recheck, both concurrent callers acquire the lock and both issue a Tauri read (archiveCallCount == 2).`,
);
// Release archive responses so the lock holder can finish and the test
// can unmount cleanly.
resolveArchive();
// Wait for call #2 to settle as well.
if (call2Promise) {
await act(async () => {
await call2Promise;
});
}
await settle(5);
await unmount();
});
/**
* Regression (d): A→B→A — old-A's stale in-flight request must not corrupt
* fresh-A's paging state when the user returns to channel A.
*
* The gap in the channel-string equality check (af45fbf05): using
* `requestChannelId === ps.activeChannelId` as the ownership test fails when
* the user navigates A→B→A. Old-A's request sees `ps.activeChannelId === "A"`
* after the second return to A — so every check passes. Old-A can mark fresh-A
* exhausted and its finally releases fresh-A's lock.
*
* With resetGeneration each applyChannelReset() call increments the counter,
* so A(gen=1) → B(gen=2) → A(gen=3): old-A snapshotted gen=1, which never
* equals gen=3, so all writes are discarded regardless of channel name.
*
* Race sequence:
* 1. Mount on chan-a (gen=1). A's Tauri read = 1 row (short page). Ingest
* starts. A's decrypt is DEFERRED.
* 2. Switch to chan-b (gen=2). B's paging starts.
* 3. Switch BACK to chan-a (gen=3). Fresh-A's hydration starts. Fresh-A's
* first Tauri read is DEFERRED (freshAReadDeferred).
* 4. Resolve old-A's decrypt. Old-A hits short-page branch.
* - Without generation: old-A sees ps.activeChannelId==="chan-a", writes
* ps.hasOlderArchived=false and clears ps.isFetching.
* - With generation: gen=1 !== gen=3, writes discarded.
* 5. LOCK PROBE: call fetchOlderArchived directly. Fresh-A holds the lock
* (its deferred read is in flight). With correct protection the probe
* returns immediately (freshACallCount unchanged). Without it (old-A's
* finally stole the lock), the probe starts a duplicate read.
* 6. Resolve fresh-A's first read (full page). Fresh-A loop continues.
* 7. Assert freshACallCount >= 2 (fresh-A ran past page 1).
*
* VERIFIED: this test is RED when activeChannelId string equality replaces
* resetGeneration (old-A passes every check, marks fresh-A exhausted at step 4).
* GREEN at current head.
*/
it("test_A_B_A_old_request_cannot_corrupt_fresh_A_state", async () => {
let resolveOldADecrypt;
const oldADecryptDeferred = new Promise((resolve) => {
resolveOldADecrypt = resolve;
});
let resolveFreshAFirstRead;
const freshAFirstReadDeferred = new Promise((resolve) => {
resolveFreshAFirstRead = resolve;
});
const PAGE_SIZE = 200;
const makePage = (channelId, offset) =>
Array.from({ length: PAGE_SIZE }, (_, i) =>
makeArchivedRow(offset + i, channelId),
);
let oldADecryptStarted = false;
// Track calls per channel / phase. We only care about chan-a reads on fresh-A.
let freshACallCount = 0;
// After we switch back to A (gen=3), track reads for that phase.
let onFreshA = false;
setIpcHandler("list_save_subscriptions", async () =>
makeOwnerPSubResponse(),
);
setIpcHandler("read_unindexed_observer_rows", async () => []);
setIpcHandler("index_observer_channel_id", async () => null);
setIpcHandler("read_archived_observer_events_for_channel", async (args) => {
if (args.channelId === "chan-a") {
if (!onFreshA) {
// Old-A's read: 1 row = short page.
return [JSON.stringify(makeArchivedRow(1, "chan-a"))];
}
// Fresh-A's reads.
freshACallCount++;
if (freshACallCount === 1) {
// Defer fresh-A's first read.
await freshAFirstReadDeferred;
return makePage("chan-a", 200).map((r) => JSON.stringify(r)); // full
}
if (freshACallCount <= 4)
return makePage("chan-a", freshACallCount * 200).map((r) =>
JSON.stringify(r),
);
return [JSON.stringify(makeArchivedRow(9999, "chan-a"))]; // exhaust
}
if (args.channelId === "chan-b") {
// B gets one short page (we don't care about B's progress here).
return [JSON.stringify(makeArchivedRow(50, "chan-b"))];
}
return [];
});
setIpcHandler("decrypt_observer_event", async (args) => {
try {
const event = JSON.parse(args.eventJson);
const parsed = JSON.parse(event.content);
if (parsed.channelId === "chan-a" && !oldADecryptStarted && !onFreshA) {
oldADecryptStarted = true;
await oldADecryptDeferred; // block OLD A's decrypt
}
return parsed;
} catch {
return { kind: "telemetry", channelId: null };
}
});
const qc = makeQueryClient();
const { render, getFetchOlderArchived, unmount } = mountHook("chan-a", qc);
// Step 1: Mount on chan-a (gen=1). Old-A's Tauri read returns 1 row.
// Ingest starts, decrypt blocks.
await render("chan-a");
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
// Step 2: Switch to chan-b (gen=2).
await render("chan-b");
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
// Step 3: Switch back to chan-a (gen=3). Fresh-A hydration starts.
onFreshA = true;
await render("chan-a");
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
// Fresh-A must have started its first (deferred) read.
assert.ok(
freshACallCount >= 1,
`fresh-A must have started its first read — got freshACallCount=${freshACallCount}`,
);
const freshACountBeforeOldResolve = freshACallCount;
// Step 4: Resolve old-A's decrypt. Without generation check, old-A's
// post-ingest branch writes ps.hasOlderArchived=false (marks fresh-A
// exhausted) and its finally clears ps.isFetching (steals fresh-A's lock).
resolveOldADecrypt();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
// Step 5: LOCK PROBE — fresh-A holds the lock (first read deferred).
// A concurrent fetchOlderArchived call must be rejected (lock held).
// If old-A stole the lock, this probe starts a duplicate read (freshACallCount
// jumps before the deferred first read resolves).
const fetchFn = getFetchOlderArchived();
if (fetchFn) {
await act(async () => {
await fetchFn();
});
}
assert.equal(
freshACallCount,
freshACountBeforeOldResolve,
`Lock probe must not start a new fresh-A read while fresh-A holds the lock (freshACallCount=${freshACallCount}, expected ${freshACountBeforeOldResolve}). Old-A's stale finally stole the lock (A→B→A channel-string equality bug).`,
);
// Step 6: Resolve fresh-A's first read (full page). Loop continues.
resolveFreshAFirstRead();
await settle(10);
// Step 7: Fresh-A must have made at least 2 reads (loop continued past page 1).
// Without generation check, old-A wrote ps.hasOlderArchived=false before
// fresh-A's first page resolved, causing the loop to exit — freshACallCount==1.
assert.ok(
freshACallCount >= 2,
`fresh-A must read at least 2 pages — got ${freshACallCount}. Old-A's stale writes (A→B→A) would leave freshACallCount==1.`,
);
await unmount();
});
});
@@ -21,6 +21,7 @@ import type { RelayEvent } from "@/shared/api/types";
import {
createArchivePagingState,
applyChannelReset,
runHydrationLoop,
} from "./archivePagingState";
export type { ArchivePagingState } from "./archivePagingState";
@@ -88,7 +89,12 @@ export function useArchivedChannelEvents(
return React.useSyncExternalStore(subscribeToStore, getSnapshot);
}
const ARCHIVED_EVENTS_PAGE_SIZE = 50;
const ARCHIVED_EVENTS_PAGE_SIZE = 200;
// Number of pages to load eagerly on panel open (before any scroll). Each page
// is ARCHIVED_EVENTS_PAGE_SIZE frames; 10 pages = 2000 frames, which covers
// agent turns that emit hundreds of frames (e.g. a full code-review turn ~900).
const INITIAL_HYDRATION_BUDGET_PAGES = 10;
/**
* Load-older-on-scroll for archived observer frames, scoped to a single channel.
@@ -138,10 +144,13 @@ export function useLoadArchivedObserverEvents(
// Reset per-channel paging state when channelId changes. Backfill state is
// identity-level (not per-channel) and must NOT be reset here — the backfill
// index covers all channels and only needs to run once per identity mount.
// Only the cursor, exhaustion flag, and fetching lock are channel-scoped.
// Only the cursor, exhaustion flag, fetching lock, channel label, and
// resetGeneration are channel-scoped. resetGeneration is incremented by
// applyChannelReset so in-flight reads from any prior reset (including
// A→B→A) detect staleness and discard their results.
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the intentional reset key; ps is a stable ref excluded from deps by convention; setHasOlderArchived is a stable React state setter
React.useEffect(() => {
applyChannelReset(ps);
applyChannelReset(ps, channelId);
setHasOlderArchived(true);
}, [channelId]);
@@ -259,7 +268,7 @@ export function useLoadArchivedObserverEvents(
ps.backfillPromise = promise;
}, [enabled, hasSubscription]);
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref; ps.isFetching/ps.cursor/ps.backfillPromise/ps.hasOlderArchived are read via the stable ref object, not reactive values
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref; all per-page state (isFetching, cursor, hasOlderArchived, resetGeneration) is read from the ref rather than React state, so this callback is intentionally stable across exhaustion/channel changes
const fetchOlderArchived = React.useCallback(async () => {
if (
!enabled ||
@@ -267,11 +276,18 @@ export function useLoadArchivedObserverEvents(
!hasSubscription ||
!channelId ||
ps.isFetching ||
!hasOlderArchived
!ps.hasOlderArchived
) {
return;
}
// Snapshot the reset generation at the start of this request. Every
// shared-state write below rechecks requestGeneration === ps.resetGeneration
// first. A mismatch means at least one channel switch occurred while we
// were awaiting async I/O — even A→B→A is detected because each switch
// increments resetGeneration. Channel ID is kept only as the query input.
const requestGeneration = ps.resetGeneration;
// Await backfill completion before reading the channel index. This
// guarantees the index is populated before the first paginated read, so
// a scroll-trigger that fires before backfill writes can't return 0 rows
@@ -280,12 +296,21 @@ export function useLoadArchivedObserverEvents(
await ps.backfillPromise;
}
// Re-check after awaiting: hasOlderArchived might have been set false
// while we were waiting (e.g. subscription check failed).
if (!hasOlderArchived) {
// Re-check after awaiting: generation may have advanced (channel switched),
// archive exhausted, or another concurrent caller may have acquired the
// fetch lock while we were suspended on backfill. All three must be
// re-evaluated because any of them could have changed mid-await.
if (
!ps.hasOlderArchived ||
requestGeneration !== ps.resetGeneration ||
ps.isFetching
) {
return;
}
// Acquire the fetch lock under this request's generation. The finally
// block only releases the lock if the generation still matches — so a stale
// in-flight request cannot clear the lock that belongs to a later reset.
ps.isFetching = true;
try {
const before = ps.cursor ?? undefined;
@@ -294,6 +319,13 @@ export function useLoadArchivedObserverEvents(
limit: ARCHIVED_EVENTS_PAGE_SIZE,
});
// Discard result if the generation advanced while the Tauri read was in
// flight (channel switch, including A→B→A). The new channel will start
// its own read with a null cursor.
if (requestGeneration !== ps.resetGeneration) {
return;
}
if (events.length > 0) {
// Cursor = the last row in newest-first order = the oldest event on
// this page. Capture both created_at and id to mirror the compound
@@ -306,6 +338,14 @@ export function useLoadArchivedObserverEvents(
await ingestArchivedObserverEvents(events);
}
// Re-check generation after ingestArchivedObserverEvents: ingestion
// decrypts each frame asynchronously and may take time. If a channel
// switch occurred during that await (including A→B→A), discard all
// remaining shared-state writes — exhaustion and React mirror.
if (requestGeneration !== ps.resetGeneration) {
return;
}
// A short page means the archive is exhausted for this channel.
if (events.length < ARCHIVED_EVENTS_PAGE_SIZE) {
setHasOlderArchived(false);
@@ -314,9 +354,56 @@ export function useLoadArchivedObserverEvents(
} catch (error) {
console.error("[useLoadArchivedObserverEvents] fetch failed:", error);
} finally {
ps.isFetching = false;
// Only release the fetch lock if this request still owns it. If the
// generation advanced (any channel switch including A→B→A), the new
// channel acquired its own lock — releasing here would steal it.
if (requestGeneration === ps.resetGeneration) {
ps.isFetching = false;
}
}
}, [enabled, identityPubkey, hasSubscription, channelId, hasOlderArchived]);
}, [enabled, identityPubkey, hasSubscription, channelId]);
// Eager initial hydration: on panel open (or channel switch), load archive
// pages automatically until the budget is reached or the channel is exhausted.
// This makes archived history visible immediately without any scrolling.
//
// Runs when: enabled + subscription confirmed + channelId resolved +
// hydration not yet done for this channel. Respects `applyChannelReset`
// (which resets initialHydrationDone) so channel switches trigger a fresh
// pass. Uses fetchOlderArchived's existing lock/cursor/backfill-await
// machinery — no parallel state machine.
//
// fetchOlderArchived is now stable (it no longer captures hasOlderArchived
// from React state — it reads ps.hasOlderArchived from the ref), so it is
// safe to call from this effect without coupling the hydration lifecycle to
// React state identity changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref; initialHydrationDone is read from ps (not as a reactive dep) to avoid triggering re-runs; fetchOlderArchived is stable and intentionally omitted
React.useEffect(() => {
if (
!enabled ||
!identityPubkey ||
!hasSubscription ||
!channelId ||
ps.initialHydrationDone
) {
return;
}
// Mark done immediately to prevent concurrent hydration loops. The loop
// runs asynchronously; the signal object handles mid-loop cancellation on
// channel switch (the cleanup fn sets signal.cancelled = true).
ps.initialHydrationDone = true;
const signal = { cancelled: false };
void runHydrationLoop(
ps,
fetchOlderArchived,
INITIAL_HYDRATION_BUDGET_PAGES,
signal,
);
return () => {
signal.cancelled = true;
};
}, [enabled, identityPubkey, hasSubscription, channelId]);
return { fetchOlderArchived, hasOlderArchived };
}
+5 -8
View File
@@ -348,13 +348,10 @@ export function useUpdateChannelMutation(channelId: string | null) {
return updateChannel({ ...input, channelId });
},
onMutate: () => ({ channelId }),
onSuccess: (updatedChannel) => {
if (!channelId) {
return;
}
queryClient.setQueryData<ChannelDetail>(
channelDetailQueryKey(channelId),
channelDetailQueryKey(updatedChannel.id),
updatedChannel,
);
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current = []) =>
@@ -365,7 +362,7 @@ export function useUpdateChannelMutation(channelId: string | null) {
),
);
},
onSettled: () => {
onSettled: (_data, _error, _variables, context) => {
// refetchType "none": onSuccess already cached the relay-returned detail;
// awaiting the full channel-list refetch kept the edit dialog stuck on
// "Saving..." (same failure #1360 fixed for create).
@@ -373,9 +370,9 @@ export function useUpdateChannelMutation(channelId: string | null) {
queryKey: channelsQueryKey,
refetchType: "none",
});
if (channelId) {
if (context?.channelId) {
void queryClient.invalidateQueries({
queryKey: channelDetailQueryKey(channelId),
queryKey: channelDetailQueryKey(context.channelId),
refetchType: "none",
});
}
@@ -27,8 +27,6 @@ import {
useDeleteChannelMutation,
useJoinChannelMutation,
useLeaveChannelMutation,
useSetChannelPurposeMutation,
useSetChannelTopicMutation,
useUnarchiveChannelMutation,
useUpdateChannelMutation,
} from "@/features/channels/hooks";
@@ -36,7 +34,6 @@ import { compareMembersByRole } from "@/features/channels/lib/memberUtils";
import {
DEFAULT_EPHEMERAL_TTL_SECONDS,
formatTtlDuration,
parseTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
@@ -45,7 +42,6 @@ import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
@@ -68,6 +64,12 @@ import {
PANEL_OVERLAY_CLASS,
} from "@/shared/ui/OverlayPanelBackdrop";
import { ChannelCanvas } from "./ChannelCanvas";
import {
CHANNEL_FORM_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_SHELL_CLASS,
} from "./channelFormStyles";
import { ChannelTypeSettings } from "./ChannelTypeSettings";
import { ChannelPermissionsSettings } from "./ChannelPermissionsSettings";
import {
ChannelHero,
ChannelQuickAction,
@@ -78,7 +80,6 @@ import {
IngressRow,
NarrativeField,
NarrativeGroup,
ToggleRow,
} from "./ChannelManagementSheetRows";
import {
ChannelManagementModerationActions,
@@ -118,13 +119,13 @@ export function ChannelManagementSheet({
const membersQuery = useChannelMembersQuery(channelId, open);
const canvasQuery = useCanvasQuery(channelId, channelId !== null && open);
const updateChannelDetailsMutation = useUpdateChannelMutation(channelId);
const setTopicMutation = useSetChannelTopicMutation(channelId);
const setPurposeMutation = useSetChannelPurposeMutation(channelId);
const archiveChannelMutation = useArchiveChannelMutation(channelId);
const unarchiveChannelMutation = useUnarchiveChannelMutation(channelId);
const deleteChannelMutation = useDeleteChannelMutation(channelId);
const joinChannelMutation = useJoinChannelMutation(channelId);
const leaveChannelMutation = useLeaveChannelMutation(channelId);
const channelIdRef = React.useRef(channelId);
channelIdRef.current = channelId;
const detail = detailsQuery.data ?? channel;
const members = React.useMemo(() => {
@@ -159,13 +160,17 @@ export function ChannelManagementSheet({
const [nameDraft, setNameDraft] = React.useState("");
const [descriptionDraft, setDescriptionDraft] = React.useState("");
const [topicDraft, setTopicDraft] = React.useState("");
const [purposeDraft, setPurposeDraft] = React.useState("");
const [isPrivateDraft, setIsPrivateDraft] = React.useState(false);
const [isEphemeralDraft, setIsEphemeralDraft] = React.useState(false);
const [ttlDraft, setTtlDraft] = React.useState("");
const [ttlSecondsDraft, setTtlSecondsDraft] = React.useState(
DEFAULT_EPHEMERAL_TTL_SECONDS,
);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false);
const [isConvertingVisibility, setIsConvertingVisibility] =
React.useState(false);
const [hasUserEditedChannelDraft, setHasUserEditedChannelDraft] =
React.useState(false);
const [activeView, setActiveView] = React.useState<"summary" | "canvas">(
"summary",
);
@@ -194,13 +199,10 @@ export function ChannelManagementSheet({
setNameDraft(detail.name);
setDescriptionDraft(detail.description);
setTopicDraft(detail.topic ?? "");
setPurposeDraft(detail.purpose ?? "");
setIsPrivateDraft(detail.visibility === "private");
setIsEphemeralDraft(detail.ttlSeconds !== null);
setTtlDraft(
detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "",
);
setTtlSecondsDraft(detail.ttlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS);
setHasUserEditedChannelDraft(false);
setActiveView("summary");
}, [detail, open]);
@@ -232,19 +234,13 @@ export function ChannelManagementSheet({
onOpenChange(next);
}
// Parsed seconds for the ephemeral TTL field. `null` when the field is empty
// or malformed; the form blocks saving on a non-empty malformed value.
const parsedTtlSeconds = parseTtlDuration(ttlDraft);
const ttlInvalid =
isEphemeralDraft && ttlDraft.trim() !== "" && parsedTtlSeconds === null;
const currentVisibility = detail?.visibility ?? channel.visibility;
const currentTtlSeconds = detail?.ttlSeconds ?? null;
const nextVisibility: "open" | "private" = isPrivateDraft
? "private"
: "open";
const nextTtlSeconds: number | null = isEphemeralDraft
? (parsedTtlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS)
? ttlSecondsDraft
: null;
const lifecycleDirty =
nextVisibility !== currentVisibility ||
@@ -254,22 +250,11 @@ export function ChannelManagementSheet({
const nameDirty = nameDraft.trim() !== resolvedChannel.name.trim();
const descriptionDirty =
descriptionDraft.trim() !== resolvedChannel.description.trim();
const topicDirty = topicDraft.trim() !== (resolvedChannel.topic ?? "").trim();
const purposeDirty =
purposeDraft.trim() !== (resolvedChannel.purpose ?? "").trim();
const isSavingChannelEdits =
updateChannelDetailsMutation.isPending ||
setTopicMutation.isPending ||
setPurposeMutation.isPending;
const hasChannelEditChanges =
nameDirty ||
descriptionDirty ||
lifecycleDirty ||
topicDirty ||
purposeDirty;
const isSavingChannelEdits = updateChannelDetailsMutation.isPending;
const hasChannelEditChanges = nameDirty || descriptionDirty || lifecycleDirty;
const canSaveChannelEdits =
nameDraft.trim().length > 0 &&
!ttlInvalid &&
hasUserEditedChannelDraft &&
hasChannelEditChanges &&
!isSavingChannelEdits;
const canvasContent = canvasQuery.data?.content?.trim() ?? "";
@@ -279,6 +264,18 @@ export function ChannelManagementSheet({
: undefined;
const canOpenCanvas = hasCanvas || canEditNarrative;
function handleEditDialogOpenChange(next: boolean) {
if (!next) {
setNameDraft(resolvedChannel.name);
setDescriptionDraft(resolvedChannel.description);
setIsEphemeralDraft(currentTtlSeconds !== null);
setTtlSecondsDraft(currentTtlSeconds ?? DEFAULT_EPHEMERAL_TTL_SECONDS);
setHasUserEditedChannelDraft(false);
}
setIsEditDialogOpen(next);
}
async function handleSaveChannelEdits() {
try {
if (nameDirty || descriptionDirty || lifecycleDirty) {
@@ -294,20 +291,32 @@ export function ChannelManagementSheet({
});
}
if (topicDirty) {
await setTopicMutation.mutateAsync({ topic: topicDraft.trim() });
}
if (purposeDirty) {
await setPurposeMutation.mutateAsync({ purpose: purposeDraft.trim() });
}
setHasUserEditedChannelDraft(false);
setIsEditDialogOpen(false);
} catch {
// React Query stores mutation errors; keep the dialog open and render them.
}
}
async function handleConvertVisibility(visibility: "open" | "private") {
if (visibility === currentVisibility) {
return;
}
setIsConvertingVisibility(true);
try {
const updatedChannel = await updateChannelDetailsMutation.mutateAsync({
visibility,
});
if (channelIdRef.current === updatedChannel.id) {
setIsPrivateDraft(visibility === "private");
}
} catch {
// React Query stores mutation errors; keep the dialog open and render them.
} finally {
setIsConvertingVisibility(false);
}
}
return (
<DialogPrimitive.Root
modal={!isSplitLayout}
@@ -421,153 +430,108 @@ export function ChannelManagementSheet({
)}
{canManageChannel ? (
<Dialog onOpenChange={setIsEditDialogOpen} open={isEditDialogOpen}>
<DialogContent className="max-w-lg overflow-hidden p-0">
<Dialog
onOpenChange={handleEditDialogOpenChange}
open={isEditDialogOpen}
>
<DialogContent
aria-describedby={undefined}
className="max-w-lg overflow-hidden p-0"
>
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Edit channel</DialogTitle>
<DialogDescription>
Update settings for{" "}
<span className="font-medium">{resolvedChannel.name}</span>.
</DialogDescription>
<DialogTitle>
Edit {currentVisibility === "private" ? "private" : "public"}{" "}
channel
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
<div className="space-y-3">
<div className="space-y-5">
<div className="space-y-1.5">
<label
className="text-sm font-medium"
className="text-sm font-medium text-foreground"
htmlFor="channel-name"
>
Name
</label>
<Input
data-testid="channel-management-name"
disabled={isSavingChannelEdits}
id="channel-name"
onChange={(event) => setNameDraft(event.target.value)}
value={nameDraft}
/>
<div
className={cn(
"flex min-h-11 items-center px-3",
CHANNEL_FORM_FIELD_SHELL_CLASS,
)}
>
<Input
className={cn(
"h-8 px-0 py-0 leading-6",
CHANNEL_FORM_FIELD_CONTROL_CLASS,
)}
data-testid="channel-management-name"
disabled={isSavingChannelEdits}
id="channel-name"
onChange={(event) => {
setNameDraft(event.target.value);
setHasUserEditedChannelDraft(true);
}}
value={nameDraft}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
className="text-sm font-medium text-foreground"
htmlFor="channel-description"
>
Description
</label>
<Textarea
className="min-h-24"
data-testid="channel-management-description"
disabled={isSavingChannelEdits}
id="channel-description"
onChange={(event) =>
setDescriptionDraft(event.target.value)
}
value={descriptionDraft}
/>
<div className={CHANNEL_FORM_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-20 resize-none px-3 py-3 leading-5",
CHANNEL_FORM_FIELD_CONTROL_CLASS,
)}
data-testid="channel-management-description"
disabled={isSavingChannelEdits}
id="channel-description"
onChange={(event) => {
setDescriptionDraft(event.target.value);
setHasUserEditedChannelDraft(true);
}}
rows={2}
value={descriptionDraft}
/>
</div>
</div>
</div>
{resolvedChannel.channelType !== "dm" ? (
<div
className="space-y-3"
className="space-y-5"
data-testid="channel-management-lifecycle"
>
<FieldGroup>
<ToggleRow
checked={isPrivateDraft}
description="Only members can find and join this channel."
disabled={isSavingChannelEdits}
label="Private"
onCheckedChange={setIsPrivateDraft}
testId="channel-management-private-toggle"
/>
<ToggleRow
checked={isEphemeralDraft}
description="Automatically delete this channel after a set time."
disabled={isSavingChannelEdits}
label="Ephemeral"
onCheckedChange={setIsEphemeralDraft}
testId="channel-management-ephemeral-toggle"
/>
</FieldGroup>
{isEphemeralDraft ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-ttl"
>
Timeout
</label>
<Input
aria-invalid={ttlInvalid}
data-testid="channel-management-ttl"
disabled={isSavingChannelEdits}
id="channel-ttl"
onChange={(event) => setTtlDraft(event.target.value)}
placeholder="e.g. 1d, 12h, 30m"
value={ttlDraft}
/>
<p
className={cn(
"text-xs",
ttlInvalid
? "text-destructive"
: "text-muted-foreground",
)}
>
{ttlInvalid
? "Enter a duration like 1d, 12h, or 30m."
: "Defaults to 7d when left empty. Resets the deletion countdown from now whenever changed."}
</p>
</div>
) : null}
</div>
) : null}
{canEditNarrative ? (
<div className="space-y-5">
<div className="space-y-3">
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-topic"
>
Topic
</label>
<Input
data-testid="channel-management-topic"
disabled={isSavingChannelEdits}
id="channel-topic"
onChange={(event) =>
setTopicDraft(event.target.value)
}
value={topicDraft}
/>
</div>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-purpose"
>
Purpose
</label>
<Input
data-testid="channel-management-purpose"
disabled={isSavingChannelEdits}
id="channel-purpose"
onChange={(event) =>
setPurposeDraft(event.target.value)
}
value={purposeDraft}
/>
</div>
</div>
<ChannelTypeSettings
disabled={isSavingChannelEdits}
onTemporaryChange={(temporary) => {
setIsEphemeralDraft(temporary);
setHasUserEditedChannelDraft(true);
}}
onTtlSecondsChange={(ttlSeconds) => {
setTtlSecondsDraft(ttlSeconds);
setHasUserEditedChannelDraft(true);
}}
temporary={isEphemeralDraft}
testIdPrefix="channel-management"
ttlSeconds={ttlSecondsDraft}
/>
<ChannelPermissionsSettings
disabled={isSavingChannelEdits}
isPending={isConvertingVisibility}
onVisibilityChange={(visibility) =>
void handleConvertVisibility(visibility)
}
testIdPrefix="channel-management"
visibility={isPrivateDraft ? "private" : "open"}
/>
</div>
) : null}
@@ -576,21 +540,11 @@ export function ChannelManagementSheet({
{updateChannelDetailsMutation.error.message}
</p>
) : null}
{setTopicMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{setTopicMutation.error.message}
</p>
) : null}
{setPurposeMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{setPurposeMutation.error.message}
</p>
) : null}
</div>
<div className="flex shrink-0 justify-end gap-2 border-t border-border/60 px-6 py-4">
<Button
onClick={() => setIsEditDialogOpen(false)}
onClick={() => handleEditDialogOpenChange(false)}
size="sm"
type="button"
variant="outline"
@@ -11,7 +11,6 @@ import { toast } from "sonner";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Switch } from "@/shared/ui/switch";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
function getChannelIcon(channelType: Channel["channelType"]): LucideIcon {
@@ -268,38 +267,3 @@ export function IngressRow({
</button>
);
}
export function ToggleRow({
checked,
description,
disabled,
label,
onCheckedChange,
testId,
}: {
checked: boolean;
description: string;
disabled?: boolean;
label: string;
onCheckedChange: (checked: boolean) => void;
testId: string;
}) {
return (
<div className="flex items-center gap-3 px-4 py-3">
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{label}
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
{description}
</span>
</span>
<Switch
checked={checked}
data-testid={testId}
disabled={disabled}
onCheckedChange={onCheckedChange}
/>
</div>
);
}
@@ -202,7 +202,7 @@ export function ChannelMembersBar({
type="button"
variant="outline"
>
<Settings2 />
<EllipsisVertical />
</Button>
</TooltipTrigger>
<TooltipContent>Channel settings</TooltipContent>
@@ -0,0 +1,98 @@
import { ChevronDown, LoaderCircle } from "lucide-react";
import type { ChannelVisibility } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { cn } from "@/shared/lib/cn";
export function ChannelPermissionsSettings({
disabled,
isPending = false,
onVisibilityChange,
testIdPrefix,
visibility,
}: {
disabled?: boolean;
isPending?: boolean;
onVisibilityChange: (visibility: ChannelVisibility) => void;
testIdPrefix: string;
visibility: ChannelVisibility;
}) {
const visibilityLabel = visibility === "private" ? "Private" : "Public";
return (
<div
className={cn(
"flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3",
disabled && "opacity-50",
)}
data-testid={`${testIdPrefix}-permissions-container`}
>
<span className="text-sm font-medium text-foreground">Visibility</span>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-busy={isPending}
aria-label={
isPending
? "Updating visibility"
: `Visibility: ${visibilityLabel}`
}
className="-mr-2.5 ml-auto h-9 w-fit justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
data-testid={`${testIdPrefix}-permissions`}
disabled={disabled}
type="button"
variant="ghost"
>
<span aria-live="polite" className="text-right">
{isPending ? "Updating…" : visibilityLabel}
</span>
{isPending ? (
<LoaderCircle
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground/70 motion-safe:animate-spin"
/>
) : (
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
style={{
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}
>
<DropdownMenuRadioGroup
onValueChange={(nextVisibility) =>
onVisibilityChange(
nextVisibility === "private" ? "private" : "open",
)
}
value={visibility}
>
<DropdownMenuRadioItem
data-testid={`${testIdPrefix}-permissions-option-open`}
value="open"
>
Public
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
data-testid={`${testIdPrefix}-permissions-option-private`}
value="private"
>
Private
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,91 @@
import { ChevronDown, ClockFading, Hash } from "lucide-react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
export function ChannelTypePicker({
align = "start",
ariaLabel,
className,
disabled,
onOpenChange,
onTemporaryChange,
open,
temporary,
temporaryOptionAriaLabel = "Temporary channel",
testId,
}: {
align?: React.ComponentProps<typeof DropdownMenuContent>["align"];
ariaLabel?: string;
className?: string;
disabled?: boolean;
onOpenChange?: (open: boolean) => void;
onTemporaryChange: (temporary: boolean) => void;
open?: boolean;
temporary: boolean;
temporaryOptionAriaLabel?: string;
testId?: string;
}) {
const [internalOpen, setInternalOpen] = React.useState(false);
const pickerOpen = open ?? internalOpen;
const setPickerOpen = onOpenChange ?? setInternalOpen;
const label = temporary ? "Temporary" : "Ongoing";
const Icon = temporary ? ClockFading : Hash;
function selectType(nextType: string) {
onTemporaryChange(nextType === "temporary");
setPickerOpen(false);
}
return (
<DropdownMenu modal={false} onOpenChange={setPickerOpen} open={pickerOpen}>
<DropdownMenuTrigger asChild>
<Button
aria-label={ariaLabel ?? `Channel type: ${label}`}
className={cn(
"h-9 w-fit px-2.5 text-sm font-medium text-foreground hover:bg-muted/50",
className,
)}
data-testid={testId}
disabled={disabled}
type="button"
variant="ghost"
>
<Icon className="h-4 w-4" />
{label}
<ChevronDown className="h-4 w-4 text-muted-foreground/70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
onCloseAutoFocus={(event) => event.preventDefault()}
style={{
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}
>
<DropdownMenuRadioGroup
onValueChange={selectType}
value={temporary ? "temporary" : "ongoing"}
>
<DropdownMenuRadioItem aria-label="Ongoing channel" value="ongoing">
Ongoing
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
aria-label={temporaryOptionAriaLabel}
value="temporary"
>
Temporary
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,161 @@
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
DEFAULT_EPHEMERAL_TTL_SECONDS,
formatTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { ChannelTypePicker } from "./ChannelTypePicker";
const EPHEMERAL_TIMEOUT_OPTIONS = [
{ label: "30 minutes", seconds: 30 * 60 },
{ label: "1 hour", seconds: 60 * 60 },
{ label: "6 hours", seconds: 6 * 60 * 60 },
{ label: "12 hours", seconds: 12 * 60 * 60 },
{ label: "1 day", seconds: 24 * 60 * 60 },
{ label: "3 days", seconds: 3 * 24 * 60 * 60 },
{ label: "7 days", seconds: DEFAULT_EPHEMERAL_TTL_SECONDS },
{ label: "14 days", seconds: 14 * 24 * 60 * 60 },
{ label: "30 days", seconds: 30 * 24 * 60 * 60 },
] as const;
const CHANNEL_TYPE_RESIZE_TRANSITION = {
duration: 0.22,
ease: [0.23, 1, 0.32, 1],
} as const;
export function ChannelTypeSettings({
disabled,
onOpenChange,
onTemporaryChange,
onTtlSecondsChange,
open,
temporary,
testIdPrefix,
ttlSeconds,
}: {
disabled?: boolean;
onOpenChange?: (open: boolean) => void;
onTemporaryChange: (temporary: boolean) => void;
onTtlSecondsChange: (ttlSeconds: number) => void;
open?: boolean;
temporary: boolean;
testIdPrefix: string;
ttlSeconds: number;
}) {
const shouldReduceMotion = useReducedMotion();
const channelTypeResizeTransition = shouldReduceMotion
? { duration: 0 }
: CHANNEL_TYPE_RESIZE_TRANSITION;
const selectedTimeoutOption = EPHEMERAL_TIMEOUT_OPTIONS.find(
(option) => option.seconds === ttlSeconds,
);
const timeoutOptions = selectedTimeoutOption
? EPHEMERAL_TIMEOUT_OPTIONS
: [
{
label: `Current (${formatTtlDuration(ttlSeconds)})`,
seconds: ttlSeconds,
},
...EPHEMERAL_TIMEOUT_OPTIONS,
];
return (
<div
className="overflow-hidden rounded-xl border border-input bg-background"
data-testid={`${testIdPrefix}-channel-type-container`}
>
<div
className="flex items-center justify-between gap-3 px-3 py-3"
data-testid={`${testIdPrefix}-channel-type-row`}
>
<span className="text-sm font-medium text-foreground">
Channel type
</span>
<ChannelTypePicker
align="end"
className="-mr-2.5"
disabled={disabled}
onOpenChange={onOpenChange}
onTemporaryChange={onTemporaryChange}
open={open}
temporary={temporary}
testId={`${testIdPrefix}-channel-type`}
/>
</div>
<AnimatePresence initial={false}>
{temporary ? (
<motion.div
animate={{ height: "auto", opacity: 1 }}
className="overflow-hidden"
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
key={`${testIdPrefix}-ephemeral-settings`}
transition={channelTypeResizeTransition}
>
<div
className="relative flex items-center justify-between gap-3 px-3 py-3 before:absolute before:inset-x-3 before:top-0 before:border-t before:border-border/70"
data-testid={`${testIdPrefix}-ephemeral-settings`}
>
<label
className="text-sm font-medium"
htmlFor={`${testIdPrefix}-ttl`}
>
Expires after
</label>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Expires after"
className="-mr-2.5 ml-auto h-9 w-fit justify-end px-2.5 text-right text-sm font-medium text-foreground hover:bg-muted/50"
data-testid={`${testIdPrefix}-ttl`}
disabled={disabled}
id={`${testIdPrefix}-ttl`}
type="button"
variant="ghost"
>
<span className="text-right">
{selectedTimeoutOption?.label ??
`Current (${formatTtlDuration(ttlSeconds)})`}
</span>
<ChevronDown className="size-4 shrink-0 text-muted-foreground/70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
style={{
minWidth: "var(--radix-dropdown-menu-trigger-width)",
}}
>
<DropdownMenuRadioGroup
onValueChange={(value) => onTtlSecondsChange(Number(value))}
value={String(ttlSeconds)}
>
{timeoutOptions.map((option) => (
<DropdownMenuRadioItem
data-testid={`${testIdPrefix}-ttl-option-${option.seconds}`}
key={option.seconds}
value={String(option.seconds)}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,5 @@
export const CHANNEL_FORM_FIELD_SHELL_CLASS =
"rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50";
export const CHANNEL_FORM_FIELD_CONTROL_CLASS =
"border-0 bg-transparent text-muted-foreground/55 shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0";
@@ -19,6 +19,8 @@ import {
} from "@/features/agents/activeAgentTurnsStore";
import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore";
import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync";
import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache";
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
@@ -33,13 +35,21 @@ import type { Community } from "./types";
* destroyed via effect cleanup and do not need entries here.
* See AGENTS.md "Community Switching" for the full contract.
*/
function resetCommunityState(): void {
function resetCommunityState({
resetAvatarState,
}: {
resetAvatarState: boolean;
}): void {
relayClient.disconnect();
resetRateLimitGate();
clearAllDrafts();
resetAgentObserverStore();
resetActiveAgentTurnsStore();
resetAgentWorkingSignal();
if (resetAvatarState) {
resetAvatarProfileSync();
resetAvatarPresentations();
}
resetSidebarRelayConnectionCardState();
resetMediaCaches();
resetVideoPlayerState();
@@ -84,6 +94,10 @@ export function useCommunityInit(
// Track the previously-applied community ID so we can save its turn state
// before resetting when the user switches to a different community.
const prevCommunityIdRef = useRef<string | null>(null);
// Deferred avatar work owns the relay captured when it was queued. A
// same-relay reconnect during onboarding must not cancel that work, while an
// actual relay boundary must clear both the queue and its presentation probe.
const appliedRelayUrlRef = useRef<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token/reposDir) — depending on the whole object would trigger resets on name-only changes
useEffect(() => {
@@ -145,9 +159,13 @@ export function useCommunityInit(
// store under the outgoing community ID and delete its snapshot.
prevCommunityIdRef.current = null;
}
resetCommunityState();
resetCommunityState({
resetAvatarState:
appliedRelayUrlRef.current !== activeCommunity.relayUrl,
});
}
hasInitializedRef.current = true;
appliedRelayUrlRef.current = activeCommunity.relayUrl;
// Apply community config to the Tauri backend.
//
@@ -14,6 +14,7 @@ import {
WELCOME_SURFACE_READY_EVENT,
} from "@/features/onboarding/welcome";
import { useAvatarPresentation } from "@/features/profile/avatarPresentationStore";
import { registerAvatarWhenReady } from "@/features/profile/avatarProfileSync";
import { profileQueryKey } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
@@ -152,6 +153,7 @@ export function CommunityOnboardingFlow({
const systemColorScheme = useSystemColorScheme();
const [displayName, setDisplayName] = React.useState("");
const [avatarUrl, setAvatarUrl] = React.useState("");
const avatarPresentation = useAvatarPresentation(avatarUrl);
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false);
const [starterPersonas, setStarterPersonas] = React.useState<AgentPersona[]>(
@@ -380,10 +382,34 @@ export function CommunityOnboardingFlow({
if (!displayName.trim()) return;
setIsPending(true);
try {
await updateProfile({
displayName: displayName.trim(),
avatarUrl: avatarUrl.trim() || undefined,
});
const candidateAvatarUrl = avatarUrl.trim();
const presentationState = avatarPresentation?.state;
const shouldSaveCandidate =
candidateAvatarUrl.length > 0 &&
presentationState !== "failed" &&
presentationState !== "pending";
const deferredAvatar =
candidateAvatarUrl && presentationState && presentationState !== "ready"
? registerAvatarWhenReady({
avatarUrl: candidateAvatarUrl,
relayUrl: transaction.relayUrl,
})
: null;
try {
const profile = await updateProfile({
displayName: displayName.trim(),
avatarUrl: shouldSaveCandidate ? candidateAvatarUrl : undefined,
});
deferredAvatar?.release({
expectedPubkey: profile.pubkey,
expectedAvatarUrl: profile.avatarUrl,
});
} catch (error) {
deferredAvatar?.cancel();
throw error;
}
update({ stage: "team-intro", error: undefined });
} catch (error) {
if (isRelayMembershipDeniedError(error)) {
@@ -125,7 +125,10 @@ async function verifyPresentation(
export function beginAvatarPresentation(remoteUrl: string, image: Blob): void {
const existing = presentations.get(remoteUrl);
if (existing) releaseLocalPreview(existing);
if (existing) {
toast.dismiss(toastId(remoteUrl));
releaseLocalPreview(existing);
}
const localPreviewUrl = URL.createObjectURL(image);
const entry: AvatarPresentationEntry = {
@@ -139,6 +142,27 @@ export function beginAvatarPresentation(remoteUrl: string, image: Blob): void {
void verifyPresentation(entry);
}
export function disposeAvatarPresentation(remoteUrl: string): void {
const entry = presentations.get(remoteUrl);
if (!entry) return;
entry.generation = nextGeneration++;
toast.dismiss(toastId(remoteUrl));
releaseLocalPreview(entry);
presentations.delete(remoteUrl);
emitChange();
}
export function resetAvatarPresentations(): void {
for (const entry of presentations.values()) {
entry.generation = nextGeneration++;
toast.dismiss(toastId(entry.remoteUrl));
releaseLocalPreview(entry);
}
presentations.clear();
emitChange();
}
export function retryAvatarPresentation(remoteUrl: string): void {
const entry = presentations.get(remoteUrl);
if (entry?.snapshot.state !== "failed") return;
@@ -172,3 +196,16 @@ export function useAvatarPresentation(
() => null,
);
}
export function useAvatarSelection(
avatarUrl: string,
onUrlChange: (avatarUrl: string) => void,
): (avatarUrl: string) => void {
return React.useCallback(
(nextAvatarUrl: string) => {
if (avatarUrl !== nextAvatarUrl) disposeAvatarPresentation(avatarUrl);
onUrlChange(nextAvatarUrl);
},
[avatarUrl, onUrlChange],
);
}
@@ -0,0 +1,303 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
createAvatarProfileSync,
createProfileCacheRefreshQueue,
} from "./avatarProfileSync.ts";
const INPUT = {
avatarUrl: "https://old-relay.example/avatar.png",
relayUrl: "wss://old-relay.example",
expectedPubkey: "pubkey",
expectedAvatarUrl: null,
};
function createHarness({
initialState = "pending",
saveProfile,
getActivePubkey = async () => INPUT.expectedPubkey,
scheduleRetry,
} = {}) {
let presentation = { displayUrl: INPUT.avatarUrl, state: initialState };
let listener = () => {};
let unsubscribeCount = 0;
const saves = [];
const refreshed = [];
const sync = createAvatarProfileSync({
getPresentation: () => presentation,
subscribe: (nextListener) => {
listener = nextListener;
return () => {
unsubscribeCount += 1;
};
},
saveProfile:
saveProfile ??
(async (input) => {
saves.push(input);
return { avatarUrl: input.avatarUrl, pubkey: input.expectedPubkey };
}),
getActivePubkey,
refreshCaches: async (profile, input) => {
refreshed.push({ profile, input });
},
scheduleRetry,
});
return {
get unsubscribeCount() {
return unsubscribeCount;
},
listener: () => listener(),
refreshed,
saves,
removePresentation: () => {
presentation = null;
},
setState: (state) => {
presentation = {
...(presentation ?? { displayUrl: INPUT.avatarUrl }),
state,
};
},
sync,
};
}
async function flushPromises() {
await new Promise((resolve) => setImmediate(resolve));
}
test("saves at the captured relay and refreshes caches after verification", async () => {
const harness = createHarness();
harness.sync.saveWhenReady(INPUT);
harness.setState("ready");
harness.listener();
await flushPromises();
assert.deepEqual(harness.saves, [INPUT]);
assert.equal(harness.refreshed.length, 1);
assert.equal(harness.refreshed[0].input.relayUrl, INPUT.relayUrl);
assert.equal(harness.unsubscribeCount, 1);
});
test("community reset cancels a pending avatar save", async () => {
const harness = createHarness();
harness.sync.saveWhenReady(INPUT);
harness.sync.reset();
harness.setState("ready");
harness.listener();
await flushPromises();
assert.deepEqual(harness.saves, []);
assert.equal(harness.unsubscribeCount, 1);
});
test("a reset sync accepts deferred work from the next community", async () => {
const harness = createHarness();
harness.sync.reset();
harness.setState("ready");
const nextInput = {
...INPUT,
relayUrl: "wss://next-relay.example",
};
harness.sync.saveWhenReady(nextInput);
await flushPromises();
assert.deepEqual(harness.saves, [nextInput]);
assert.equal(harness.refreshed.length, 1);
});
test("skips cache refresh when the active identity changes during save", async () => {
let resolveSave;
const savePromise = new Promise((resolve) => {
resolveSave = resolve;
});
let activePubkey = INPUT.expectedPubkey;
const harness = createHarness({
initialState: "ready",
saveProfile: () => savePromise,
getActivePubkey: async () => activePubkey,
});
harness.sync.saveWhenReady(INPUT);
activePubkey = "replacement-pubkey";
resolveSave({ avatarUrl: INPUT.avatarUrl, pubkey: INPUT.expectedPubkey });
await flushPromises();
assert.deepEqual(harness.refreshed, []);
assert.equal(harness.unsubscribeCount, 1);
});
test("retries a transient save and keeps the sync pending until success", async () => {
const scheduled = [];
let attempt = 0;
const harness = createHarness({
initialState: "ready",
saveProfile: async (input) => {
attempt += 1;
if (attempt === 1) throw new Error("relay unreachable: network error");
return { avatarUrl: input.avatarUrl, pubkey: input.expectedPubkey };
},
scheduleRetry: (callback, delayMs) => {
const retry = { callback, delayMs, cancelled: false };
scheduled.push(retry);
return () => {
retry.cancelled = true;
};
},
});
harness.sync.saveWhenReady(INPUT);
await flushPromises();
assert.equal(attempt, 1);
assert.equal(harness.unsubscribeCount, 0);
assert.equal(scheduled[0].delayMs, 5_000);
harness.listener();
await flushPromises();
assert.equal(attempt, 1, "store updates must not bypass retry backoff");
scheduled[0].callback();
await flushPromises();
assert.equal(attempt, 2);
assert.equal(harness.refreshed.length, 1);
assert.equal(harness.unsubscribeCount, 1);
});
test("reset cancels a scheduled transient retry", async () => {
let scheduled;
let attempts = 0;
const harness = createHarness({
initialState: "ready",
saveProfile: async () => {
attempts += 1;
throw new Error("relay rate-limited: retry in 5s");
},
scheduleRetry: (callback, delayMs) => {
scheduled = { callback, delayMs, cancelled: false };
return () => {
scheduled.cancelled = true;
};
},
});
harness.sync.saveWhenReady(INPUT);
await flushPromises();
harness.sync.reset();
assert.equal(scheduled.delayMs, 5_000);
assert.equal(scheduled.cancelled, true);
scheduled.callback();
await flushPromises();
assert.equal(attempts, 1);
});
test("registration preserves readiness until the initial profile write completes", async () => {
const harness = createHarness();
const registration = harness.sync.registerWhenReady({
avatarUrl: INPUT.avatarUrl,
relayUrl: INPUT.relayUrl,
});
harness.setState("ready");
harness.listener();
harness.removePresentation();
assert.deepEqual(harness.saves, []);
registration.release({
expectedPubkey: INPUT.expectedPubkey,
expectedAvatarUrl: null,
});
await flushPromises();
assert.deepEqual(harness.saves, [INPUT]);
assert.equal(harness.refreshed.length, 1);
});
test("cancelled registration cannot save after the initial profile write fails", async () => {
const harness = createHarness();
const registration = harness.sync.registerWhenReady({
avatarUrl: INPUT.avatarUrl,
relayUrl: INPUT.relayUrl,
});
harness.setState("ready");
harness.listener();
registration.cancel();
registration.release({
expectedPubkey: INPUT.expectedPubkey,
expectedAvatarUrl: null,
});
await flushPromises();
assert.deepEqual(harness.saves, []);
assert.equal(harness.unsubscribeCount, 1);
});
test("cache refresh waits for a provider and flushes exactly once", async () => {
const refreshed = [];
const queue = createProfileCacheRefreshQueue(
async (client, profile, relayUrl) => {
refreshed.push({ client, profile, relayUrl });
},
);
const profile = { avatarUrl: INPUT.avatarUrl, pubkey: INPUT.expectedPubkey };
const client = {};
await queue.enqueue({ profile, input: INPUT });
assert.deepEqual(refreshed, []);
const detach = queue.setClient(client);
await flushPromises();
assert.deepEqual(refreshed, [{ client, profile, relayUrl: INPUT.relayUrl }]);
detach();
const detachAgain = queue.setClient(client);
await flushPromises();
assert.equal(refreshed.length, 1);
detachAgain();
});
test("cache refresh reset discards work from the previous community", async () => {
const refreshed = [];
const queue = createProfileCacheRefreshQueue(
async (client, profile, relayUrl) => {
refreshed.push({ client, profile, relayUrl });
},
);
await queue.enqueue({
profile: { avatarUrl: INPUT.avatarUrl, pubkey: INPUT.expectedPubkey },
input: INPUT,
});
queue.reset();
queue.setClient({});
await flushPromises();
assert.deepEqual(refreshed, []);
});
test("cache refresh follows only a successful save", async () => {
let rejectSave;
const savePromise = new Promise((_, reject) => {
rejectSave = reject;
});
const harness = createHarness({
initialState: "ready",
saveProfile: () => savePromise,
});
harness.sync.saveWhenReady(INPUT);
rejectSave(new Error("stale baseline"));
await flushPromises();
assert.deepEqual(harness.refreshed, []);
assert.equal(harness.unsubscribeCount, 1);
});
@@ -0,0 +1,262 @@
import type { QueryClient } from "@tanstack/react-query";
import {
getAvatarPresentation,
subscribeAvatarPresentations,
type AvatarPresentation,
} from "@/features/profile/avatarPresentationStore";
import { refreshProfileCaches } from "@/features/profile/profileCacheSync";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { updateProfileAtRelay } from "@/shared/api/tauriProfiles";
import type { Profile } from "@/shared/api/types";
import { isRelayUnreachableError } from "@/shared/lib/relayError";
const AVATAR_SAVE_RETRY_DELAYS_MS = [5_000, 30_000, 120_000] as const;
type PendingAvatarSave = {
avatarUrl: string;
relayUrl: string;
expectedPubkey: string;
expectedAvatarUrl: string | null;
};
type DeferredAvatarSave = Pick<PendingAvatarSave, "avatarUrl" | "relayUrl">;
type AvatarSaveRegistration = {
cancel: () => void;
release: (
input: Pick<PendingAvatarSave, "expectedPubkey" | "expectedAvatarUrl">,
) => void;
};
type AvatarProfileSyncDependencies = {
getPresentation: (avatarUrl: string) => AvatarPresentation | null;
subscribe: (listener: () => void) => () => void;
saveProfile: (input: PendingAvatarSave) => Promise<Profile>;
getActivePubkey: () => Promise<string | null>;
refreshCaches: (profile: Profile, input: PendingAvatarSave) => Promise<void>;
scheduleRetry?: (callback: () => void, delayMs: number) => () => void;
};
function isRetryableAvatarSaveError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
isRelayUnreachableError(error) || message.startsWith("relay rate-limited:")
);
}
export function createAvatarProfileSync(
dependencies: AvatarProfileSyncDependencies,
) {
const pendingSyncs = new Map<string, () => void>();
let generation = 0;
const reset = () => {
generation += 1;
for (const stop of pendingSyncs.values()) stop();
pendingSyncs.clear();
};
const queueSave = (input: PendingAvatarSave, assumeReady = false): void => {
const syncKey = `${input.relayUrl}:${input.expectedPubkey}:${input.avatarUrl}`;
if (pendingSyncs.has(syncKey)) return;
let isSaving = false;
let isReady = assumeReady;
let retryAttempt = 0;
let cancelRetry: (() => void) | null = null;
let unsubscribe = () => {};
const queuedGeneration = generation;
const stop = () => {
cancelRetry?.();
cancelRetry = null;
unsubscribe();
pendingSyncs.delete(syncKey);
};
const saveIfReady = () => {
if (generation !== queuedGeneration || cancelRetry !== null) return;
const presentation = dependencies.getPresentation(input.avatarUrl);
if (presentation?.state === "ready") isReady = true;
if (!presentation && !isReady) {
stop();
return;
}
if (!isReady || isSaving) return;
isSaving = true;
void dependencies
.saveProfile(input)
.then(async (profile) => {
if (generation !== queuedGeneration) return;
const activePubkey = await dependencies.getActivePubkey();
if (
generation !== queuedGeneration ||
activePubkey?.toLowerCase() !== input.expectedPubkey.toLowerCase()
) {
return;
}
await dependencies.refreshCaches(profile, input);
})
.then(stop)
.catch((error: unknown) => {
if (
generation !== queuedGeneration ||
!isRetryableAvatarSaveError(error)
) {
stop();
return;
}
const delayMs = AVATAR_SAVE_RETRY_DELAYS_MS[retryAttempt];
if (delayMs === undefined) {
stop();
return;
}
retryAttempt += 1;
isSaving = false;
const scheduleRetry =
dependencies.scheduleRetry ??
((callback, delay) => {
const timeout = window.setTimeout(callback, delay);
return () => window.clearTimeout(timeout);
});
cancelRetry = scheduleRetry(() => {
cancelRetry = null;
saveIfReady();
}, delayMs);
});
};
unsubscribe = dependencies.subscribe(saveIfReady);
pendingSyncs.set(syncKey, stop);
saveIfReady();
};
const registerWhenReady = (
input: DeferredAvatarSave,
): AvatarSaveRegistration => {
const registrationKey = `registration:${input.relayUrl}:${input.avatarUrl}`;
if (pendingSyncs.has(registrationKey)) {
return { cancel: () => {}, release: () => {} };
}
let observedReady = false;
let active = true;
const queuedGeneration = generation;
const observe = () => {
if (generation !== queuedGeneration) return;
if (dependencies.getPresentation(input.avatarUrl)?.state === "ready") {
observedReady = true;
}
};
const unsubscribe = dependencies.subscribe(observe);
const cancel = () => {
if (!active) return;
active = false;
unsubscribe();
pendingSyncs.delete(registrationKey);
};
pendingSyncs.set(registrationKey, cancel);
observe();
return {
cancel,
release: (completion) => {
if (!active || generation !== queuedGeneration) return;
cancel();
queueSave({ ...input, ...completion }, observedReady);
},
};
};
return { registerWhenReady, reset, saveWhenReady: queueSave };
}
type ProfileCacheRefresh = {
profile: Profile;
input: PendingAvatarSave;
};
type ProfileCacheRefreshQueue = {
enqueue: (refresh: ProfileCacheRefresh) => Promise<void>;
reset: () => void;
setClient: (client: QueryClient) => () => void;
};
export function createProfileCacheRefreshQueue(
refresh: (
client: QueryClient,
profile: Profile,
relayUrl: string,
) => Promise<void>,
): ProfileCacheRefreshQueue {
let client: QueryClient | null = null;
const pending = new Map<string, ProfileCacheRefresh>();
const refreshKey = ({ profile, input }: ProfileCacheRefresh) =>
`${input.relayUrl}:${profile.pubkey.toLowerCase()}`;
const flush = (nextClient: QueryClient) => {
const queued = [...pending.values()];
pending.clear();
for (const item of queued) {
void refresh(nextClient, item.profile, item.input.relayUrl);
}
};
return {
enqueue: async (item) => {
if (client) {
await refresh(client, item.profile, item.input.relayUrl);
return;
}
pending.set(refreshKey(item), item);
},
reset: () => pending.clear(),
setClient: (nextClient) => {
client = nextClient;
flush(nextClient);
return () => {
if (client === nextClient) client = null;
};
},
};
}
const profileCacheRefreshQueue =
createProfileCacheRefreshQueue(refreshProfileCaches);
const avatarProfileSync = createAvatarProfileSync({
getPresentation: getAvatarPresentation,
subscribe: subscribeAvatarPresentations,
saveProfile: updateProfileAtRelay,
getActivePubkey: async () => {
try {
return (await getIdentity()).pubkey;
} catch {
return null;
}
},
refreshCaches: async (profile, input) => {
await profileCacheRefreshQueue.enqueue({ profile, input });
},
});
export function setAvatarProfileSyncQueryClient(
client: QueryClient,
): () => void {
return profileCacheRefreshQueue.setClient(client);
}
export function registerAvatarWhenReady(
input: DeferredAvatarSave,
): AvatarSaveRegistration {
return avatarProfileSync.registerWhenReady(input);
}
export function saveAvatarWhenReady(input: PendingAvatarSave): void {
avatarProfileSync.saveWhenReady(input);
}
export function resetAvatarProfileSync(): void {
profileCacheRefreshQueue.reset();
avatarProfileSync.reset();
}
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import test from "node:test";
import { QueryClient } from "@tanstack/react-query";
import { readSelfProfileCache } from "./lib/selfProfileStorage.ts";
import { refreshProfileCaches } from "./profileCacheSync.ts";
function installBrowserStubs() {
const values = new Map();
globalThis.window = {
dispatchEvent() {},
localStorage: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
removeItem: (key) => values.delete(key),
key: (index) => [...values.keys()][index] ?? null,
get length() {
return values.size;
},
},
};
globalThis.CustomEvent = class CustomEvent {};
globalThis.fetch = async () => ({ ok: false });
}
const PUBKEY = "abcdef";
const RELAY_URL = "wss://relay.example";
const PROFILE = {
pubkey: PUBKEY,
displayName: "Alice",
avatarUrl: "https://cdn.example/avatar.png",
about: "About Alice",
nip05Handle: null,
ownerPubkey: null,
hasProfileEvent: true,
};
test("successful deferred save synchronizes every profile cache", async () => {
installBrowserStubs();
const queryClient = new QueryClient();
queryClient.setQueryData(["profile"], { ...PROFILE, avatarUrl: null });
queryClient.setQueryData(["user-profile", PUBKEY], {
...PROFILE,
avatarUrl: null,
});
queryClient.setQueryData(["users-batch-entry", PUBKEY], {
summary: { displayName: "Alice", avatarUrl: null },
fetchedAt: Date.now(),
});
queryClient.setQueryData(["users-batch", PUBKEY], {
profiles: {
[PUBKEY]: {
displayName: "Alice",
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
},
},
missing: [],
});
queryClient.setQueryData(
["user-search", "alice", 8],
[{ pubkey: PUBKEY, displayName: "Alice", avatarUrl: null }],
);
await refreshProfileCaches(queryClient, PROFILE, RELAY_URL);
assert.deepEqual(queryClient.getQueryData(["profile"]), PROFILE);
assert.deepEqual(queryClient.getQueryData(["user-profile", PUBKEY]), PROFILE);
assert.equal(
queryClient.getQueryData(["users-batch", PUBKEY]).profiles[PUBKEY]
.avatarUrl,
PROFILE.avatarUrl,
);
assert.equal(
queryClient.getQueryData(["users-batch-entry", PUBKEY]),
undefined,
);
assert.equal(
queryClient.getQueryState(["user-search", "alice", 8]).isInvalidated,
true,
);
const persisted = readSelfProfileCache(RELAY_URL, PUBKEY);
assert.equal(persisted.displayName, PROFILE.displayName);
assert.equal(persisted.avatarUrl, PROFILE.avatarUrl);
assert.equal(persisted.about, PROFILE.about);
assert.equal(persisted.avatarDataUrl, null);
assert.equal(persisted.hasProfileEvent, true);
assert.ok(persisted.updatedAt > 0);
});
@@ -0,0 +1,91 @@
import type { Query, QueryClient } from "@tanstack/react-query";
import {
evictUsersBatchEntries,
profileQueryKey,
} from "@/features/profile/hooks";
import {
fetchAvatarDataUrl,
readSelfProfileCache,
resolveAvatarDataUrl,
writeSelfProfileCache,
} from "@/features/profile/lib/selfProfileStorage";
import type {
Profile,
UserProfileSummary,
UsersBatchResponse,
} from "@/shared/api/types";
import { getAvatarSnapshotUrl } from "@/shared/lib/animatedAvatar";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
function queryContainsPubkey(query: Query, pubkey: string): boolean {
return query.queryKey.includes(pubkey);
}
export async function refreshProfileCaches(
queryClient: QueryClient,
profile: Profile,
relayUrl: string,
): Promise<void> {
const pubkey = profile.pubkey.toLowerCase();
await queryClient.cancelQueries({
predicate: (query) =>
query.queryKey[0] === profileQueryKey[0] ||
(query.queryKey[0] === "user-profile" &&
queryContainsPubkey(query, pubkey)) ||
(query.queryKey[0] === "users-batch" &&
queryContainsPubkey(query, pubkey)),
});
queryClient.setQueryData(profileQueryKey, profile);
queryClient.setQueryData<Profile>(["user-profile", pubkey], profile);
evictUsersBatchEntries(queryClient, [pubkey]);
queryClient.setQueriesData<UsersBatchResponse>(
{
predicate: (query) =>
query.queryKey[0] === "users-batch" &&
queryContainsPubkey(query, pubkey),
},
(current) => {
if (!current?.profiles[pubkey]) return current;
return {
...current,
profiles: {
...current.profiles,
[pubkey]: {
...current.profiles[pubkey],
avatarUrl: profile.avatarUrl,
} satisfies UserProfileSummary,
},
};
},
);
// Search result pages also embed profile avatars, but their arbitrary query
// text/page shape makes a safe targeted rewrite brittle. Mark every search
// view stale; active searches refetch immediately and inactive ones refresh
// when next opened.
await queryClient.invalidateQueries({ queryKey: ["user-search"] });
const existing = readSelfProfileCache(relayUrl, profile.pubkey);
const baseCache = {
version: 1 as const,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
about: profile.about,
avatarDataUrl: resolveAvatarDataUrl(profile.avatarUrl, null, existing),
updatedAt: Date.now(),
...(profile.hasProfileEvent && { hasProfileEvent: true as const }),
};
// Persist the canonical profile before attempting the optional image snapshot,
// so quitting during that fetch cannot leave the durable fallback stale.
writeSelfProfileCache(relayUrl, profile.pubkey, baseCache);
const snapshotUrl = getAvatarSnapshotUrl(profile.avatarUrl);
if (!snapshotUrl) return;
const fetched = await fetchAvatarDataUrl(rewriteRelayUrl(snapshotUrl));
if (!fetched) return;
writeSelfProfileCache(relayUrl, profile.pubkey, {
...baseCache,
avatarDataUrl: fetched,
});
}
@@ -9,6 +9,7 @@ import { AnimatedAvatarCapture } from "@/features/profile/ui/AnimatedAvatarCaptu
import { AvatarCustomColorPanel } from "@/features/profile/ui/AvatarCustomColorPanel";
import { ProfileAvatarUploadPreview } from "@/features/profile/ui/ProfileAvatarUploadPreview";
import { ProfileAvatarModeTabs } from "@/features/profile/ui/ProfileAvatarModeTabs";
import { useAvatarSelection } from "@/features/profile/avatarPresentationStore";
import { useAvatarUpload } from "@/features/profile/useAvatarUpload";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
@@ -161,14 +162,15 @@ export function ProfileAvatarEditor({
},
[mode, onModeChange],
);
const setAvatar = useAvatarSelection(avatarUrl, onUrlChange);
const handleUploadSuccess = React.useCallback(
(uploadedUrl: string) => {
setUrlDraft("");
onUploadedAvatarChange?.(uploadedUrl);
onUrlChange(uploadedUrl);
setAvatar(uploadedUrl);
updateMode("image");
},
[onUploadedAvatarChange, onUrlChange, updateMode],
[onUploadedAvatarChange, setAvatar, updateMode],
);
const [isAnimatedApplyPending, setIsAnimatedApplyPending] =
React.useState(false);
@@ -192,14 +194,14 @@ export function ProfileAvatarEditor({
clearUploadError();
setUrlDraft("");
onUploadedAvatarChange?.(animatedUrl);
onUrlChange(animatedUrl);
setAvatar(animatedUrl);
onAnimatedAvatarApply?.(animatedUrl);
},
[
clearUploadError,
onAnimatedAvatarApply,
onUploadedAvatarChange,
onUrlChange,
setAvatar,
],
);
// Done on the animated tab uploads the pending recording first, then
@@ -347,14 +349,14 @@ export function ProfileAvatarEditor({
}
onUploadedAvatarChange?.(null);
onUrlChange(nextAvatarUrl);
setAvatar(nextAvatarUrl);
}, [
avatarUrl,
customColorDraft,
isCustomColorPickerOpen,
onUploadedAvatarChange,
onUrlChange,
selectedEmoji,
setAvatar,
]);
const handleFiles = React.useCallback(
@@ -379,14 +381,14 @@ export function ProfileAvatarEditor({
clearUploadError();
onUploadedAvatarChange?.(null);
onUrlChange(nextUrl);
setAvatar(nextUrl);
hasUserEditedUrlDraftRef.current = false;
updateMode("image");
}, [
clearUploadError,
isInputDisabled,
onUploadedAvatarChange,
onUrlChange,
setAvatar,
updateMode,
urlDraft,
]);
@@ -396,10 +398,10 @@ export function ProfileAvatarEditor({
setUrlDraft("");
hasUserEditedUrlDraftRef.current = false;
onUploadedAvatarChange?.(null);
onUrlChange(emojiAvatarDataUrl(emoji, color));
setAvatar(emojiAvatarDataUrl(emoji, color));
onEmojiAvatarChange?.();
},
[onEmojiAvatarChange, onUploadedAvatarChange, onUrlChange, selectedColor],
[onEmojiAvatarChange, onUploadedAvatarChange, selectedColor, setAvatar],
);
const openCustomColorPicker = React.useCallback(() => {
@@ -699,7 +701,7 @@ export function ProfileAvatarEditor({
hasUserEditedUrlDraftRef.current = true;
setUrlDraft(event.target.value);
onUploadedAvatarChange?.(null);
onUrlChange(event.target.value);
setAvatar(event.target.value);
}}
onFocus={() => {
isUrlInputFocusedRef.current = true;
@@ -1,4 +1,3 @@
import { ClockFading, Hash, type LucideIcon } from "lucide-react";
import * as React from "react";
import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks";
@@ -40,8 +39,8 @@ export type CreateChannelFormState = {
setVisibility: (value: ChannelVisibility) => void;
ephemeral: boolean;
setEphemeral: (value: boolean) => void;
durationLabel: string;
DurationIcon: LucideIcon;
ttlSeconds: number;
setTtlSeconds: (value: number) => void;
typePopoverOpen: boolean;
setTypePopoverOpen: (open: boolean) => void;
errorMessage: string | null;
@@ -72,6 +71,9 @@ export function useCreateChannelForm({
const [description, setDescription] = React.useState("");
const [visibility, setVisibility] = React.useState<ChannelVisibility>("open");
const [ephemeral, setEphemeral] = React.useState(false);
const [ttlSeconds, setTtlSeconds] = React.useState(
DEFAULT_EPHEMERAL_TTL_SECONDS,
);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
string | null
@@ -84,9 +86,6 @@ export function useCreateChannelForm({
const templates = templatesQuery.data ?? [];
const kindLabel = channelKind === "forum" ? "forum" : "channel";
const durationLabel = ephemeral ? "Temporary" : "Ongoing";
const DurationIcon = ephemeral ? ClockFading : Hash;
React.useEffect(() => {
if (!active) return;
@@ -94,6 +93,7 @@ export function useCreateChannelForm({
setDescription("");
setVisibility("open");
setEphemeral(false);
setTtlSeconds(DEFAULT_EPHEMERAL_TTL_SECONDS);
setErrorMessage(null);
setSelectedTemplateId(null);
setTypePopoverOpen(false);
@@ -158,7 +158,7 @@ export function useCreateChannelForm({
name: trimmedName,
description: description.trim() || undefined,
visibility,
ttlSeconds: ephemeral ? DEFAULT_EPHEMERAL_TTL_SECONDS : undefined,
ttlSeconds: ephemeral ? ttlSeconds : undefined,
templateId: selectedTemplateId ?? undefined,
});
onCreated?.();
@@ -179,6 +179,7 @@ export function useCreateChannelForm({
onCreate,
onCreated,
selectedTemplateId,
ttlSeconds,
visibility,
],
);
@@ -203,8 +204,8 @@ export function useCreateChannelForm({
},
ephemeral,
setEphemeral,
durationLabel,
DurationIcon,
ttlSeconds,
setTtlSeconds,
typePopoverOpen,
setTypePopoverOpen,
errorMessage,
@@ -1,26 +1,24 @@
import { ChevronDown, ClockFading, Hash } from "lucide-react";
import type { ChannelTemplate } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
import {
CHANNEL_FORM_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_SHELL_CLASS,
} from "@/features/channels/ui/channelFormStyles";
import { ChannelPermissionsSettings } from "@/features/channels/ui/ChannelPermissionsSettings";
import { ChannelTypeSettings } from "@/features/channels/ui/ChannelTypeSettings";
import type { CreateChannelFormState } from "@/features/sidebar/lib/useCreateChannelForm";
const CREATE_FIELD_SHELL_CLASS =
"rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50";
const CREATE_FIELD_CONTROL_CLASS =
"border-0 bg-transparent text-muted-foreground/55 shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0";
const CREATE_LABEL_OPTIONAL_CLASS =
"ml-1 text-xs font-normal text-muted-foreground/50";
export const CREATE_CHANNEL_FORM_ID = "create-channel-form";
/**
* The body of the create-channel form (name, description, private toggle,
* The body of the create-channel form (name, description, visibility,
* optional template). Rendered inside both the standalone dialog and the
* "Add channel" browser's create mode. Wrap in a `<form>` with
* `id={CREATE_CHANNEL_FORM_ID}` and hook up `form.handleSubmit`.
@@ -44,7 +42,7 @@ export function CreateChannelFormFields({
<div
className={cn(
"flex min-h-11 items-center px-3",
CREATE_FIELD_SHELL_CLASS,
CHANNEL_FORM_FIELD_SHELL_CLASS,
)}
>
<Input
@@ -53,7 +51,7 @@ export function CreateChannelFormFields({
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
CREATE_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-name"
disabled={isCreating}
@@ -77,11 +75,11 @@ export function CreateChannelFormFields({
Description
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<div className={CREATE_FIELD_SHELL_CLASS}>
<div className={CHANNEL_FORM_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-20 resize-none px-3 py-3 leading-5",
CREATE_FIELD_CONTROL_CLASS,
CHANNEL_FORM_FIELD_CONTROL_CLASS,
)}
data-testid="create-channel-description"
disabled={isCreating}
@@ -94,39 +92,16 @@ export function CreateChannelFormFields({
</div>
</div>
<div
className={cn(
"flex min-h-12 items-center justify-between gap-4 rounded-xl py-1",
isCreating && "opacity-50",
)}
data-testid="create-channel-visibility"
>
<label
className="min-w-0 cursor-pointer space-y-0.5"
htmlFor="create-channel-private"
>
<span className="block text-sm font-medium text-foreground">
Private
</span>
<span
className="block text-xs leading-4 text-muted-foreground/65"
id="create-channel-private-description"
>
Only members can invite people to this {kindLabel}.
</span>
</label>
<Switch
aria-describedby="create-channel-private-description"
checked={form.visibility === "private"}
className="shrink-0 shadow-none [&>span]:shadow-none"
data-testid="create-channel-private-toggle"
disabled={isCreating}
id="create-channel-private"
onCheckedChange={(checked) =>
form.setVisibility(checked ? "private" : "open")
}
/>
</div>
<ChannelTypeSettings
disabled={isCreating}
onOpenChange={form.setTypePopoverOpen}
onTemporaryChange={form.setEphemeral}
onTtlSecondsChange={form.setTtlSeconds}
open={form.typePopoverOpen}
temporary={form.ephemeral}
testIdPrefix="create-channel"
ttlSeconds={form.ttlSeconds}
/>
{form.templates.length > 0 ? (
<div className="space-y-1.5">
@@ -155,6 +130,13 @@ export function CreateChannelFormFields({
</div>
) : null}
<ChannelPermissionsSettings
disabled={isCreating}
onVisibilityChange={form.setVisibility}
testIdPrefix="create-channel"
visibility={form.visibility}
/>
{form.errorMessage ? (
<p className="text-sm text-destructive">{form.errorMessage}</p>
) : null}
@@ -163,9 +145,8 @@ export function CreateChannelFormFields({
}
/**
* Footer for the create-channel form: the Ongoing/Temporary duration picker on
* the left and the submit button on the right. The submit button is bound to
* the form via `form={CREATE_CHANNEL_FORM_ID}`.
* Footer for the create-channel form. The submit button is bound to the form
* via `form={CREATE_CHANNEL_FORM_ID}`.
*/
export function CreateChannelFormFooter({
form,
@@ -174,58 +155,10 @@ export function CreateChannelFormFooter({
form: CreateChannelFormState;
submitLabel?: string;
}) {
const { DurationIcon, durationLabel, isCreating, kindLabel } = form;
const { isCreating, kindLabel } = form;
return (
<div className="flex w-full items-center justify-between gap-3">
<Popover
onOpenChange={form.setTypePopoverOpen}
open={form.typePopoverOpen}
>
<PopoverTrigger asChild>
<Button
aria-label={`Channel duration: ${durationLabel}`}
className="-ml-2.5 h-9 px-2.5 text-sm font-medium text-foreground hover:bg-muted/50"
disabled={isCreating}
type="button"
variant="ghost"
>
<DurationIcon className="h-4 w-4" />
{durationLabel}
<ChevronDown className="h-4 w-4 text-muted-foreground/70" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-1">
<div className="px-3 pb-1.5 pt-2 text-xs font-medium text-muted-foreground/70">
Channel type
</div>
<fieldset className="space-y-1">
<legend className="sr-only">Channel type</legend>
<ChannelDurationOption
ariaLabel="Ongoing channel"
checked={!form.ephemeral}
description="For projects, teams, and recurring conversations."
icon={Hash}
label="Ongoing"
onSelect={() => {
form.setEphemeral(false);
form.setTypePopoverOpen(false);
}}
/>
<ChannelDurationOption
ariaLabel="Ephemeral - auto-archives after 7 days of inactivity"
checked={form.ephemeral}
description="For quick discussions that archive automatically when inactive."
icon={ClockFading}
label="Temporary"
onSelect={() => {
form.setEphemeral(true);
form.setTypePopoverOpen(false);
}}
/>
</fieldset>
</PopoverContent>
</Popover>
<div className="flex w-full items-center justify-end gap-3">
<Button
data-testid="create-channel-submit"
disabled={!form.canSubmit}
@@ -237,65 +170,3 @@ export function CreateChannelFormFooter({
</div>
);
}
function ChannelDurationOption({
ariaLabel,
checked,
description,
icon: Icon,
label,
onSelect,
}: {
ariaLabel: string;
checked: boolean;
description: string;
icon: typeof Hash;
label: string;
onSelect: () => void;
}) {
return (
<label
className={cn(
"relative flex min-h-16 cursor-pointer items-start gap-3 rounded-lg px-3 py-2.5 text-left text-muted-foreground/75 transition-colors duration-150 ease-out hover:bg-muted/50 hover:text-foreground has-[:focus-visible]:outline-hidden has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-ring",
checked && "text-foreground",
)}
>
<input
aria-label={ariaLabel}
checked={checked}
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
name="create-channel-duration"
onChange={onSelect}
type="radio"
/>
<span
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-muted-foreground/40",
checked && "border-foreground",
)}
aria-hidden="true"
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full bg-foreground transition-opacity duration-150",
checked ? "opacity-100" : "opacity-0",
)}
/>
</span>
<span className="grid min-w-0 flex-1 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 gap-y-1">
<Icon className="h-4 w-4 shrink-0 text-current" />
<span className="block text-sm font-medium leading-4 text-current">
{label}
</span>
<span
className={cn(
"col-span-2 block text-xs leading-4 text-muted-foreground/70",
checked && "text-muted-foreground/65",
)}
>
{description}
</span>
</span>
</label>
);
}
+13
View File
@@ -83,6 +83,19 @@ export async function updateProfile(
return fromRawProfile(profile);
}
export async function updateProfileAtRelay(input: {
relayUrl: string;
expectedPubkey: string;
expectedAvatarUrl: string | null;
avatarUrl: string;
}): Promise<Profile> {
const profile = await invokeTauri<RawProfile>(
"update_profile_at_relay",
input,
);
return fromRawProfile(profile);
}
export async function getUserProfile(pubkey?: string): Promise<Profile> {
const profile = await invokeTauri<RawProfile>("get_user_profile", { pubkey });
return fromRawProfile(profile);
+7
View File
@@ -9324,6 +9324,13 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof handleUpdateProfile>[0],
activeConfig,
);
case "update_profile_at_relay":
return handleUpdateProfile(
{
avatarUrl: (payload as { avatarUrl: string }).avatarUrl,
},
activeConfig,
);
case "get_user_profile":
return handleGetUserProfile(
(payload as Parameters<typeof handleGetUserProfile>[0]) ?? {},
+228 -53
View File
@@ -15,7 +15,9 @@ async function openManagementSheet(page: import("@playwright/test").Page) {
async function openEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toBeVisible();
}
@@ -25,80 +27,155 @@ async function settle(page: import("@playwright/test").Page) {
);
}
async function selectTemporaryChannelType(
page: import("@playwright/test").Page,
) {
await page.getByTestId("channel-management-channel-type").click();
await page.getByLabel("Temporary channel").click();
}
test.describe("channel controls", () => {
test("01 — lifecycle section: Private + Ephemeral switches", async ({
test("01 — lifecycle section: visibility + channel type", async ({
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(page.getByText(/Update settings for/)).toHaveCount(0);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await expect(
page.getByTestId("channel-management-private-toggle"),
page.getByTestId("channel-management-permissions-container"),
).toBeVisible();
await expect(
page.getByTestId("channel-management-ephemeral-toggle"),
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
await expect(
page.getByTestId("channel-management-channel-type"),
).toBeVisible();
await expect(
page.getByTestId("channel-management-channel-type"),
).toContainText("Ongoing");
await expect(
page.getByTestId("channel-management-ephemeral-settings"),
).toHaveCount(0);
await expect(
page
.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
})
.getByTestId("channel-management-topic"),
).toHaveCount(0);
await expect(
page
.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
})
.getByTestId("channel-management-purpose"),
).toHaveCount(0);
await settle(page);
});
test("02 — Private toggled on", async ({ page }) => {
await installMockBridge(page);
test("02 — visibility updates immediately", async ({ page }) => {
await installMockBridge(page, { updateChannelDelayMs: 500 });
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await page.getByTestId("channel-management-private-toggle").click();
const permissions = page.getByTestId("channel-management-permissions");
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(permissions).toHaveAttribute("aria-busy", "true");
await expect(permissions).toContainText("Updating…");
await expect(
page.getByTestId("channel-management-private-toggle"),
).toBeChecked();
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await expect(permissions).toHaveAccessibleName("Visibility: Private");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await settle(page);
});
).toBeDisabled();
test("03 — Ephemeral on with friendly timeout field", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await page.getByTestId("channel-management-ephemeral-toggle").click();
const ttl = page.getByTestId("channel-management-ttl");
await expect(ttl).toBeVisible();
await ttl.fill("1d12h");
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-open")
.click();
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await settle(page);
});
test("04 — invalid timeout blocks save with inline error", async ({
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await page.getByTestId("channel-management-ephemeral-toggle").click();
const ttl = page.getByTestId("channel-management-ttl");
await ttl.fill("soon");
await expect(ttl).toHaveAttribute("aria-invalid", "true");
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(permissions).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
await settle(page);
});
test("03 — Temporary type reveals expiration presets", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await selectTemporaryChannelType(page);
const ephemeralSettings = page.getByTestId(
"channel-management-ephemeral-settings",
);
await expect(ephemeralSettings).toBeVisible();
const ttl = ephemeralSettings.getByTestId("channel-management-ttl");
await expect(ttl).toBeVisible();
await expect(ttl).toHaveAttribute("aria-label", "Expires after");
await expect(ttl).toContainText("7 days");
await ttl.click();
await expect(
page.getByTestId("channel-management-ttl-option-1800"),
).toHaveText("30 minutes");
await expect(
page.getByTestId("channel-management-ttl-option-2592000"),
).toHaveText("30 days");
await page.getByTestId("channel-management-ttl-option-86400").click();
await expect(ttl).toContainText("1 day");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await page.getByTestId("channel-management-channel-type").click();
await page.getByLabel("Ongoing channel").click();
await expect(ephemeralSettings).toHaveCount(0);
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
});
test("04 — changing the timeout preset keeps save enabled", async ({
page,
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await selectTemporaryChannelType(page);
const ttl = page.getByTestId("channel-management-ttl");
await ttl.click();
await page.getByTestId("channel-management-ttl-option-21600").click();
await expect(ttl).toContainText("6 hours");
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
});
test("05 — sticky footer pins lifecycle buttons", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
@@ -123,7 +200,7 @@ test.describe("channel controls", () => {
await openManagementSheet(page);
await openEditDialog(page);
await page.getByTestId("channel-management-ephemeral-toggle").click();
await selectTemporaryChannelType(page);
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
@@ -134,7 +211,9 @@ test.describe("channel controls", () => {
).toHaveText("Saving...");
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
});
@@ -145,11 +224,19 @@ test.describe("channel controls", () => {
await openManagementSheet(page);
await openEditDialog(page);
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
await page.getByTestId("channel-management-permissions").click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await selectTemporaryChannelType(page);
await page.getByTestId("channel-management-save-changes").click();
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
await page.getByTestId("auxiliary-panel-close").click();
@@ -158,16 +245,104 @@ test.describe("channel controls", () => {
).not.toBeVisible();
await page.getByTestId("channel-management-trigger").click();
await openEditDialog(page);
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
await expect(
page.getByTestId("channel-management-private-toggle"),
).toHaveAttribute("data-state", "checked");
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Private");
await expect(
page.getByTestId("channel-management-ephemeral-toggle"),
).toHaveAttribute("data-state", "checked");
await expect(page.getByTestId("channel-management-ttl")).toHaveValue("7d");
page.getByTestId("channel-management-channel-type"),
).toContainText("Temporary");
await expect(page.getByTestId("channel-management-ttl")).toContainText(
"7 days",
);
await settle(page);
});
test("09 — cancel discards unsaved channel drafts", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
await page.getByTestId("channel-management-name").fill("discarded-name");
await page
.getByRole("textbox", { name: "Description" })
.fill("This description should be discarded");
await selectTemporaryChannelType(page);
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
await openEditDialog(page);
await expect(page.getByTestId("channel-management-name")).toHaveValue(
"general",
);
await expect(
page.getByRole("textbox", { name: "Description" }),
).toHaveValue("General discussion for everyone");
await expect(
page.getByTestId("channel-management-channel-type"),
).toContainText("Ongoing");
await expect(
page.getByTestId("channel-management-ephemeral-settings"),
).toHaveCount(0);
await expect(
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
});
test("10 — stale visibility updates do not affect a new channel", async ({
page,
}) => {
await installMockBridge(page, { updateChannelDelayMs: 1_500 });
await openManagementSheet(page);
await openEditDialog(page);
const permissions = page.getByTestId("channel-management-permissions");
await permissions.click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(permissions).toHaveAttribute("aria-busy", "true");
const agentsChannelId = await page
.getByTestId("channel-agents")
.getAttribute("data-channel-id");
if (!agentsChannelId) {
throw new Error("Expected the agents channel id.");
}
await page.evaluate((channelId) => {
const hash = window.location.hash.replace(/^#/, "") || "/";
const [, query = ""] = hash.split("?");
const nextHash = `#/channels/${channelId}${query ? `?${query}` : ""}`;
window.history.pushState(
{},
"",
`${window.location.pathname}${nextHash}`,
);
window.dispatchEvent(new HashChangeEvent("hashchange"));
window.dispatchEvent(new PopStateEvent("popstate"));
}, agentsChannelId);
await expect(page.getByTestId("chat-title")).toHaveText("agents");
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(permissions).toHaveAttribute("aria-busy", "false");
await expect(permissions).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
});
});
+93 -46
View File
@@ -144,7 +144,9 @@ async function closeChannelManagement(page: import("@playwright/test").Page) {
async function openChannelEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toBeVisible();
}
@@ -1303,10 +1305,37 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
await page
.getByTestId("create-channel-description")
.fill("Auto-cleaned test stream");
await page.getByRole("button", { name: "Channel duration: Ongoing" }).click();
await page
.getByLabel("Ephemeral - auto-archives after 7 days of inactivity")
.click();
await page.getByTestId("create-channel-channel-type").click();
await page.getByLabel("Temporary channel").click();
const channelTypeContainer = page.getByTestId(
"create-channel-channel-type-container",
);
await expect(
channelTypeContainer.getByTestId("create-channel-permissions-container"),
).toHaveCount(0);
await expect(
page.getByTestId("create-channel-permissions-container"),
).toBeVisible();
await expect(page.getByTestId("create-channel-permissions")).toContainText(
"Public",
);
await page.getByTestId("create-channel-permissions").click();
await page.getByTestId("create-channel-permissions-option-private").click();
await expect(page.getByTestId("create-channel-permissions")).toContainText(
"Private",
);
await expect(
page.getByTestId("create-channel-permissions-option-private"),
).toHaveCount(0);
await page.getByTestId("create-channel-permissions").click();
await expect(
page.getByTestId("create-channel-permissions-option-open"),
).toBeVisible();
await page.getByTestId("create-channel-permissions-option-open").click();
await expect(page.getByTestId("create-channel-ttl")).toContainText("7 days");
await page.getByTestId("create-channel-ttl").click();
await page.getByTestId("create-channel-ttl-option-1209600").click();
await expect(page.getByTestId("create-channel-ttl")).toContainText("14 days");
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("stream-list")).toContainText(channelName);
@@ -1317,7 +1346,7 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
await expect(page.getByTestId("chat-ephemeral-badge")).toBeVisible();
await expect(page.getByTestId("chat-ephemeral-badge")).toHaveAttribute(
"aria-label",
/Ephemeral channel\. Cleans up in 7 days\./,
/Ephemeral channel\. Cleans up in 14 days\./,
);
await page
@@ -1345,12 +1374,8 @@ test("ephemeral countdown refreshes when switching channels after a clock jump",
await page
.getByTestId("create-channel-description")
.fill("Auto-cleaned test stream");
await page
.getByRole("button", { name: "Channel duration: Ongoing" })
.click();
await page
.getByLabel("Ephemeral - auto-archives after 7 days of inactivity")
.click();
await page.getByTestId("create-channel-channel-type").click();
await page.getByLabel("Temporary channel").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toContainText(channelName);
}
@@ -2202,24 +2227,28 @@ test("sidebar persists after channel switch", async ({ page }) => {
await expect(page.getByTestId("app-sidebar")).toBeVisible();
});
test("manage channel updates details and context", async ({ page }) => {
test("manage channel updates details", async ({ page }) => {
const stamp = Date.now();
const newName = `release-hub-${stamp}`;
const newDescription = `Release coordination ${stamp}`;
const newTopic = `Launch plan ${stamp}`;
const newPurpose = `Track blockers and owners ${stamp}`;
await page.goto("/");
await openChannelManagement(page, "general");
await openChannelEditDialog(page);
const editDialog = page.getByRole("dialog", { name: "Edit channel" });
const editDialog = page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
});
await editDialog.getByTestId("channel-management-name").fill(newName);
await editDialog
.getByTestId("channel-management-description")
.fill(newDescription);
await editDialog.getByTestId("channel-management-topic").fill(newTopic);
await editDialog.getByTestId("channel-management-purpose").fill(newPurpose);
await expect(editDialog.getByTestId("channel-management-topic")).toHaveCount(
0,
);
await expect(
editDialog.getByTestId("channel-management-purpose"),
).toHaveCount(0);
await editDialog.getByTestId("channel-management-save-changes").click();
await expect(editDialog).toHaveCount(0);
@@ -2231,12 +2260,6 @@ test("manage channel updates details and context", async ({ page }) => {
await expect(
page.getByTestId("channel-management-description"),
).toContainText(newDescription);
await expect(page.getByTestId("channel-management-topic")).toContainText(
newTopic,
);
await expect(page.getByTestId("channel-management-purpose")).toContainText(
newPurpose,
);
await closeChannelManagement(page);
@@ -2249,7 +2272,7 @@ test("manage channel updates details and context", async ({ page }) => {
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await openChannelEditDialog(page);
const reopenedEditDialog = page.getByRole("dialog", {
name: "Edit channel",
name: /Edit (?:public|private) channel/,
});
await expect(
@@ -2260,10 +2283,10 @@ test("manage channel updates details and context", async ({ page }) => {
).toHaveValue(newDescription);
await expect(
reopenedEditDialog.getByTestId("channel-management-topic"),
).toHaveValue(newTopic);
).toHaveCount(0);
await expect(
reopenedEditDialog.getByTestId("channel-management-purpose"),
).toHaveValue(newPurpose);
).toHaveCount(0);
});
test("manage channel updates visibility and ephemeral lifecycle independently", async ({
@@ -2277,8 +2300,18 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
await expect(saveChangesButton).toBeDisabled();
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
await page.getByTestId("channel-management-permissions").click();
await page
.getByTestId("channel-management-permissions-option-private")
.click();
await expect(
page.getByRole("dialog", { name: "Edit private channel" }),
).toBeVisible();
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Private");
await page.getByTestId("channel-management-channel-type").click();
await page.getByLabel("Temporary channel").click();
await expect(page.getByTestId("channel-management-ttl")).toBeVisible();
await expect(saveChangesButton).toBeEnabled();
@@ -2296,9 +2329,11 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
}),
}),
);
await expect(page.getByRole("dialog", { name: "Edit channel" })).toHaveCount(
0,
);
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
const channelAfterEnable = await invokeMockCommand<{
ttl_seconds: number | null;
@@ -2317,15 +2352,25 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
saveChangesButton = page.getByTestId("channel-management-save-changes");
await expect(
page.getByTestId("channel-management-private-toggle"),
).toHaveAttribute("data-state", "checked");
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Private");
await expect(
page.getByTestId("channel-management-ephemeral-toggle"),
).toHaveAttribute("data-state", "checked");
await expect(page.getByTestId("channel-management-ttl")).toHaveValue("7d");
page.getByTestId("channel-management-channel-type"),
).toContainText("Temporary");
await expect(page.getByTestId("channel-management-ttl")).toContainText(
"7 days",
);
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
await page.getByTestId("channel-management-permissions").click();
await page.getByTestId("channel-management-permissions-option-open").click();
await expect(
page.getByRole("dialog", { name: "Edit public channel" }),
).toBeVisible();
await expect(
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Public");
await page.getByTestId("channel-management-channel-type").click();
await page.getByLabel("Ongoing channel").click();
await expect(saveChangesButton).toBeEnabled();
const commandCountBeforeDisable = (await readCommandPayloadLog(page)).length;
@@ -2342,9 +2387,11 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
}),
}),
);
await expect(page.getByRole("dialog", { name: "Edit channel" })).toHaveCount(
0,
);
await expect(
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toHaveCount(0);
const channelAfterDisable = await invokeMockCommand<{
ttl_seconds: number | null;
@@ -2362,11 +2409,11 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
await openChannelEditDialog(page);
await expect(
page.getByTestId("channel-management-private-toggle"),
).toHaveAttribute("data-state", "unchecked");
page.getByTestId("channel-management-permissions"),
).toHaveAccessibleName("Visibility: Public");
await expect(
page.getByTestId("channel-management-ephemeral-toggle"),
).toHaveAttribute("data-state", "unchecked");
page.getByTestId("channel-management-channel-type"),
).toContainText("Ongoing");
await expect(page.getByTestId("channel-management-ttl")).toHaveCount(0);
});
+17 -14
View File
@@ -36,7 +36,9 @@ async function openChannelManagement(page: import("@playwright/test").Page) {
async function openChannelEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
}),
).toBeVisible();
}
@@ -454,7 +456,7 @@ test("multiple channels independent", async ({ page }) => {
);
});
test("manage sheet updates channel details and context through the relay", async ({
test("manage sheet updates channel details through the relay", async ({
page,
}) => {
const stamp = Date.now();
@@ -462,8 +464,6 @@ test("manage sheet updates channel details and context through the relay", async
const renamedChannel = `manage-renamed-${stamp}`;
const initialDescription = `Initial description ${stamp}`;
const updatedDescription = `Updated description ${stamp}`;
const updatedTopic = `Updated topic ${stamp}`;
const updatedPurpose = `Updated purpose ${stamp}`;
await installRelayBridge(page, "tyler");
await page.goto("/");
@@ -471,16 +471,20 @@ test("manage sheet updates channel details and context through the relay", async
await openChannelManagement(page);
await openChannelEditDialog(page);
const editDialog = page.getByRole("dialog", { name: "Edit channel" });
const editDialog = page.getByRole("dialog", {
name: /Edit (?:public|private) channel/,
});
await editDialog.getByTestId("channel-management-name").fill(renamedChannel);
await editDialog
.getByTestId("channel-management-description")
.fill(updatedDescription);
await editDialog.getByTestId("channel-management-topic").fill(updatedTopic);
await editDialog
.getByTestId("channel-management-purpose")
.fill(updatedPurpose);
await expect(editDialog.getByTestId("channel-management-topic")).toHaveCount(
0,
);
await expect(
editDialog.getByTestId("channel-management-purpose"),
).toHaveCount(0);
await editDialog.getByTestId("channel-management-save-changes").click();
await expect(editDialog).toHaveCount(0);
@@ -492,16 +496,15 @@ test("manage sheet updates channel details and context through the relay", async
await page.getByTestId(`channel-${renamedChannel}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
// channelDescription deduplicates by showing only the first non-empty field
await expect(page.getByTestId("chat-title")).toHaveAttribute(
"title",
updatedTopic,
updatedDescription,
);
await openChannelManagement(page);
await openChannelEditDialog(page);
const reopenedEditDialog = page.getByRole("dialog", {
name: "Edit channel",
name: /Edit (?:public|private) channel/,
});
await expect(
@@ -512,10 +515,10 @@ test("manage sheet updates channel details and context through the relay", async
).toHaveValue(updatedDescription);
await expect(
reopenedEditDialog.getByTestId("channel-management-topic"),
).toHaveValue(updatedTopic);
).toHaveCount(0);
await expect(
reopenedEditDialog.getByTestId("channel-management-purpose"),
).toHaveValue(updatedPurpose);
).toHaveCount(0);
});
test("manage sheet archive and unarchive survives a reload through the relay", async ({
+404 -46
View File
@@ -58,6 +58,8 @@ async function setRelayConnectionState(
const HOME_SEEN_STORAGE_KEY_PREFIX = "buzz-home-feed-seen.v1:";
const COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY =
"buzz-community-onboarding-transaction.v1";
const ONE_PIXEL_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const BLANK_TYLER_IDENTITY = {
...TEST_IDENTITIES.tyler,
@@ -89,6 +91,48 @@ async function seedOnboardingCompletion(page: Page, pubkey: string) {
);
}
async function seedCommunityProfileStage(page: Page, id: string) {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript(
({ pubkey, transactionId, transactionStorageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
transactionStorageKey,
JSON.stringify({
id: transactionId,
source: "first-community",
stage: "profile",
relayUrl: "wss://default.example.com",
communityName: "Default",
communityId: "e2e-default-community",
addedCommunity: true,
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
transactionId: id,
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
},
);
}
async function uploadCommunityAvatar(page: Page, filename: string) {
await page.getByTestId("community-avatar-open").click();
await page.getByTestId("community-avatar-input").setInputFiles({
buffer: Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"),
mimeType: "image/png",
name: filename,
});
await page.getByTestId("community-avatar-done").click();
}
async function readHomeSeenStorageKeys(page: Page) {
return page.evaluate((prefix) => {
return Object.keys(window.localStorage).filter((key) =>
@@ -412,6 +456,23 @@ async function invokeMockCommand<T>(
);
}
async function seedCurrentAvatar(page: Page, avatarUrl: string) {
await page.waitForFunction(() => {
const bridgeWindow = window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: { invoke?: unknown };
};
return (
typeof bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
typeof bridgeWindow.__TAURI_INTERNALS__?.invoke === "function"
);
});
await invokeMockCommand(page, "update_profile", { avatarUrl });
await page.evaluate(() => {
window.__BUZZ_E2E_COMMAND_PAYLOADS__ = [];
});
}
async function getWelcomeChannelId(page: Page) {
const channels = await getMockChannels(page);
return (
@@ -1636,37 +1697,46 @@ test("connected first-community profile step offers equal-width Next and Back co
.toBeNull();
});
test("pending avatar stays navigable and exposes retry after propagation fails", async ({
test("name-only community profile save preserves an existing avatar", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript(
({ pubkey, transactionStorageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
transactionStorageKey,
JSON.stringify({
id: "txn-avatar-propagation",
source: "first-community",
stage: "profile",
relayUrl: "wss://default.example.com",
communityName: "Default",
communityId: "e2e-default-community",
addedCommunity: true,
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{
pubkey: BLANK_TYLER_IDENTITY.pubkey,
transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY,
},
await seedCommunityProfileStage(page, "txn-avatar-preserve-existing");
await installMockBridge(page, undefined, {
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
});
await page.goto("/");
const existingAvatarUrl =
"https://mock.relay/media/existing-community-avatar.png";
await seedCurrentAvatar(page, existingAvatarUrl);
await page.getByTestId("community-profile-name-key").fill("Tyler");
await page.getByTestId("community-profile-next").click();
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined]);
const profile = await invokeMockCommand<{ avatar_url: string | null }>(
page,
"get_profile",
);
expect(profile.avatar_url).toBe(existingAvatarUrl);
});
test("pending avatar stays navigable, clears failures, and retries", async ({
page,
}) => {
await seedCommunityProfileStage(page, "txn-avatar-propagation");
const uploadedAvatarUrl =
"https://mock.relay/media/pending-community-avatar.png";
@@ -1678,10 +1748,7 @@ test("pending avatar stays navigable and exposes retry after propagation fails",
}
await new Promise((resolve) => setTimeout(resolve, 500));
await route.fulfill({
body: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
body: Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"),
contentType: "image/png",
});
});
@@ -1708,16 +1775,7 @@ test("pending avatar stays navigable and exposes retry after propagation fails",
await page.goto("/");
await page.getByTestId("community-profile-name-key").fill("Tyler");
await page.getByTestId("community-avatar-open").click();
await page.getByTestId("community-avatar-input").setInputFiles({
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
),
mimeType: "image/png",
name: "pending-community-avatar.png",
});
await page.getByTestId("community-avatar-done").click();
await uploadCommunityAvatar(page, "pending-community-avatar.png");
const avatarImage = page.getByTestId("community-avatar-circle-image");
await expect(avatarImage).toHaveAttribute("src", /^blob:/);
@@ -1773,16 +1831,316 @@ test("pending avatar stays navigable and exposes retry after propagation fails",
page.getByText("Your default avatar is showing instead."),
).toBeVisible();
await page.getByTestId("community-profile-next").click();
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined]);
avatarReady = true;
await page.getByRole("button", { name: "Retry" }).click();
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined, uploadedAvatarUrl]);
await expect(page.getByText("Avatar couldn’t finish uploading")).toHaveCount(
0,
);
});
test("a pending avatar never becomes durable if propagation fails after onboarding unmounts", async ({
page,
}) => {
await seedCommunityProfileStage(page, "txn-avatar-saved-before-failure");
const uploadedAvatarUrl =
"https://mock.relay/media/saved-pending-community-avatar.png";
let allowAvatarFailure = false;
await page.route(`${uploadedAvatarUrl}*`, async (route) => {
while (!allowAvatarFailure) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await route.fulfill({ status: 404 });
});
await installMockBridge(
page,
{
uploadDescriptors: [
{
filename: "saved-pending-community-avatar.png",
sha256: "f".repeat(64),
size: 128,
type: "image/png",
uploaded: 1_779_900_002,
url: uploadedAvatarUrl,
},
],
},
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-profile-name-key").fill("Tyler");
await uploadCommunityAvatar(page, "saved-pending-community-avatar.png");
await expect(
page.getByTestId("community-avatar-circle-upload-pending"),
).toBeVisible();
await expect(avatarImage).toHaveAttribute("src", /^blob:/);
await expect(avatarImage).toHaveClass(/brightness-75/);
await page.getByTestId("community-profile-next").click();
await page.getByTestId("community-team-intro-enter").click();
await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0, {
timeout: 10_000,
});
allowAvatarFailure = true;
await expect
.poll(() => avatarImage.getAttribute("src"))
.not.toMatch(/^blob:/);
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined]);
await expect(
page.getByText("Avatar couldn’t finish uploading"),
).toBeVisible();
const profile = await invokeMockCommand<{ avatar_url: string | null }>(
page,
"get_profile",
);
expect(profile.avatar_url).toBeNull();
});
test("a pending avatar becomes durable after onboarding unmounts once ready", async ({
page,
}) => {
await seedCommunityProfileStage(page, "txn-avatar-ready-after-unmount");
const uploadedAvatarUrl =
"https://mock.relay/media/ready-after-unmount-community-avatar.png";
let allowAvatarReady = false;
await page.route(`${uploadedAvatarUrl}*`, async (route) => {
while (!allowAvatarReady) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await route.fulfill({
body: Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"),
contentType: "image/png",
});
});
await installMockBridge(
page,
{
uploadDescriptors: [
{
filename: "ready-after-unmount-community-avatar.png",
sha256: "b".repeat(64),
size: 128,
type: "image/png",
uploaded: 1_779_900_004,
url: uploadedAvatarUrl,
},
],
},
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-profile-name-key").fill("Tyler");
await uploadCommunityAvatar(page, "ready-after-unmount-community-avatar.png");
await page.getByTestId("community-profile-next").click();
await page.getByTestId("community-team-intro-enter").click();
await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0, {
timeout: 10_000,
});
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined]);
allowAvatarReady = true;
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined, uploadedAvatarUrl]);
const profile = await invokeMockCommand<{ avatar_url: string | null }>(
page,
"get_profile",
);
expect(profile.avatar_url).toBe(uploadedAvatarUrl);
});
test("a failed pending replacement leaves the confirmed avatar untouched", async ({
page,
}) => {
await seedCommunityProfileStage(page, "txn-avatar-restore-existing");
const existingAvatarUrl =
"https://mock.relay/media/existing-community-avatar.png";
const uploadedAvatarUrl =
"https://mock.relay/media/replacement-community-avatar.png";
await page.route(`${uploadedAvatarUrl}*`, (route) =>
route.fulfill({ status: 404 }),
);
await installMockBridge(
page,
{
uploadDescriptors: [
{
filename: "replacement-community-avatar.png",
sha256: "a".repeat(64),
size: 128,
type: "image/png",
uploaded: 1_779_900_003,
url: uploadedAvatarUrl,
},
],
},
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await seedCurrentAvatar(page, existingAvatarUrl);
await page.getByTestId("community-profile-name-key").fill("Tyler");
await uploadCommunityAvatar(page, "replacement-community-avatar.png");
await page.getByTestId("community-profile-next").click();
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])
.filter(
({ command }) =>
command === "update_profile" ||
command === "update_profile_at_relay",
)
.map(({ payload }) => (payload as { avatarUrl?: string }).avatarUrl),
),
)
.toEqual([undefined]);
const profile = await invokeMockCommand<{ avatar_url: string | null }>(
page,
"get_profile",
);
expect(profile.avatar_url).toBe(existingAvatarUrl);
});
test("replacing a pending upload disposes its verifier and local preview", async ({
page,
}) => {
await seedCommunityProfileStage(page, "txn-avatar-replacement");
await page.addInitScript(() => {
const testWindow = window as Window & {
__BUZZ_E2E_REVOKED_OBJECT_URLS__?: string[];
};
const revokedUrls: string[] = [];
const revokeObjectUrl = URL.revokeObjectURL.bind(URL);
testWindow.__BUZZ_E2E_REVOKED_OBJECT_URLS__ = revokedUrls;
URL.revokeObjectURL = (url) => {
revokedUrls.push(url);
revokeObjectUrl(url);
};
});
const uploadedAvatarUrl =
"https://mock.relay/media/superseded-community-avatar.png";
await page.route(`${uploadedAvatarUrl}*`, (route) =>
route.fulfill({ status: 404 }),
);
await installMockBridge(
page,
{
uploadDescriptors: [
{
filename: "superseded-community-avatar.png",
sha256: "e".repeat(64),
size: 128,
type: "image/png",
uploaded: 1_779_900_001,
url: uploadedAvatarUrl,
},
],
},
{
relayWsUrl: "wss://default.example.com",
skipOnboardingSeed: true,
},
);
await page.goto("/");
await page.getByTestId("community-profile-name-key").fill("Tyler");
await uploadCommunityAvatar(page, "superseded-community-avatar.png");
const supersededPreviewUrl = await page
.getByTestId("community-avatar-circle-image")
.getAttribute("src");
expect(supersededPreviewUrl).toMatch(/^blob:/);
await page.getByTestId("community-avatar-open").click();
await page.getByRole("tab", { name: "Emoji" }).click();
await selectFirstEmojiFromPicker(page);
await page.getByTestId("community-avatar-done").click();
await expect
.poll(() =>
page.evaluate(() => {
const testWindow = window as Window & {
__BUZZ_E2E_REVOKED_OBJECT_URLS__?: string[];
};
return testWindow.__BUZZ_E2E_REVOKED_OBJECT_URLS__ ?? [];
}),
)
.toContain(supersededPreviewUrl);
await page.waitForTimeout(6_000);
await expect(page.getByText("Avatar couldn’t finish uploading")).toHaveCount(
0,
);
await expect(page.getByRole("button", { name: "Retry" })).toHaveCount(0);
});
test("membership denial on community profile save offers recovery", async ({
@@ -69,7 +69,8 @@ test.describe("welcome and channel agent entry points", () => {
await page
.getByTestId("create-channel-description")
.fill("A private channel for getting oriented in this workspace.");
await page.getByTestId("create-channel-private-toggle").click();
await page.getByTestId("create-channel-permissions").click();
await page.getByTestId("create-channel-permissions-option-private").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText("Welcome");
await expect(
@@ -108,7 +109,8 @@ test.describe("welcome and channel agent entry points", () => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await openCreateChannelDialog(page);
await page.getByTestId("create-channel-name").fill("Welcome");
await page.getByTestId("create-channel-private-toggle").click();
await page.getByTestId("create-channel-permissions").click();
await page.getByTestId("create-channel-permissions-option-private").click();
await page.getByTestId("create-channel-submit").click();
await expect(page.getByTestId("chat-title")).toHaveText("Welcome");
await page.getByTestId("welcome-intro-action-create-agent").click();