Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
83 changes: 82 additions & 1 deletion desktop/src-tauri/src/commands/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ use crate::{
events,
models::{ProfileInfo, SearchUsersResponse, UserNotesResponse, UsersBatchResponse},
nostr_convert,
relay::{query_relay, submit_event},
relay::{
query_relay, query_relay_at, relay_http_base_url, submit_event, submit_event_at_with_keys,
},
};

#[tauri::command]
Expand Down Expand Up @@ -94,6 +96,64 @@ 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(&state, &api_base_url, std::slice::from_ref(&filter)).await?;
Comment thread
loganj marked this conversation as resolved.
Outdated
let current: Value = prior_events
.first()
.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 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);
let builder = events::build_profile(display_name, name, Some(&avatar_url), about, nip05)?;
Comment thread
loganj marked this conversation as resolved.
Outdated
submit_event_at_with_keys(builder, &state, &api_base_url, &signer).await?;

let events = query_relay_at(&state, &api_base_url, &[filter]).await?;
Ok(events
.first()
.map(nostr_convert::profile_info_from_event)
.transpose()?
.unwrap_or_else(|| empty_profile_info(&expected_pubkey)))
}

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 +395,27 @@ 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 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
52 changes: 2 additions & 50 deletions desktop/src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,56 +530,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
}
6 changes: 6 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,11 @@ function CommunitySwitchGate() {
function CommunityQueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(createBuzzQueryClient);

useEffect(() => {
setAvatarProfileSyncQueryClient(queryClient);
return () => setAvatarProfileSyncQueryClient(null);
}, [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
25 changes: 23 additions & 2 deletions desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
WELCOME_SURFACE_READY_EVENT,
} from "@/features/onboarding/welcome";
import { useAvatarPresentation } from "@/features/profile/avatarPresentationStore";
import { saveAvatarWhenReady } from "@/features/profile/avatarProfileSync";
import { profileQueryKey } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
Expand Down Expand Up @@ -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[]>(
Expand Down Expand Up @@ -380,10 +382,29 @@ export function CommunityOnboardingFlow({
if (!displayName.trim()) return;
setIsPending(true);
try {
await updateProfile({
const candidateAvatarUrl = avatarUrl.trim();
const presentationState = avatarPresentation?.state;
const shouldSaveCandidate =
candidateAvatarUrl.length > 0 &&
presentationState !== "failed" &&
presentationState !== "pending";

const profile = await updateProfile({
displayName: displayName.trim(),
avatarUrl: avatarUrl.trim() || undefined,
avatarUrl: shouldSaveCandidate ? candidateAvatarUrl : undefined,
Comment thread
loganj marked this conversation as resolved.
Outdated
});
if (
candidateAvatarUrl &&
presentationState &&
presentationState !== "ready"
) {
saveAvatarWhenReady({
avatarUrl: candidateAvatarUrl,
relayUrl: transaction.relayUrl,
expectedPubkey: profile.pubkey,
expectedAvatarUrl: profile.avatarUrl,
});
}
update({ stage: "team-intro", error: undefined });
} catch (error) {
if (isRelayMembershipDeniedError(error)) {
Expand Down
Loading
Loading