diff --git a/crates/sprout-auth/src/nip98.rs b/crates/sprout-auth/src/nip98.rs index 746b7ecdb..ccad6c16b 100644 --- a/crates/sprout-auth/src/nip98.rs +++ b/crates/sprout-auth/src/nip98.rs @@ -293,13 +293,14 @@ mod tests { } #[test] - fn is_self_mintable_all_seven() { + fn is_self_mintable_all_eight() { use crate::scope::{is_self_mintable, Scope}; assert!(is_self_mintable(&Scope::MessagesRead)); assert!(is_self_mintable(&Scope::MessagesWrite)); assert!(is_self_mintable(&Scope::ChannelsRead)); assert!(is_self_mintable(&Scope::ChannelsWrite)); assert!(is_self_mintable(&Scope::UsersRead)); + assert!(is_self_mintable(&Scope::UsersWrite)); assert!(is_self_mintable(&Scope::FilesRead)); assert!(is_self_mintable(&Scope::FilesWrite)); } @@ -308,7 +309,6 @@ mod tests { fn is_self_mintable_admin_scopes_false() { use crate::scope::{is_self_mintable, Scope}; assert!(!is_self_mintable(&Scope::AdminChannels)); - assert!(!is_self_mintable(&Scope::UsersWrite)); assert!(!is_self_mintable(&Scope::AdminUsers)); assert!(!is_self_mintable(&Scope::JobsRead)); assert!(!is_self_mintable(&Scope::JobsWrite)); diff --git a/crates/sprout-auth/src/scope.rs b/crates/sprout-auth/src/scope.rs index 903288fac..904bd9386 100644 --- a/crates/sprout-auth/src/scope.rs +++ b/crates/sprout-auth/src/scope.rs @@ -150,15 +150,19 @@ impl FromStr for Scope { /// Scopes that can be self-minted via `POST /api/tokens`. /// -/// Admin-only scopes (`AdminChannels`, `UsersWrite`, `AdminUsers`, `JobsRead`, `JobsWrite`, +/// Admin-only scopes (`AdminChannels`, `AdminUsers`, `JobsRead`, `JobsWrite`, /// `SubscriptionsRead`, `SubscriptionsWrite`) are intentionally excluded — they require /// `sprout-admin mint-token`. +/// +/// `UsersWrite` is included because it only guards self-profile endpoints +/// (`PUT /api/users/me/profile`, `PUT /api/users/me/channel-add-policy`). pub const SELF_MINTABLE_SCOPES: &[Scope] = &[ Scope::MessagesRead, Scope::MessagesWrite, Scope::ChannelsRead, Scope::ChannelsWrite, Scope::UsersRead, + Scope::UsersWrite, Scope::FilesRead, Scope::FilesWrite, ]; @@ -176,6 +180,7 @@ pub fn is_self_mintable(scope: &Scope) -> bool { | Scope::ChannelsRead | Scope::ChannelsWrite | Scope::UsersRead + | Scope::UsersWrite | Scope::FilesRead | Scope::FilesWrite ) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index ceccb5638..4f5fd5962 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -275,6 +275,7 @@ pub fn default_token_scopes() -> Vec { "messages:read".to_string(), "messages:write".to_string(), "channels:read".to_string(), + "users:write".to_string(), ] } diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 113c6d75b..7ffd3441a 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -64,7 +64,9 @@ pub struct ChannelInfo { pub member_count: i64, pub last_message_at: Option, pub archived_at: Option, + #[serde(default)] pub participants: Vec, + #[serde(default)] pub participant_pubkeys: Vec, #[serde(default = "default_true")] pub is_member: bool, @@ -127,11 +129,9 @@ pub struct OpenDmBody<'a> { pub pubkeys: &'a [String], } -#[derive(Serialize, Deserialize)] +#[derive(Deserialize)] pub struct OpenDmResponse { pub channel_id: String, - pub created: bool, - pub participants: Vec, } #[derive(Serialize)] diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index f5505580f..ddeb74d91 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -6,7 +6,7 @@ use sha2::{Digest, Sha256}; use crate::{ app_state::AppState, - models::{MintTokenBody, MintTokenResponse, UpdateProfileBody}, + models::UpdateProfileBody, }; pub fn relay_ws_url() -> String { @@ -171,38 +171,6 @@ pub fn build_nip98_auth_header_for_keys( )) } -pub async fn mint_managed_agent_api_token( - client: &reqwest::Client, - relay_url: &str, - keys: &Keys, - name: &str, - scopes: &[String], -) -> Result { - let url = format!("{}{}", relay_http_base_url(relay_url), "/api/tokens"); - let body = MintTokenBody { - name, - scopes, - channel_ids: None, - expires_in_days: None, - }; - let body_bytes = - serde_json::to_vec(&body).map_err(|error| format!("serialize failed: {error}"))?; - let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; - let forwarded_proto = if url.starts_with("http://") { - "http" - } else { - "https" - }; - let request = client - .request(Method::POST, url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .header("X-Forwarded-Proto", forwarded_proto) - .body(body_bytes); - - send_json_request(request).await -} - pub async fn relay_error_message(response: reqwest::Response) -> String { let status = response.status(); let body = response.text().await.unwrap_or_default(); diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index a99ae1c76..d1c09ee6f 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -401,14 +401,9 @@ export function useManagedAgentLogQuery( ) { return useQuery({ queryKey: ["managed-agent-log", pubkey, lineCount], - queryFn: () => { - if (!pubkey) { - throw new Error("No agent selected."); - } - - return getManagedAgentLog(pubkey, lineCount); - }, + queryFn: () => getManagedAgentLog(pubkey!, lineCount), enabled: pubkey !== null, + retry: false, staleTime: 1_000, refetchInterval: pubkey ? 2_000 : false, }); diff --git a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx index 9a358676e..f580cdae0 100644 --- a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx @@ -203,8 +203,9 @@ function ManagedAgentRow({ return ( onViewLogs(agent.pubkey)} >

{agent.name}

@@ -231,11 +232,19 @@ function ManagedAgentRow({ {agent.status} - + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > {agent.agentCommand} - + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > ; }; export type TimelineMessage = { diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx new file mode 100644 index 000000000..55940e080 --- /dev/null +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -0,0 +1,85 @@ +import * as React from "react"; + +import type { TimelineReaction } from "@/features/messages/types"; +import { cn } from "@/shared/lib/cn"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/shared/ui/tooltip"; + +export function MessageReactions({ + messageId, + reactions, + canToggle, + pending, + onSelect, +}: { + messageId: string; + reactions: TimelineReaction[]; + canToggle: boolean; + pending: boolean; + onSelect: (emoji: string) => void; +}) { + if (reactions.length === 0) { + return null; + } + + return ( + +
+ {reactions.map((reaction) => { + const tooltipText = + reaction.users.length > 0 + ? reaction.users.map((u) => u.displayName).join(", ") + : undefined; + + const pill = ( + + ); + + if (!tooltipText) { + return ( + + {pill} + + ); + } + + // Wrap in a span so the tooltip trigger receives hover/focus events + // even when the inner button is disabled (Radix tooltips require it). + return ( + + + {pill} + + {tooltipText} + + ); + })} +
+
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 31691beed..7013720b8 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -1,9 +1,7 @@ import * as React from "react"; -import type { - TimelineMessage, - TimelineReaction, -} from "@/features/messages/types"; +import type { TimelineMessage } from "@/features/messages/types"; +import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; @@ -244,42 +242,17 @@ export const MessageRow = React.memo( {renderBody()} - {reactions.length > 0 ? ( -
- {reactions.map((reaction: TimelineReaction) => ( - - ))} -
- ) : null} + { + void handleReactionSelect(emoji).catch(() => { + return; + }); + }} + /> {reactionErrorMessage ? (

{reactionErrorMessage} diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 95f845167..cb99ce507 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -40,6 +41,7 @@ export function useUsersBatchQuery( enabled?: boolean; }, ) { + const queryClient = useQueryClient(); const normalizedPubkeys = [ ...new Set(pubkeys.map((pubkey) => pubkey.toLowerCase())), ] @@ -47,13 +49,28 @@ export function useUsersBatchQuery( .sort(); const enabled = (options?.enabled ?? true) && normalizedPubkeys.length > 0; - return useQuery({ + const query = useQuery({ enabled, queryKey: ["users-batch", ...normalizedPubkeys], queryFn: () => getUsersBatch(normalizedPubkeys), staleTime: 60_000, gcTime: 5 * 60 * 1_000, }); + + // Seed individual "user-profile" cache entries so avatar clicks are instant + // cache hits instead of fresh network requests. + useEffect(() => { + const profiles = query.data?.profiles; + if (!profiles) return; + for (const [pubkey, summary] of Object.entries(profiles)) { + queryClient.setQueryData( + ["user-profile", pubkey], + (existing) => existing ?? { pubkey, about: null, ...summary }, + ); + } + }, [query.data, queryClient]); + + return query; } export function useUserSearchQuery(