mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: triage bugs and quick wins (#127)
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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(),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user