Skip to content
Open
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
9 changes: 7 additions & 2 deletions crates/buzz-cli/src/commands/reactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,15 @@ pub async fn cmd_remove_reaction(
.as_array()
.ok_or_else(|| CliError::Other("reactions query response is not an array".into()))?;

// Find the reaction event matching the emoji
// Find the reaction event matching the emoji. Custom-emoji reactions store
// `:shortcode:` while callers often pass the bare shortcode.
let reaction_event_id = arr
.iter()
.find(|ev| ev.get("content").and_then(|c| c.as_str()) == Some(emoji))
.find(|ev| {
ev.get("content")
.and_then(|c| c.as_str())
.is_some_and(|content| buzz_sdk::reaction_content_matches(content, emoji))
})
.and_then(|ev| ev.get("id").and_then(|id| id.as_str()))
.ok_or_else(|| {
CliError::Other(format!(
Expand Down
28 changes: 28 additions & 0 deletions crates/buzz-sdk/src/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,23 @@ pub fn normalize_custom_emoji_shortcode(shortcode: &str) -> Result<String, SdkEr
Ok(trimmed.to_ascii_lowercase())
}

/// True when a kind:7 reaction `content` matches a user-supplied emoji argument.
///
/// Unicode reactions compare literally (after trim). Custom-emoji reactions
/// store `:shortcode:` per NIP-25/30; callers often pass the bare shortcode
/// (or a differently-cased / colon-wrapped form).
pub fn reaction_content_matches(content: &str, emoji: &str) -> bool {
let content = content.trim();
let emoji = emoji.trim();
if content == emoji {
return true;
}
match normalize_custom_emoji_shortcode(emoji) {
Ok(shortcode) => content == format!(":{shortcode}:"),
Err(_) => false,
}
}

fn check_custom_emoji_url(url: &str) -> Result<(), SdkError> {
if url.is_empty() {
return Err(SdkError::InvalidInput(
Expand Down Expand Up @@ -2323,6 +2340,17 @@ mod tests {
assert!(has_tag(&ev, "e", &eid.to_hex()));
}

#[test]
fn reaction_content_matches_unicode_and_custom_shortcodes() {
assert!(reaction_content_matches("👍", "👍"));
assert!(reaction_content_matches(" 👍 ", "👍"));
assert!(reaction_content_matches(":party:", "party"));
assert!(reaction_content_matches(":party:", ":Party:"));
assert!(reaction_content_matches(":party_parrot:", "Party_Parrot"));
assert!(!reaction_content_matches(":party:", "other"));
assert!(!reaction_content_matches("👍", "party"));
}

#[test]
fn set_canvas_happy_path() {
let cid = uuid();
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,7 @@ pub async fn remove_reaction(

let reaction_event = reactions
.iter()
.find(|ev| ev.content.trim() == trimmed_emoji)
.find(|ev| buzz_sdk_pkg::reaction_content_matches(&ev.content, trimmed_emoji))
.ok_or("could not find your reaction event for this emoji")?;

let builder = events::build_remove_reaction(reaction_event.id)?;
Expand Down
50 changes: 50 additions & 0 deletions desktop/src/features/onboarding/welcomeGuide.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
activateWelcomeTeamPersonasSequentially,
buildWelcomeStarterCreateInput,
filterWelcomeTeamAgentsMissingFromChannel,
LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT,
pickWelcomeGuideAgent,
pickWelcomeGuideAgentForRelay,
Expand Down Expand Up @@ -379,3 +380,52 @@ test("starter matching prefers running, then deployed instances", () => {
deployed,
);
});

test("filterWelcomeTeamAgentsMissingFromChannel skips same-named agent members", () => {
const localFizz = makeAgent({ name: "Fizz", pubkey: PUB_A });
const localHoney = makeAgent({ name: "Honey", pubkey: PUB_B });
const localBumble = makeAgent({ name: "Bumble", pubkey: PUB_C });

const members = [
{
pubkey: "d".repeat(64),
role: "bot",
isAgent: true,
joinedAt: "2026-06-11T00:00:00.000Z",
displayName: "Fizz",
},
{
pubkey: "e".repeat(64),
role: "bot",
isAgent: true,
joinedAt: "2026-06-11T00:00:00.000Z",
displayName: "honey",
},
];

const missing = filterWelcomeTeamAgentsMissingFromChannel(
[localFizz, localHoney, localBumble],
members,
);
assert.deepEqual(
missing.map((agent) => agent.pubkey),
[PUB_C],
);
});

test("filterWelcomeTeamAgentsMissingFromChannel skips pubkeys already present", () => {
const localFizz = makeAgent({ name: "Fizz", pubkey: PUB_A });
const members = [
{
pubkey: PUB_A,
role: "bot",
isAgent: true,
joinedAt: "2026-06-11T00:00:00.000Z",
displayName: "Fizz",
},
];
assert.deepEqual(
filterWelcomeTeamAgentsMissingFromChannel([localFizz], members),
[],
);
});
38 changes: 33 additions & 5 deletions desktop/src/features/onboarding/welcomeGuide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas";
import type {
AcpRuntime,
AgentPersona,
ChannelMember,
CreateManagedAgentInput,
ManagedAgent,
} from "@/shared/api/types";
Expand Down Expand Up @@ -183,11 +184,9 @@ async function ensureWelcomeTeamMembership(
agents: WelcomeTeamAgents,
) {
const members = await getChannelMembers(channelId).catch(() => []);
const memberPubkeys = new Set(
members.map((member) => normalizePubkey(member.pubkey)),
);
const missingAgents = agents.filter(
(agent) => !memberPubkeys.has(normalizePubkey(agent.pubkey)),
const missingAgents = filterWelcomeTeamAgentsMissingFromChannel(
agents,
members,
);
if (missingAgents.length === 0) {
return;
Expand All @@ -206,6 +205,35 @@ async function ensureWelcomeTeamMembership(
}
}

/**
* Welcome Team agents that still need channel membership.
*
* Skips pubkeys already in the channel, and skips local starters whose display
* name is already used by another agent member — a second Desktop install must
* not mint parallel Fizz/Honey/Bumble identities into the shared roster (#2648).
*/
export function filterWelcomeTeamAgentsMissingFromChannel(
agents: readonly ManagedAgent[],
members: readonly ChannelMember[],
): ManagedAgent[] {
const memberPubkeys = new Set(
members.map((member) => normalizePubkey(member.pubkey)),
);
const occupiedAgentNames = new Set(
members
.filter((member) => member.isAgent)
.map((member) => member.displayName?.trim().toLowerCase())
.filter((name): name is string => Boolean(name)),
);
return agents.filter((agent) => {
if (memberPubkeys.has(normalizePubkey(agent.pubkey))) {
return false;
}
const name = agent.name.trim().toLowerCase();
return !occupiedAgentNames.has(name);
});
}

export async function buildWelcomeStarterCreateInput(
starter: WelcomeTeamStarterDefinition,
persona: AgentPersona,
Expand Down
18 changes: 16 additions & 2 deletions desktop/src/features/profile/ui/ProfileAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,24 @@ export function ProfileAvatar({
// the poster and hover animation can recover independently.
const liveSrc = baseUrl ? rewriteRelayUrl(baseUrl) : null;
const [failedSrc, setFailedSrc] = React.useState<string | null>(null);
// Adjust failure state when the candidate URL changes so a transient relay
// blip can recover without remounting (e.g. visiting Settings).
const [trackedLiveSrc, setTrackedLiveSrc] = React.useState(liveSrc);
if (liveSrc !== trackedLiveSrc) {
setTrackedLiveSrc(liveSrc);
setFailedSrc(null);
}

const liveFailed = liveSrc !== null && failedSrc === liveSrc;

// When the relay is unreachable the proxied avatar URL 404s/times out; fall
// back to the locally cached data URL instead of dropping to initials.
const src = liveFailed
? (avatarDataUrl ?? undefined)
: (liveSrc ?? avatarDataUrl ?? undefined);
const shouldShowFallback = src === undefined || (!animated && liveFailed);
// Monogram only when there is no image candidate. A live failure that falls
// back to avatarDataUrl must still render that image, not initials (#2665).
const shouldShowFallback = src === undefined;

return (
<Avatar
Expand All @@ -80,7 +90,11 @@ export function ProfileAvatar({
)}
data-testid={testId ? `${testId}-image` : undefined}
onLoadingStatusChange={(status) => {
if (status === "error") setFailedSrc(liveSrc);
if (status === "error" && liveSrc !== null && src === liveSrc) {
setFailedSrc(liveSrc);
}
// Only clear on a successful live load — clearing after a data-URL
// fallback would immediately retry the broken live URL in a loop.
if (status === "loaded" && src === liveSrc) {
setFailedSrc(null);
}
Expand Down