Skip to content
Merged
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ This allows `applyTheme()` to switch themes at runtime.
- **`Label`** (`src/components/ui/label.tsx`): Dotted-border tag/badge for metadata labels (language, kind, status, metric type). Two sizes: `sm` (default) and `md`. Use instead of ad-hoc `<span>` tags for tag-like indicators.
- **`RichText`** (`src/components/nostr/RichText.tsx`): Universal Nostr content renderer. Parses mentions, hashtags, custom emoji, media embeds, and nostr: references. Use for any event body text — never render `event.content` as a raw string.
- **`CustomEmoji`** (`src/components/nostr/CustomEmoji.tsx`): Renders NIP-30 custom emoji images inline. Shows shortcode tooltip, handles load errors gracefully.
- **`Timestamp`** (`src/components/Timestamp.tsx`): Locale-aware short time display (e.g., "2:30 PM" or "14:30"). Takes a Unix timestamp in seconds. Use for inline time rendering in chat messages, lists, and log entries. For other formats (relative, date, datetime), use `formatTimestamp()` from `src/hooks/useLocale.ts`.

## Important Patterns

Expand Down
37 changes: 12 additions & 25 deletions src/actions/delete-event.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import publishService from "@/services/publish-service";
import { selectRelaysForPublish } from "@/services/relay-selection";
import { EventFactory } from "applesauce-core/event-factory";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { mergeRelaySets } from "applesauce-core/helpers";
import { grimoireStateAtom } from "@/core/state";
import { getDefaultStore } from "jotai";
import { NostrEvent } from "@/types/nostr";
import { settingsManager } from "@/services/settings";
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
Expand Down Expand Up @@ -37,24 +33,15 @@ export class DeleteEventAction {

const event = await factory.sign(draft);

// Get write relays from cache and state
const authorWriteRelays =
(await relayListCache.getOutboxRelays(account.pubkey)) || [];

const store = getDefaultStore();
const state = store.get(grimoireStateAtom);
const stateWriteRelays =
state.activeAccount?.relays?.filter((r) => r.write).map((r) => r.url) ||
[];

// Combine all relay sources
const writeRelays = mergeRelaySets(
authorWriteRelays,
stateWriteRelays,
AGGREGATOR_RELAYS,
);

// Publish to all target relays
await pool.publish(writeRelays, event);
// Select relays and publish
const relays = await selectRelaysForPublish(account.pubkey);
const result = await publishService.publish(event, relays);

if (!result.ok) {
const errors = result.failed
.map((f) => `${f.relay}: ${f.error}`)
.join(", ");
throw new Error(`Failed to publish deletion event. Errors: ${errors}`);
}
}
}
28 changes: 16 additions & 12 deletions src/actions/publish-spell.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PublishSpellAction } from "./publish-spell";
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import publishService from "@/services/publish-service";
import * as spellStorage from "@/services/spell-storage";
import { LocalSpell } from "@/services/db";

Expand All @@ -15,20 +15,24 @@ vi.mock("@/services/accounts", () => ({
},
}));

vi.mock("@/services/relay-pool", () => ({
vi.mock("@/services/publish-service", () => ({
default: {
publish: vi.fn(),
publish: vi.fn().mockResolvedValue({
publishId: "pub_1",
event: {},
successful: ["wss://test.relay/"],
failed: [],
ok: true,
}),
},
}));

vi.mock("@/services/spell-storage", () => ({
markSpellPublished: vi.fn(),
}));

vi.mock("@/services/relay-list-cache", () => ({
relayListCache: {
getOutboxRelays: vi.fn().mockResolvedValue([]),
},
vi.mock("@/services/relay-selection", () => ({
selectRelaysForPublish: vi.fn().mockResolvedValue(["wss://test.relay/"]),
}));

vi.mock("@/services/event-store", () => ({
Expand Down Expand Up @@ -89,18 +93,18 @@ describe("PublishSpellAction", () => {

await action.execute(spell);

// Check if signer was called
expect(mockSigner.signEvent).toHaveBeenCalled();

// Check if published to pool
expect(pool.publish).toHaveBeenCalled();
// Verify publishService was called (not pool.publish)
expect(publishService.publish).toHaveBeenCalledWith(
expect.objectContaining({ kind: 777 }),
["wss://test.relay/"],
);

// Check if storage updated
expect(spellStorage.markSpellPublished).toHaveBeenCalledWith(
"local-id",
expect.objectContaining({
kind: 777,
// We expect tags to contain name and alt (description)
tags: expect.arrayContaining([
["name", "My Spell"],
["alt", expect.stringContaining("Description")],
Expand Down
49 changes: 19 additions & 30 deletions src/actions/publish-spell.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { LocalSpell } from "@/services/db";
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import publishService from "@/services/publish-service";
import { selectRelaysForPublish } from "@/services/relay-selection";
import { encodeSpell } from "@/lib/spell-conversion";
import { markSpellPublished } from "@/services/spell-storage";
import { EventFactory } from "applesauce-core/event-factory";
import { SpellEvent } from "@/types/spell";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { mergeRelaySets } from "applesauce-core/helpers";
import eventStore from "@/services/event-store";
import { settingsManager } from "@/services/settings";
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";

Expand All @@ -25,7 +22,6 @@ export class PublishSpellAction {

if (spell.isPublished && spell.event) {
// Use existing signed event for rebroadcasting

event = spell.event;
} else {
const signer = account.signer;
Expand All @@ -34,9 +30,7 @@ export class PublishSpellAction {

const encoded = encodeSpell({
command: spell.command,

name: spell.name,

description: spell.description,
});

Expand All @@ -50,38 +44,33 @@ export class PublishSpellAction {

const draft = await factory.build({
kind: 777,

content: encoded.content,

tags,
});

event = (await factory.sign(draft)) as SpellEvent;
}

// Use provided relays or fallback to author's write relays + aggregators

let relays = targetRelays;

if (!relays || relays.length === 0) {
const authorWriteRelays =
(await relayListCache.getOutboxRelays(account.pubkey)) || [];

relays = mergeRelaySets(
event.tags.find((t) => t[0] === "relays")?.slice(1) || [],

authorWriteRelays,

AGGREGATOR_RELAYS,
);
// Determine relays: explicit target relays or outbox selection with hints
let relays: string[];
if (targetRelays && targetRelays.length > 0) {
relays = targetRelays;
} else {
const eventRelayHints =
event.tags.find((t) => t[0] === "relays")?.slice(1) || [];
relays = await selectRelaysForPublish(account.pubkey, {
relayHints: eventRelayHints,
});
}

// Publish to all target relays

await pool.publish(relays, event);
const result = await publishService.publish(event, relays);

// Add to event store for immediate availability
eventStore.add(event);
if (!result.ok) {
const errors = result.failed
.map((f) => `${f.relay}: ${f.error}`)
.join(", ");
throw new Error(`Failed to publish spell. Errors: ${errors}`);
}

await markSpellPublished(spell.id, event);
}
Expand Down
Loading