fix: triage bugs and quick wins (#127)

This commit is contained in:
tlongwell-block
2026-03-20 09:51:09 -04:00
committed by GitHub
parent 49161df09d
commit 283d59d55b
12 changed files with 152 additions and 90 deletions
+2 -2
View File
@@ -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));
+6 -1
View File
@@ -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
)
@@ -275,6 +275,7 @@ pub fn default_token_scopes() -> Vec<String> {
"messages:read".to_string(),
"messages:write".to_string(),
"channels:read".to_string(),
"users:write".to_string(),
]
}
+3 -3
View File
@@ -64,7 +64,9 @@ pub struct ChannelInfo {
pub member_count: i64,
pub last_message_at: Option<String>,
pub archived_at: Option<String>,
#[serde(default)]
pub participants: Vec<String>,
#[serde(default)]
pub participant_pubkeys: Vec<String>,
#[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<String>,
}
#[derive(Serialize)]
+1 -33
View File
@@ -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<MintTokenResponse, String> {
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();
+2 -7
View File
@@ -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,
});
@@ -203,8 +203,9 @@ function ManagedAgentRow({
return (
<tr
className="border-b border-border/60 last:border-b-0 hover:bg-muted/30"
className="cursor-pointer border-b border-border/60 last:border-b-0 hover:bg-muted/30"
data-testid={`managed-agent-${agent.pubkey}`}
onClick={() => onViewLogs(agent.pubkey)}
>
<td className="px-4 py-3">
<p className="truncate font-medium text-foreground">{agent.name}</p>
@@ -231,11 +232,19 @@ function ManagedAgentRow({
{agent.status}
</span>
</td>
<td className="px-4 py-3">
<td
className="px-4 py-3"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<ModelPicker agent={agent} />
</td>
<td className="px-4 py-3 text-muted-foreground">{agent.agentCommand}</td>
<td className="px-3 py-3">
<td
className="px-3 py-3"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<AgentActionsMenu
agent={agent}
isActionPending={isActionPending}
@@ -173,6 +173,7 @@ export function formatTimelineMessages(
emoji,
count: 0,
reactedByCurrentUser: false,
users: [],
};
existing.count += 1;
@@ -180,6 +181,13 @@ export function formatTimelineMessages(
existing.reactedByCurrentUser = true;
}
const profile = profiles?.[actorPubkey];
const displayName =
profile?.displayName?.trim() ||
profile?.nip05Handle?.trim() ||
`${actorPubkey.slice(0, 8)}`;
existing.users.push({ pubkey: actorPubkey, displayName });
current.set(emoji, existing);
reactionsByEventId.set(targetId, current);
}
+1
View File
@@ -2,6 +2,7 @@ export type TimelineReaction = {
emoji: string;
count: number;
reactedByCurrentUser?: boolean;
users: Array<{ pubkey: string; displayName: string }>;
};
export type TimelineMessage = {
@@ -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 (
<TooltipProvider delayDuration={200}>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pt-1">
{reactions.map((reaction) => {
const tooltipText =
reaction.users.length > 0
? reaction.users.map((u) => u.displayName).join(", ")
: undefined;
const pill = (
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
reaction.reactedByCurrentUser
? "border-primary/40 bg-primary/10 text-primary"
: "border-border/70 bg-muted/70 text-foreground/90",
canToggle
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
: "cursor-default",
)}
disabled={!canToggle || pending}
onClick={() => {
if (!canToggle) return;
onSelect(reaction.emoji);
}}
type="button"
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">{reaction.count}</span>
</button>
);
if (!tooltipText) {
return (
<React.Fragment key={`${messageId}-${reaction.emoji}`}>
{pill}
</React.Fragment>
);
}
// Wrap in a span so the tooltip trigger receives hover/focus events
// even when the inner button is disabled (Radix tooltips require it).
return (
<Tooltip key={`${messageId}-${reaction.emoji}`}>
<TooltipTrigger asChild>
<span className="inline-flex">{pill}</span>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
);
}
+13 -40
View File
@@ -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(
</div>
</div>
{renderBody()}
{reactions.length > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pt-1">
{reactions.map((reaction: TimelineReaction) => (
<button
aria-label={`Toggle ${reaction.emoji} reaction`}
aria-pressed={reaction.reactedByCurrentUser}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
reaction.reactedByCurrentUser
? "border-primary/40 bg-primary/10 text-primary"
: "border-border/70 bg-muted/70 text-foreground/90",
canToggleReactions
? "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
: "cursor-default",
)}
disabled={!canToggleReactions || reactionPending}
key={`${message.id}-${reaction.emoji}`}
onClick={() => {
if (!canToggleReactions) {
return;
}
void handleReactionSelect(reaction.emoji).catch(() => {
return;
});
}}
type="button"
>
<span>{reaction.emoji}</span>
<span className="text-muted-foreground">
{reaction.count}
</span>
</button>
))}
</div>
) : null}
<MessageReactions
messageId={message.id}
reactions={reactions}
canToggle={canToggleReactions}
pending={reactionPending}
onSelect={(emoji) => {
void handleReactionSelect(emoji).catch(() => {
return;
});
}}
/>
{reactionErrorMessage ? (
<p className="mt-1.5 text-xs text-destructive">
{reactionErrorMessage}
+18 -1
View File
@@ -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<UsersBatchResponse>({
const query = useQuery<UsersBatchResponse>({
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<Profile>(
["user-profile", pubkey],
(existing) => existing ?? { pubkey, about: null, ...summary },
);
}
}, [query.data, queryClient]);
return query;
}
export function useUserSearchQuery(