Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 134 additions & 1 deletion desktop/src-tauri/src/commands/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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(
Comment thread
loganj marked this conversation as resolved.
&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>,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 3 additions & 50 deletions desktop/src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"))?;
Expand Down Expand Up @@ -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.
///
Expand Down
60 changes: 60 additions & 0 deletions desktop/src-tauri/src/relay/submit.rs
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 20 additions & 2 deletions desktop/src/features/communities/useCommunityInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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.
//
Expand Down
Loading
Loading