mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
refactor: extract hooks and create generic Composer component
- Extract useRelaySelection hook for relay list management and status tracking - Extract useEventPublisher hook for event signing and publishing - Create generic Composer component that adapts UI based on schema - Refactor PostViewer to use Composer with NOTE_SCHEMA This refactoring separates concerns and makes it easier to create composers for other event kinds (issues, articles, comments, etc.) by simply providing the appropriate schema. https://claude.ai/code/session_01WpZc66saVdASHKrrnz3Tme
This commit is contained in:
@@ -1,836 +1,108 @@
|
||||
import { useState, useRef, useCallback, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Paperclip,
|
||||
Send,
|
||||
Loader2,
|
||||
Check,
|
||||
X,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Server,
|
||||
ServerOff,
|
||||
Plus,
|
||||
Circle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "./ui/button";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { Input } from "./ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "./ui/dropdown-menu";
|
||||
/**
|
||||
* PostViewer - Note (kind 1) composer using generic Composer
|
||||
*
|
||||
* Uses the schema-driven Composer component with NOTE_SCHEMA
|
||||
* to compose and publish short text notes.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { Composer, type ComposerInput } from "@/components/composer";
|
||||
import { Kind1Renderer } from "@/components/nostr/kinds";
|
||||
import { NOTE_SCHEMA } from "@/lib/composer/schemas";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { useProfileSearch } from "@/hooks/useProfileSearch";
|
||||
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
|
||||
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { RichEditor, type RichEditorHandle } from "./editor/RichEditor";
|
||||
import type { BlobAttachment, EmojiTag } from "./editor/MentionEditor";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
import { Kind1Renderer } from "./nostr/kinds";
|
||||
import pool from "@/services/relay-pool";
|
||||
import eventStore from "@/services/event-store";
|
||||
import { EventFactory } from "applesauce-core/event-factory";
|
||||
import { NoteBlueprint } from "applesauce-common/blueprints";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
import { normalizeRelayURL } from "@/lib/relay-url";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { getAuthIcon } from "@/lib/relay-status-utils";
|
||||
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
|
||||
|
||||
// Per-relay publish status
|
||||
type RelayStatus = "pending" | "publishing" | "success" | "error";
|
||||
|
||||
interface RelayPublishState {
|
||||
url: string;
|
||||
status: RelayStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Storage keys
|
||||
const DRAFT_STORAGE_KEY = "grimoire-post-draft";
|
||||
|
||||
interface PostViewerProps {
|
||||
windowId?: string;
|
||||
}
|
||||
|
||||
export function PostViewer({ windowId }: PostViewerProps = {}) {
|
||||
const { pubkey, canSign, signer } = useAccount();
|
||||
const { searchProfiles } = useProfileSearch();
|
||||
const { searchEmojis } = useEmojiSearch();
|
||||
const { state } = useGrimoire();
|
||||
const { getRelay } = useRelayState();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const { signer } = useAccount();
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Editor ref for programmatic control
|
||||
const editorRef = useRef<RichEditorHandle>(null);
|
||||
|
||||
// Publish state
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [relayStates, setRelayStates] = useState<RelayPublishState[]>([]);
|
||||
const [selectedRelays, setSelectedRelays] = useState<Set<string>>(new Set());
|
||||
const [isEditorEmpty, setIsEditorEmpty] = useState(true);
|
||||
const [lastPublishedEvent, setLastPublishedEvent] = useState<any>(null);
|
||||
const [showPublishedPreview, setShowPublishedPreview] = useState(false);
|
||||
const [newRelayInput, setNewRelayInput] = useState("");
|
||||
|
||||
// Get relay pool state for connection status
|
||||
const relayPoolMap = use$(pool.relays$);
|
||||
|
||||
// Get active account's write relays from Grimoire state, fallback to aggregators
|
||||
const writeRelays = useMemo(() => {
|
||||
if (!state.activeAccount?.relays) return AGGREGATOR_RELAYS;
|
||||
const userWriteRelays = state.activeAccount.relays
|
||||
.filter((r) => r.write)
|
||||
.map((r) => r.url);
|
||||
return userWriteRelays.length > 0 ? userWriteRelays : AGGREGATOR_RELAYS;
|
||||
}, [state.activeAccount?.relays]);
|
||||
|
||||
// Update relay states when write relays change
|
||||
const updateRelayStates = useCallback(() => {
|
||||
setRelayStates(
|
||||
writeRelays.map((url) => ({
|
||||
url,
|
||||
status: "pending" as RelayStatus,
|
||||
})),
|
||||
);
|
||||
setSelectedRelays(new Set(writeRelays));
|
||||
}, [writeRelays]);
|
||||
|
||||
// Initialize selected relays when write relays change
|
||||
useEffect(() => {
|
||||
if (writeRelays.length > 0) {
|
||||
updateRelayStates();
|
||||
}
|
||||
}, [writeRelays, updateRelayStates]);
|
||||
|
||||
// Track if draft has been loaded to prevent re-runs
|
||||
const draftLoadedRef = useRef(false);
|
||||
|
||||
// Load draft from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (!pubkey || draftLoadedRef.current) return;
|
||||
|
||||
const draftKey = windowId
|
||||
? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}`
|
||||
: `${DRAFT_STORAGE_KEY}-${pubkey}`;
|
||||
const savedDraft = localStorage.getItem(draftKey);
|
||||
|
||||
if (savedDraft) {
|
||||
try {
|
||||
const draft = JSON.parse(savedDraft);
|
||||
draftLoadedRef.current = true;
|
||||
|
||||
// Restore editor content with retry logic for editor readiness
|
||||
if (draft.editorState) {
|
||||
const trySetContent = (attempts = 0) => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.setContent(draft.editorState);
|
||||
} else if (attempts < 10) {
|
||||
// Retry up to 10 times with 50ms intervals (500ms total)
|
||||
setTimeout(() => trySetContent(attempts + 1), 50);
|
||||
}
|
||||
};
|
||||
// Start trying after a short delay to let editor mount
|
||||
setTimeout(() => trySetContent(), 50);
|
||||
}
|
||||
|
||||
// Restore selected relays
|
||||
if (draft.selectedRelays && Array.isArray(draft.selectedRelays)) {
|
||||
setSelectedRelays(new Set(draft.selectedRelays));
|
||||
}
|
||||
|
||||
// Restore added relays (relays not in writeRelays)
|
||||
if (draft.addedRelays && Array.isArray(draft.addedRelays)) {
|
||||
setRelayStates((prev) => {
|
||||
const currentRelayUrls = new Set(prev.map((r) => r.url));
|
||||
const newRelays = draft.addedRelays
|
||||
.filter((url: string) => !currentRelayUrls.has(url))
|
||||
.map((url: string) => ({
|
||||
url,
|
||||
status: "pending" as RelayStatus,
|
||||
}));
|
||||
return newRelays.length > 0 ? [...prev, ...newRelays] : prev;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load draft:", err);
|
||||
// Build the kind 1 note event
|
||||
const handleBuildEvent = useCallback(
|
||||
async (input: ComposerInput): Promise<NostrEvent> => {
|
||||
if (!signer) {
|
||||
throw new Error("No signer available");
|
||||
}
|
||||
} else {
|
||||
draftLoadedRef.current = true;
|
||||
}
|
||||
}, [pubkey, windowId]);
|
||||
|
||||
// Save draft to localStorage on content change
|
||||
const saveDraft = useCallback(() => {
|
||||
if (!pubkey || !editorRef.current) return;
|
||||
// Create event factory with signer
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(signer);
|
||||
|
||||
const content = editorRef.current.getContent();
|
||||
const editorState = editorRef.current.getJSON();
|
||||
// Use NoteBlueprint - it auto-extracts hashtags, mentions, and quotes from content!
|
||||
const draft = await factory.create(NoteBlueprint, input.content, {
|
||||
emojis: input.emojiTags.map((e) => ({
|
||||
shortcode: e.shortcode,
|
||||
url: e.url,
|
||||
})),
|
||||
});
|
||||
|
||||
const draftKey = windowId
|
||||
? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}`
|
||||
: `${DRAFT_STORAGE_KEY}-${pubkey}`;
|
||||
// Add tags that applesauce doesn't handle yet
|
||||
const additionalTags: string[][] = [];
|
||||
|
||||
if (!content.trim()) {
|
||||
// Clear draft if empty
|
||||
localStorage.removeItem(draftKey);
|
||||
return;
|
||||
}
|
||||
// Add subject tag if title provided
|
||||
if (input.title) {
|
||||
additionalTags.push(["subject", input.title]);
|
||||
}
|
||||
|
||||
// Identify added relays (those not in writeRelays)
|
||||
const addedRelays = relayStates
|
||||
.filter((r) => !writeRelays.includes(r.url))
|
||||
.map((r) => r.url);
|
||||
// Add a tags for address references (naddr - not yet supported by applesauce)
|
||||
for (const addr of input.addressRefs) {
|
||||
additionalTags.push([
|
||||
"a",
|
||||
`${addr.kind}:${addr.pubkey}:${addr.identifier}`,
|
||||
]);
|
||||
}
|
||||
|
||||
const draft = {
|
||||
editorState, // Full editor JSON state (preserves blobs, emojis, formatting)
|
||||
selectedRelays: Array.from(selectedRelays), // Selected relay URLs
|
||||
addedRelays, // Custom relays added by user
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// Add client tag (if enabled)
|
||||
if (settings?.post?.includeClientTag) {
|
||||
additionalTags.push(GRIMOIRE_CLIENT_TAG);
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(draftKey, JSON.stringify(draft));
|
||||
} catch (err) {
|
||||
console.error("Failed to save draft:", err);
|
||||
}
|
||||
}, [pubkey, windowId, selectedRelays, relayStates, writeRelays]);
|
||||
// Add imeta tags for blob attachments (NIP-92)
|
||||
for (const blob of input.blobAttachments) {
|
||||
const imetaTag = [
|
||||
"imeta",
|
||||
`url ${blob.url}`,
|
||||
`m ${blob.mimeType}`,
|
||||
`x ${blob.sha256}`,
|
||||
`size ${blob.size}`,
|
||||
];
|
||||
if (blob.server) {
|
||||
imetaTag.push(`server ${blob.server}`);
|
||||
}
|
||||
additionalTags.push(imetaTag);
|
||||
}
|
||||
|
||||
// Debounced draft save on editor changes
|
||||
const draftSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
// Merge additional tags with blueprint tags
|
||||
draft.tags.push(...additionalTags);
|
||||
|
||||
// Sign and return the event
|
||||
return factory.sign(draft);
|
||||
},
|
||||
[signer, settings?.post?.includeClientTag],
|
||||
);
|
||||
|
||||
const handleEditorChange = useCallback(() => {
|
||||
// Update empty state immediately
|
||||
if (editorRef.current) {
|
||||
setIsEditorEmpty(editorRef.current.isEmpty());
|
||||
}
|
||||
|
||||
// Debounce draft save (500ms)
|
||||
if (draftSaveTimeoutRef.current) {
|
||||
clearTimeout(draftSaveTimeoutRef.current);
|
||||
}
|
||||
draftSaveTimeoutRef.current = setTimeout(() => {
|
||||
saveDraft();
|
||||
}, 500);
|
||||
}, [saveDraft]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (draftSaveTimeoutRef.current) {
|
||||
clearTimeout(draftSaveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Blossom upload for attachments
|
||||
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
|
||||
accept: "image/*,video/*,audio/*",
|
||||
onSuccess: (results) => {
|
||||
if (results.length > 0 && editorRef.current) {
|
||||
const { blob, server } = results[0];
|
||||
editorRef.current.insertBlob({
|
||||
url: blob.url,
|
||||
sha256: blob.sha256,
|
||||
mimeType: blob.type,
|
||||
size: blob.size,
|
||||
server,
|
||||
});
|
||||
editorRef.current.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Toggle relay selection
|
||||
const toggleRelay = useCallback((url: string) => {
|
||||
setSelectedRelays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(url)) {
|
||||
next.delete(url);
|
||||
} else {
|
||||
next.add(url);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Retry publishing to a specific relay
|
||||
const retryRelay = useCallback(
|
||||
async (relayUrl: string) => {
|
||||
// Reuse the last published event instead of recreating it
|
||||
if (!lastPublishedEvent) {
|
||||
toast.error("No event to retry");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Update status to publishing
|
||||
setRelayStates((prev) =>
|
||||
prev.map((r) =>
|
||||
r.url === relayUrl
|
||||
? { ...r, status: "publishing" as RelayStatus }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
|
||||
// Republish the same signed event
|
||||
await pool.publish([relayUrl], lastPublishedEvent);
|
||||
|
||||
// Update status to success
|
||||
setRelayStates((prev) =>
|
||||
prev.map((r) =>
|
||||
r.url === relayUrl
|
||||
? { ...r, status: "success" as RelayStatus, error: undefined }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
|
||||
toast.success(`Published to ${relayUrl.replace(/^wss?:\/\//, "")}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to retry publish to ${relayUrl}:`, error);
|
||||
setRelayStates((prev) =>
|
||||
prev.map((r) =>
|
||||
r.url === relayUrl
|
||||
? {
|
||||
...r,
|
||||
status: "error" as RelayStatus,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
}
|
||||
: r,
|
||||
),
|
||||
);
|
||||
toast.error(
|
||||
`Failed to publish to ${relayUrl.replace(/^wss?:\/\//, "")}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[lastPublishedEvent],
|
||||
);
|
||||
|
||||
// Publish to selected relays with per-relay status tracking
|
||||
const handlePublish = useCallback(
|
||||
async (
|
||||
content: string,
|
||||
emojiTags: EmojiTag[],
|
||||
blobAttachments: BlobAttachment[],
|
||||
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||
) => {
|
||||
if (!canSign || !signer || !pubkey) {
|
||||
toast.error("Please log in to publish");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
toast.error("Cannot publish empty note");
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = Array.from(selectedRelays);
|
||||
if (selected.length === 0) {
|
||||
toast.error("Please select at least one relay");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
|
||||
// Create and sign event first
|
||||
let event;
|
||||
try {
|
||||
// Create event factory with signer
|
||||
const factory = new EventFactory();
|
||||
factory.setSigner(signer);
|
||||
|
||||
// Use NoteBlueprint - it auto-extracts hashtags, mentions, and quotes from content!
|
||||
const draft = await factory.create(NoteBlueprint, content.trim(), {
|
||||
emojis: emojiTags.map((e) => ({
|
||||
shortcode: e.shortcode,
|
||||
url: e.url,
|
||||
})),
|
||||
});
|
||||
|
||||
// Add tags that applesauce doesn't handle yet
|
||||
const additionalTags: string[][] = [];
|
||||
|
||||
// Add a tags for address references (naddr - not yet supported by applesauce)
|
||||
for (const addr of addressRefs) {
|
||||
additionalTags.push([
|
||||
"a",
|
||||
`${addr.kind}:${addr.pubkey}:${addr.identifier}`,
|
||||
]);
|
||||
}
|
||||
|
||||
// Add client tag (if enabled)
|
||||
if (settings?.post?.includeClientTag) {
|
||||
additionalTags.push(GRIMOIRE_CLIENT_TAG);
|
||||
}
|
||||
|
||||
// Add imeta tags for blob attachments (NIP-92)
|
||||
for (const blob of blobAttachments) {
|
||||
const imetaTag = [
|
||||
"imeta",
|
||||
`url ${blob.url}`,
|
||||
`m ${blob.mimeType}`,
|
||||
`x ${blob.sha256}`,
|
||||
`size ${blob.size}`,
|
||||
];
|
||||
if (blob.server) {
|
||||
imetaTag.push(`server ${blob.server}`);
|
||||
}
|
||||
additionalTags.push(imetaTag);
|
||||
}
|
||||
|
||||
// Merge additional tags with blueprint tags
|
||||
draft.tags.push(...additionalTags);
|
||||
|
||||
// Sign the event
|
||||
event = await factory.sign(draft);
|
||||
} catch (error) {
|
||||
// Signing failed - user might have rejected it
|
||||
console.error("Failed to sign event:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to sign note",
|
||||
);
|
||||
setIsPublishing(false);
|
||||
return; // Don't destroy the post, let user try again
|
||||
}
|
||||
|
||||
// Signing succeeded, now publish to relays
|
||||
try {
|
||||
// Store the signed event for potential retries
|
||||
setLastPublishedEvent(event);
|
||||
|
||||
// Update relay states - set selected to publishing, keep others as pending
|
||||
setRelayStates((prev) =>
|
||||
prev.map((r) =>
|
||||
selected.includes(r.url)
|
||||
? { ...r, status: "publishing" as RelayStatus }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
|
||||
// Publish to each relay individually to track status
|
||||
const publishPromises = selected.map(async (relayUrl) => {
|
||||
try {
|
||||
await pool.publish([relayUrl], event);
|
||||
|
||||
// Update status to success
|
||||
setRelayStates((prev) =>
|
||||
prev.map((r) =>
|
||||
r.url === relayUrl
|
||||
? { ...r, status: "success" as RelayStatus }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
return { success: true, relayUrl };
|
||||
} catch (error) {
|
||||
console.error(`Failed to publish to ${relayUrl}:`, error);
|
||||
|
||||
// Update status to error
|
||||
setRelayStates((prev) =>
|
||||
prev.map((r) =>
|
||||
r.url === relayUrl
|
||||
? {
|
||||
...r,
|
||||
status: "error" as RelayStatus,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error",
|
||||
}
|
||||
: r,
|
||||
),
|
||||
);
|
||||
return { success: false, relayUrl };
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all publishes to complete (settled = all finished, regardless of success/failure)
|
||||
const results = await Promise.allSettled(publishPromises);
|
||||
|
||||
// Check how many relays succeeded
|
||||
const successCount = results.filter(
|
||||
(r) => r.status === "fulfilled" && r.value.success,
|
||||
).length;
|
||||
|
||||
if (successCount > 0) {
|
||||
// At least one relay succeeded - add to event store
|
||||
eventStore.add(event);
|
||||
|
||||
// Clear draft from localStorage
|
||||
if (pubkey) {
|
||||
const draftKey = windowId
|
||||
? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}`
|
||||
: `${DRAFT_STORAGE_KEY}-${pubkey}`;
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
|
||||
// Clear editor content
|
||||
editorRef.current?.clear();
|
||||
|
||||
// Show published preview
|
||||
setShowPublishedPreview(true);
|
||||
|
||||
// Show success toast
|
||||
if (successCount === selected.length) {
|
||||
toast.success(
|
||||
`Published to all ${selected.length} relay${selected.length > 1 ? "s" : ""}`,
|
||||
);
|
||||
} else {
|
||||
toast.warning(
|
||||
`Published to ${successCount} of ${selected.length} relays`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// All relays failed - keep the editor visible with content
|
||||
toast.error(
|
||||
"Failed to publish to any relay. Please check your relay connections and try again.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to publish:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to publish note",
|
||||
);
|
||||
|
||||
// Reset relay states to pending on publishing error
|
||||
setRelayStates((prev) =>
|
||||
prev.map((r) => ({
|
||||
...r,
|
||||
status: "error" as RelayStatus,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
})),
|
||||
);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
},
|
||||
[canSign, signer, pubkey, selectedRelays, settings],
|
||||
);
|
||||
|
||||
// Handle file paste
|
||||
const handleFilePaste = useCallback(
|
||||
(files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
// For pasted files, trigger upload dialog
|
||||
openUpload();
|
||||
}
|
||||
},
|
||||
[openUpload],
|
||||
);
|
||||
|
||||
// Reset form to compose another post
|
||||
const handleReset = useCallback(() => {
|
||||
setShowPublishedPreview(false);
|
||||
setLastPublishedEvent(null);
|
||||
updateRelayStates();
|
||||
editorRef.current?.clear();
|
||||
editorRef.current?.focus();
|
||||
}, [updateRelayStates]);
|
||||
|
||||
// Discard draft and clear editor
|
||||
const handleDiscard = useCallback(() => {
|
||||
editorRef.current?.clear();
|
||||
if (pubkey) {
|
||||
const draftKey = windowId
|
||||
? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}`
|
||||
: `${DRAFT_STORAGE_KEY}-${pubkey}`;
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
editorRef.current?.focus();
|
||||
}, [pubkey, windowId]);
|
||||
|
||||
// Check if input looks like a valid relay URL
|
||||
const isValidRelayInput = useCallback((input: string): boolean => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
// Allow relay URLs with or without protocol
|
||||
// Must have at least a domain part (e.g., "relay.com" or "wss://relay.com")
|
||||
const urlPattern =
|
||||
/^(wss?:\/\/)?[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}(:[0-9]{1,5})?(\/.*)?$/;
|
||||
|
||||
return urlPattern.test(trimmed);
|
||||
}, []);
|
||||
|
||||
// Add new relay to the list
|
||||
const handleAddRelay = useCallback(() => {
|
||||
const trimmed = newRelayInput.trim();
|
||||
if (!trimmed || !isValidRelayInput(trimmed)) return;
|
||||
|
||||
try {
|
||||
// Normalize the URL (adds wss:// if needed)
|
||||
const normalizedUrl = normalizeRelayURL(trimmed);
|
||||
|
||||
// Check if already in list
|
||||
const alreadyExists = relayStates.some((r) => r.url === normalizedUrl);
|
||||
if (alreadyExists) {
|
||||
toast.error("Relay already in list");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to relay states
|
||||
setRelayStates((prev) => [
|
||||
...prev,
|
||||
{ url: normalizedUrl, status: "pending" as RelayStatus },
|
||||
]);
|
||||
|
||||
// Select the new relay
|
||||
setSelectedRelays((prev) => new Set([...prev, normalizedUrl]));
|
||||
|
||||
// Clear input
|
||||
setNewRelayInput("");
|
||||
} catch (error) {
|
||||
console.error("Failed to add relay:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Invalid relay URL");
|
||||
}
|
||||
}, [newRelayInput, isValidRelayInput, relayStates]);
|
||||
|
||||
// Show login prompt if not logged in
|
||||
if (!canSign) {
|
||||
// Render preview of published event
|
||||
const renderPreview = useCallback((event: NostrEvent) => {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center p-8">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
You need to be logged in to post notes.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click the user icon in the top right to log in.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-muted/10 p-4">
|
||||
<Kind1Renderer event={event} depth={0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto space-y-4 p-4">
|
||||
{!showPublishedPreview ? (
|
||||
<>
|
||||
{/* Editor */}
|
||||
<div>
|
||||
<RichEditor
|
||||
ref={editorRef}
|
||||
placeholder="What's on your mind?"
|
||||
onSubmit={handlePublish}
|
||||
onChange={handleEditorChange}
|
||||
searchProfiles={searchProfiles}
|
||||
searchEmojis={searchEmojis}
|
||||
onFilePaste={handleFilePaste}
|
||||
autoFocus
|
||||
minHeight={150}
|
||||
maxHeight={400}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Upload button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => openUpload()}
|
||||
disabled={isPublishing}
|
||||
title="Upload image/video"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Settings dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={isPublishing}
|
||||
title="Post settings"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={settings?.post?.includeClientTag ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting("post", "includeClientTag", checked)
|
||||
}
|
||||
>
|
||||
Include client tag
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Discard button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDiscard}
|
||||
disabled={isPublishing || isEditorEmpty}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
|
||||
{/* Publish button */}
|
||||
<Button
|
||||
onClick={() => editorRef.current?.submit()}
|
||||
disabled={
|
||||
isPublishing || selectedRelays.size === 0 || isEditorEmpty
|
||||
}
|
||||
className="gap-2 w-32"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4" />
|
||||
Publish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Published event preview */}
|
||||
{lastPublishedEvent && (
|
||||
<div className="rounded-lg border border-border bg-muted/10 p-4">
|
||||
<Kind1Renderer event={lastPublishedEvent} depth={0} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset button */}
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={handleReset} className="gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Compose Another Post
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Relay selection */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Relays ({selectedRelays.size} selected)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{relayStates.map((relay) => {
|
||||
// Get relay connection state from pool
|
||||
const poolRelay = relayPoolMap?.get(relay.url);
|
||||
const isConnected = poolRelay?.connected ?? false;
|
||||
|
||||
// Get relay state for auth status
|
||||
const relayState = getRelay(relay.url);
|
||||
const authIcon = getAuthIcon(relayState);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={relay.url}
|
||||
className="flex items-center justify-between gap-3 py-1"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Checkbox
|
||||
id={relay.url}
|
||||
checked={selectedRelays.has(relay.url)}
|
||||
onCheckedChange={() => toggleRelay(relay.url)}
|
||||
disabled={isPublishing || showPublishedPreview}
|
||||
/>
|
||||
{/* Connectivity status icon */}
|
||||
{isConnected ? (
|
||||
<Server className="h-3 w-3 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<ServerOff className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{/* Auth status icon */}
|
||||
<div className="flex-shrink-0" title={authIcon.label}>
|
||||
{authIcon.icon}
|
||||
</div>
|
||||
<label
|
||||
htmlFor={relay.url}
|
||||
className="cursor-pointer truncate flex-1"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<RelayLink
|
||||
url={relay.url}
|
||||
write={true}
|
||||
showInboxOutbox={false}
|
||||
className="text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="flex-shrink-0 w-6 flex items-center justify-center">
|
||||
{relay.status === "pending" && (
|
||||
<Circle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{relay.status === "publishing" && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{relay.status === "success" && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
{relay.status === "error" && (
|
||||
<button
|
||||
onClick={() => retryRelay(relay.url)}
|
||||
disabled={isPublishing}
|
||||
className="p-0.5 rounded hover:bg-red-500/10 transition-colors"
|
||||
title={`${relay.error || "Failed to publish"}. Click to retry.`}
|
||||
>
|
||||
<X className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Add relay input */}
|
||||
{!showPublishedPreview && (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="relay.example.com"
|
||||
value={newRelayInput}
|
||||
onChange={(e) => setNewRelayInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && isValidRelayInput(newRelayInput)) {
|
||||
handleAddRelay();
|
||||
}
|
||||
}}
|
||||
disabled={isPublishing}
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={handleAddRelay}
|
||||
disabled={isPublishing || !isValidRelayInput(newRelayInput)}
|
||||
title="Add relay"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload dialog */}
|
||||
{uploadDialog}
|
||||
</div>
|
||||
</div>
|
||||
<Composer
|
||||
schema={NOTE_SCHEMA}
|
||||
windowId={windowId}
|
||||
onBuildEvent={handleBuildEvent}
|
||||
renderPreview={renderPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
746
src/components/composer/Composer.tsx
Normal file
746
src/components/composer/Composer.tsx
Normal file
@@ -0,0 +1,746 @@
|
||||
/**
|
||||
* Composer - Schema-driven event composition
|
||||
*
|
||||
* A generic composer component that adapts its UI based on the provided schema.
|
||||
* Supports different editor types, metadata fields, and relay strategies.
|
||||
*/
|
||||
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import {
|
||||
Paperclip,
|
||||
Send,
|
||||
Loader2,
|
||||
Settings,
|
||||
Server,
|
||||
ServerOff,
|
||||
Plus,
|
||||
Circle,
|
||||
Check,
|
||||
X,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { useProfileSearch } from "@/hooks/useProfileSearch";
|
||||
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
|
||||
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useRelaySelection } from "@/hooks/useRelaySelection";
|
||||
import { useEventPublisher } from "@/hooks/useEventPublisher";
|
||||
import {
|
||||
TextEditor,
|
||||
type TextEditorHandle,
|
||||
} from "@/components/editor/TextEditor";
|
||||
import {
|
||||
MarkdownEditor,
|
||||
type MarkdownEditorHandle,
|
||||
} from "@/components/editor/MarkdownEditor";
|
||||
import type { BlobAttachment, EmojiTag } from "@/components/editor/core/types";
|
||||
import { RelayLink } from "@/components/nostr/RelayLink";
|
||||
import { getAuthIcon } from "@/lib/relay-status-utils";
|
||||
import type { ComposerSchema, ComposerContext } from "@/lib/composer/schema";
|
||||
|
||||
// Generic editor handle type
|
||||
type EditorHandle = TextEditorHandle | MarkdownEditorHandle;
|
||||
|
||||
export interface ComposerProps {
|
||||
/** Schema defining how to compose this event kind */
|
||||
schema: ComposerSchema;
|
||||
/** Context for the composition (reply target, group, etc.) */
|
||||
context?: ComposerContext;
|
||||
/** Called when event is created and ready to sign */
|
||||
onBuildEvent: (input: ComposerInput) => Promise<NostrEvent>;
|
||||
/** Called after successful publish */
|
||||
onPublished?: (event: NostrEvent) => void;
|
||||
/** Render published event preview */
|
||||
renderPreview?: (event: NostrEvent) => React.ReactNode;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
/** Window ID for draft storage */
|
||||
windowId?: string;
|
||||
}
|
||||
|
||||
export interface ComposerInput {
|
||||
content: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
image?: string;
|
||||
labels?: string[];
|
||||
emojiTags: EmojiTag[];
|
||||
blobAttachments: BlobAttachment[];
|
||||
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>;
|
||||
}
|
||||
|
||||
export interface ComposerHandle {
|
||||
/** Focus the editor */
|
||||
focus: () => void;
|
||||
/** Clear the editor */
|
||||
clear: () => void;
|
||||
/** Get current content */
|
||||
getContent: () => string;
|
||||
/** Check if editor is empty */
|
||||
isEmpty: () => boolean;
|
||||
}
|
||||
|
||||
export const Composer = forwardRef<ComposerHandle, ComposerProps>(
|
||||
(
|
||||
{
|
||||
schema,
|
||||
context,
|
||||
onBuildEvent,
|
||||
onPublished,
|
||||
renderPreview,
|
||||
className = "",
|
||||
windowId,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { pubkey, canSign } = useAccount();
|
||||
const { searchProfiles } = useProfileSearch();
|
||||
const { searchEmojis } = useEmojiSearch();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
|
||||
// Editor ref
|
||||
const editorRef = useRef<EditorHandle>(null);
|
||||
|
||||
// Metadata state
|
||||
const [title, setTitle] = useState("");
|
||||
const [summary, setSummary] = useState("");
|
||||
const [image, setImage] = useState("");
|
||||
const [labels, setLabels] = useState<string[]>([]);
|
||||
|
||||
// UI state
|
||||
const [isEditorEmpty, setIsEditorEmpty] = useState(true);
|
||||
const [showPublishedPreview, setShowPublishedPreview] = useState(false);
|
||||
const [newRelayInput, setNewRelayInput] = useState("");
|
||||
|
||||
// Relay selection hook
|
||||
const relaySelection = useRelaySelection({
|
||||
strategy: schema.relays,
|
||||
contextRelay: context?.groupRelay,
|
||||
});
|
||||
|
||||
// Event publisher hook
|
||||
const publisher = useEventPublisher();
|
||||
|
||||
// Expose handle methods
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => editorRef.current?.focus(),
|
||||
clear: () => {
|
||||
editorRef.current?.clear();
|
||||
setTitle("");
|
||||
setSummary("");
|
||||
setImage("");
|
||||
setLabels([]);
|
||||
},
|
||||
getContent: () => editorRef.current?.getContent() || "",
|
||||
isEmpty: () => editorRef.current?.isEmpty() ?? true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Draft storage key
|
||||
const getDraftKey = useCallback(() => {
|
||||
if (!schema.drafts.supported || !schema.drafts.storageKey) return null;
|
||||
return schema.drafts.storageKey({
|
||||
...context,
|
||||
windowId,
|
||||
});
|
||||
}, [schema.drafts, context, windowId]);
|
||||
|
||||
// Track if draft has been loaded
|
||||
const draftLoadedRef = useRef(false);
|
||||
|
||||
// Load draft from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (!pubkey || draftLoadedRef.current) return;
|
||||
|
||||
const draftKey = getDraftKey();
|
||||
if (!draftKey) {
|
||||
draftLoadedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const savedDraft = localStorage.getItem(draftKey);
|
||||
if (savedDraft) {
|
||||
try {
|
||||
const draft = JSON.parse(savedDraft);
|
||||
draftLoadedRef.current = true;
|
||||
|
||||
// Restore editor content with retry logic
|
||||
if (draft.editorState) {
|
||||
const trySetContent = (attempts = 0) => {
|
||||
if (editorRef.current && "setContent" in editorRef.current) {
|
||||
(editorRef.current as TextEditorHandle).setContent(
|
||||
draft.editorState,
|
||||
);
|
||||
} else if (attempts < 10) {
|
||||
setTimeout(() => trySetContent(attempts + 1), 50);
|
||||
}
|
||||
};
|
||||
setTimeout(() => trySetContent(), 50);
|
||||
}
|
||||
|
||||
// Restore metadata
|
||||
if (draft.title) setTitle(draft.title);
|
||||
if (draft.summary) setSummary(draft.summary);
|
||||
if (draft.image) setImage(draft.image);
|
||||
if (draft.labels) setLabels(draft.labels);
|
||||
|
||||
// Restore relays
|
||||
if (draft.selectedRelays && draft.addedRelays) {
|
||||
relaySelection.restoreRelayStates(
|
||||
draft.selectedRelays,
|
||||
draft.addedRelays,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load draft:", err);
|
||||
}
|
||||
} else {
|
||||
draftLoadedRef.current = true;
|
||||
}
|
||||
}, [pubkey, getDraftKey, relaySelection]);
|
||||
|
||||
// Save draft to localStorage
|
||||
const saveDraft = useCallback(() => {
|
||||
if (!pubkey || !editorRef.current) return;
|
||||
|
||||
const draftKey = getDraftKey();
|
||||
if (!draftKey) return;
|
||||
|
||||
const content = editorRef.current.getContent();
|
||||
|
||||
if (!content.trim() && !title && !summary) {
|
||||
localStorage.removeItem(draftKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const draft = {
|
||||
editorState:
|
||||
"getJSON" in editorRef.current
|
||||
? (editorRef.current as TextEditorHandle).getJSON()
|
||||
: undefined,
|
||||
title,
|
||||
summary,
|
||||
image,
|
||||
labels,
|
||||
selectedRelays: Array.from(relaySelection.selectedRelays),
|
||||
addedRelays: relaySelection.getAddedRelays(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(draftKey, JSON.stringify(draft));
|
||||
} catch (err) {
|
||||
console.error("Failed to save draft:", err);
|
||||
}
|
||||
}, [pubkey, getDraftKey, title, summary, image, labels, relaySelection]);
|
||||
|
||||
// Debounced draft save
|
||||
const draftSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleEditorChange = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
setIsEditorEmpty(editorRef.current.isEmpty());
|
||||
}
|
||||
|
||||
if (draftSaveTimeoutRef.current) {
|
||||
clearTimeout(draftSaveTimeoutRef.current);
|
||||
}
|
||||
draftSaveTimeoutRef.current = setTimeout(() => {
|
||||
saveDraft();
|
||||
}, 500);
|
||||
}, [saveDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (draftSaveTimeoutRef.current) {
|
||||
clearTimeout(draftSaveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Blossom upload for attachments
|
||||
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
|
||||
accept: "image/*,video/*,audio/*",
|
||||
onSuccess: (results) => {
|
||||
if (results.length > 0 && editorRef.current) {
|
||||
const { blob, server } = results[0];
|
||||
editorRef.current.insertBlob({
|
||||
url: blob.url,
|
||||
sha256: blob.sha256,
|
||||
mimeType: blob.type,
|
||||
size: blob.size,
|
||||
server,
|
||||
});
|
||||
editorRef.current.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Handle publish
|
||||
const handlePublish = useCallback(
|
||||
async (
|
||||
content: string,
|
||||
emojiTags: EmojiTag[],
|
||||
blobAttachments: BlobAttachment[],
|
||||
addressRefs: Array<{
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
}>,
|
||||
) => {
|
||||
if (!canSign || !pubkey) {
|
||||
toast.error("Please log in to publish");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim() && schema.metadata.title?.required && !title) {
|
||||
toast.error("Please fill in required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedUrls = Array.from(relaySelection.selectedRelays);
|
||||
if (selectedUrls.length === 0) {
|
||||
toast.error("Please select at least one relay");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build the event using the provided callback
|
||||
const input: ComposerInput = {
|
||||
content: content.trim(),
|
||||
title: title || undefined,
|
||||
summary: summary || undefined,
|
||||
image: image || undefined,
|
||||
labels,
|
||||
emojiTags,
|
||||
blobAttachments,
|
||||
addressRefs,
|
||||
};
|
||||
|
||||
const event = await onBuildEvent(input);
|
||||
|
||||
// Publish with status tracking
|
||||
const result = await publisher.publishEvent(
|
||||
event,
|
||||
selectedUrls,
|
||||
relaySelection.updateRelayStatus,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// Clear draft
|
||||
const draftKey = getDraftKey();
|
||||
if (draftKey) {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
|
||||
// Clear editor and metadata
|
||||
editorRef.current?.clear();
|
||||
setTitle("");
|
||||
setSummary("");
|
||||
setImage("");
|
||||
setLabels([]);
|
||||
|
||||
// Show preview
|
||||
setShowPublishedPreview(true);
|
||||
|
||||
// Notify parent
|
||||
onPublished?.(event);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create/publish event:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to publish",
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
canSign,
|
||||
pubkey,
|
||||
schema.metadata.title?.required,
|
||||
title,
|
||||
summary,
|
||||
image,
|
||||
labels,
|
||||
relaySelection,
|
||||
onBuildEvent,
|
||||
publisher,
|
||||
getDraftKey,
|
||||
onPublished,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle file paste
|
||||
const handleFilePaste = useCallback(
|
||||
(files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
openUpload();
|
||||
}
|
||||
},
|
||||
[openUpload],
|
||||
);
|
||||
|
||||
// Reset to compose another
|
||||
const handleReset = useCallback(() => {
|
||||
setShowPublishedPreview(false);
|
||||
publisher.clearLastEvent();
|
||||
relaySelection.resetRelayStates();
|
||||
editorRef.current?.clear();
|
||||
setTitle("");
|
||||
setSummary("");
|
||||
setImage("");
|
||||
setLabels([]);
|
||||
editorRef.current?.focus();
|
||||
}, [publisher, relaySelection]);
|
||||
|
||||
// Discard draft
|
||||
const handleDiscard = useCallback(() => {
|
||||
editorRef.current?.clear();
|
||||
setTitle("");
|
||||
setSummary("");
|
||||
setImage("");
|
||||
setLabels([]);
|
||||
const draftKey = getDraftKey();
|
||||
if (draftKey) {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
editorRef.current?.focus();
|
||||
}, [getDraftKey]);
|
||||
|
||||
// Add relay
|
||||
const handleAddRelay = useCallback(() => {
|
||||
const success = relaySelection.addRelay(newRelayInput);
|
||||
if (success) {
|
||||
setNewRelayInput("");
|
||||
}
|
||||
}, [newRelayInput, relaySelection]);
|
||||
|
||||
// Handle relay retry
|
||||
const handleRetryRelay = useCallback(
|
||||
(relayUrl: string) => {
|
||||
publisher.retryRelay(relayUrl, relaySelection.updateRelayStatus);
|
||||
},
|
||||
[publisher, relaySelection],
|
||||
);
|
||||
|
||||
// Show login prompt if not logged in
|
||||
if (!canSign) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center p-8">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
You need to be logged in to {schema.name.toLowerCase()}.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click the user icon in the top right to log in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine which editor to render
|
||||
const renderEditor = () => {
|
||||
const editorProps = {
|
||||
placeholder:
|
||||
schema.content.placeholder ||
|
||||
`Write your ${schema.name.toLowerCase()}...`,
|
||||
onSubmit: handlePublish,
|
||||
onChange: handleEditorChange,
|
||||
searchProfiles,
|
||||
searchEmojis,
|
||||
onFilePaste: handleFilePaste,
|
||||
autoFocus: true,
|
||||
minHeight: schema.content.editor === "markdown" ? 200 : 150,
|
||||
maxHeight: schema.content.editor === "markdown" ? 600 : 400,
|
||||
};
|
||||
|
||||
if (schema.content.editor === "markdown") {
|
||||
return (
|
||||
<MarkdownEditor
|
||||
ref={editorRef as React.Ref<MarkdownEditorHandle>}
|
||||
{...editorProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextEditor
|
||||
ref={editorRef as React.Ref<TextEditorHandle>}
|
||||
{...editorProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Render title field if configured
|
||||
const renderTitleField = () => {
|
||||
if (!schema.metadata.title) return null;
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={
|
||||
schema.metadata.title.placeholder ||
|
||||
`${schema.metadata.title.label}...`
|
||||
}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={publisher.isPublishing || showPublishedPreview}
|
||||
className="text-lg font-medium"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Check if form is empty
|
||||
const isFormEmpty = isEditorEmpty && !title && !summary && !image;
|
||||
|
||||
return (
|
||||
<div className={`h-full overflow-y-auto ${className}`}>
|
||||
<div className="max-w-2xl mx-auto space-y-4 p-4">
|
||||
{!showPublishedPreview ? (
|
||||
<>
|
||||
{/* Title field */}
|
||||
{renderTitleField()}
|
||||
|
||||
{/* Editor */}
|
||||
<div>{renderEditor()}</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Upload button */}
|
||||
{schema.media.allowed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => openUpload()}
|
||||
disabled={publisher.isPublishing}
|
||||
title="Upload image/video"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Settings dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={publisher.isPublishing}
|
||||
title="Post settings"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={settings?.post?.includeClientTag ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting("post", "includeClientTag", checked)
|
||||
}
|
||||
>
|
||||
Include client tag
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Discard button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDiscard}
|
||||
disabled={publisher.isPublishing || isFormEmpty}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
|
||||
{/* Publish button */}
|
||||
<Button
|
||||
onClick={() => editorRef.current?.submit()}
|
||||
disabled={
|
||||
publisher.isPublishing ||
|
||||
relaySelection.selectedRelays.size === 0 ||
|
||||
isFormEmpty
|
||||
}
|
||||
className="gap-2 w-32"
|
||||
>
|
||||
{publisher.isPublishing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4" />
|
||||
Publish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Published event preview */}
|
||||
{publisher.lastPublishedEvent &&
|
||||
renderPreview?.(publisher.lastPublishedEvent)}
|
||||
|
||||
{/* Reset button */}
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="gap-2"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Compose Another {schema.name}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Relay selection */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Relays ({relaySelection.selectedRelays.size} selected)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{relaySelection.relayStates.map((relay) => {
|
||||
const isConnected = relaySelection.getConnectionStatus(
|
||||
relay.url,
|
||||
);
|
||||
const relayState = relaySelection.getRelayAuthState(relay.url);
|
||||
const authIcon = getAuthIcon(relayState);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={relay.url}
|
||||
className="flex items-center justify-between gap-3 py-1"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Checkbox
|
||||
id={relay.url}
|
||||
checked={relaySelection.selectedRelays.has(relay.url)}
|
||||
onCheckedChange={() =>
|
||||
relaySelection.toggleRelay(relay.url)
|
||||
}
|
||||
disabled={
|
||||
publisher.isPublishing || showPublishedPreview
|
||||
}
|
||||
/>
|
||||
{isConnected ? (
|
||||
<Server className="h-3 w-3 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<ServerOff className="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-shrink-0" title={authIcon.label}>
|
||||
{authIcon.icon}
|
||||
</div>
|
||||
<label
|
||||
htmlFor={relay.url}
|
||||
className="cursor-pointer truncate flex-1"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<RelayLink
|
||||
url={relay.url}
|
||||
write={true}
|
||||
showInboxOutbox={false}
|
||||
className="text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 w-6 flex items-center justify-center">
|
||||
{relay.status === "pending" && (
|
||||
<Circle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{relay.status === "publishing" && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{relay.status === "success" && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
{relay.status === "error" && (
|
||||
<button
|
||||
onClick={() => handleRetryRelay(relay.url)}
|
||||
disabled={publisher.isPublishing}
|
||||
className="p-0.5 rounded hover:bg-red-500/10 transition-colors"
|
||||
title={`${relay.error || "Failed to publish"}. Click to retry.`}
|
||||
>
|
||||
<X className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Add relay input */}
|
||||
{!showPublishedPreview && (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="relay.example.com"
|
||||
value={newRelayInput}
|
||||
onChange={(e) => setNewRelayInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
relaySelection.isValidRelayInput(newRelayInput)
|
||||
) {
|
||||
handleAddRelay();
|
||||
}
|
||||
}}
|
||||
disabled={publisher.isPublishing}
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={handleAddRelay}
|
||||
disabled={
|
||||
publisher.isPublishing ||
|
||||
!relaySelection.isValidRelayInput(newRelayInput)
|
||||
}
|
||||
title="Add relay"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload dialog */}
|
||||
{uploadDialog}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Composer.displayName = "Composer";
|
||||
10
src/components/composer/index.ts
Normal file
10
src/components/composer/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Composer components
|
||||
*/
|
||||
|
||||
export {
|
||||
Composer,
|
||||
type ComposerProps,
|
||||
type ComposerHandle,
|
||||
type ComposerInput,
|
||||
} from "./Composer";
|
||||
187
src/hooks/useEventPublisher.ts
Normal file
187
src/hooks/useEventPublisher.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* useEventPublisher - Event publishing hook
|
||||
*
|
||||
* Handles event signing and publishing with per-relay status tracking.
|
||||
* Works with useRelaySelection for relay management.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import pool from "@/services/relay-pool";
|
||||
import eventStore from "@/services/event-store";
|
||||
import type { RelayStatus } from "@/hooks/useRelaySelection";
|
||||
|
||||
export interface PublishResult {
|
||||
success: boolean;
|
||||
successCount: number;
|
||||
totalCount: number;
|
||||
event: NostrEvent | null;
|
||||
}
|
||||
|
||||
export interface UseEventPublisherResult {
|
||||
/** Whether currently publishing */
|
||||
isPublishing: boolean;
|
||||
/** Last published event (for retries and preview) */
|
||||
lastPublishedEvent: NostrEvent | null;
|
||||
/** Publish a signed event to relays */
|
||||
publishEvent: (
|
||||
event: NostrEvent,
|
||||
relayUrls: string[],
|
||||
onRelayStatus: (url: string, status: RelayStatus, error?: string) => void,
|
||||
) => Promise<PublishResult>;
|
||||
/** Retry publishing to a specific relay */
|
||||
retryRelay: (
|
||||
relayUrl: string,
|
||||
onRelayStatus: (url: string, status: RelayStatus, error?: string) => void,
|
||||
) => Promise<boolean>;
|
||||
/** Clear the last published event */
|
||||
clearLastEvent: () => void;
|
||||
}
|
||||
|
||||
export function useEventPublisher(): UseEventPublisherResult {
|
||||
const { canSign } = useAccount();
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [lastPublishedEvent, setLastPublishedEvent] =
|
||||
useState<NostrEvent | null>(null);
|
||||
|
||||
// Use ref to track the event for retry without stale closure
|
||||
const lastEventRef = useRef<NostrEvent | null>(null);
|
||||
|
||||
// Publish a signed event to relays
|
||||
const publishEvent = useCallback(
|
||||
async (
|
||||
event: NostrEvent,
|
||||
relayUrls: string[],
|
||||
onRelayStatus: (url: string, status: RelayStatus, error?: string) => void,
|
||||
): Promise<PublishResult> => {
|
||||
if (!canSign) {
|
||||
toast.error("Please log in to publish");
|
||||
return { success: false, successCount: 0, totalCount: 0, event: null };
|
||||
}
|
||||
|
||||
if (relayUrls.length === 0) {
|
||||
toast.error("Please select at least one relay");
|
||||
return { success: false, successCount: 0, totalCount: 0, event: null };
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
|
||||
// Store the signed event for potential retries
|
||||
setLastPublishedEvent(event);
|
||||
lastEventRef.current = event;
|
||||
|
||||
// Update relay states - set all to publishing
|
||||
for (const url of relayUrls) {
|
||||
onRelayStatus(url, "publishing");
|
||||
}
|
||||
|
||||
try {
|
||||
// Publish to each relay individually to track status
|
||||
const publishPromises = relayUrls.map(async (relayUrl) => {
|
||||
try {
|
||||
await pool.publish([relayUrl], event);
|
||||
onRelayStatus(relayUrl, "success");
|
||||
return { success: true, relayUrl };
|
||||
} catch (error) {
|
||||
console.error(`Failed to publish to ${relayUrl}:`, error);
|
||||
onRelayStatus(
|
||||
relayUrl,
|
||||
"error",
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
);
|
||||
return { success: false, relayUrl };
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all publishes to complete
|
||||
const results = await Promise.allSettled(publishPromises);
|
||||
|
||||
// Count successes
|
||||
const successCount = results.filter(
|
||||
(r) => r.status === "fulfilled" && r.value.success,
|
||||
).length;
|
||||
|
||||
if (successCount > 0) {
|
||||
// At least one relay succeeded - add to event store
|
||||
eventStore.add(event);
|
||||
|
||||
// Show success toast
|
||||
if (successCount === relayUrls.length) {
|
||||
toast.success(
|
||||
`Published to all ${relayUrls.length} relay${relayUrls.length > 1 ? "s" : ""}`,
|
||||
);
|
||||
} else {
|
||||
toast.warning(
|
||||
`Published to ${successCount} of ${relayUrls.length} relays`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// All relays failed
|
||||
toast.error(
|
||||
"Failed to publish to any relay. Please check your relay connections and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: successCount > 0,
|
||||
successCount,
|
||||
totalCount: relayUrls.length,
|
||||
event,
|
||||
};
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
},
|
||||
[canSign],
|
||||
);
|
||||
|
||||
// Retry publishing to a specific relay
|
||||
const retryRelay = useCallback(
|
||||
async (
|
||||
relayUrl: string,
|
||||
onRelayStatus: (url: string, status: RelayStatus, error?: string) => void,
|
||||
): Promise<boolean> => {
|
||||
const event = lastEventRef.current;
|
||||
if (!event) {
|
||||
toast.error("No event to retry");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
onRelayStatus(relayUrl, "publishing");
|
||||
await pool.publish([relayUrl], event);
|
||||
onRelayStatus(relayUrl, "success");
|
||||
toast.success(`Published to ${relayUrl.replace(/^wss?:\/\//, "")}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to retry publish to ${relayUrl}:`, error);
|
||||
onRelayStatus(
|
||||
relayUrl,
|
||||
"error",
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
);
|
||||
toast.error(
|
||||
`Failed to publish to ${relayUrl.replace(/^wss?:\/\//, "")}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Clear the last published event
|
||||
const clearLastEvent = useCallback(() => {
|
||||
setLastPublishedEvent(null);
|
||||
lastEventRef.current = null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isPublishing,
|
||||
lastPublishedEvent,
|
||||
publishEvent,
|
||||
retryRelay,
|
||||
clearLastEvent,
|
||||
};
|
||||
}
|
||||
281
src/hooks/useRelaySelection.ts
Normal file
281
src/hooks/useRelaySelection.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* useRelaySelection - Relay selection hook for publishing
|
||||
*
|
||||
* Handles relay list management, selection state, and status tracking
|
||||
* for publishing events to multiple relays.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { AGGREGATOR_RELAYS } from "@/services/loaders";
|
||||
import { normalizeRelayURL } from "@/lib/relay-url";
|
||||
import pool from "@/services/relay-pool";
|
||||
import type { RelayStrategy } from "@/lib/composer/schema";
|
||||
|
||||
// Per-relay publish status
|
||||
export type RelayStatus = "pending" | "publishing" | "success" | "error";
|
||||
|
||||
export interface RelayPublishState {
|
||||
url: string;
|
||||
status: RelayStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RelaySelectionOptions {
|
||||
/** Relay strategy from schema */
|
||||
strategy?: RelayStrategy;
|
||||
/** Address hints for address-bound events */
|
||||
addressHints?: string[];
|
||||
/** Context relay (for groups) */
|
||||
contextRelay?: string;
|
||||
}
|
||||
|
||||
export interface UseRelaySelectionResult {
|
||||
/** Current relay states */
|
||||
relayStates: RelayPublishState[];
|
||||
/** Set of selected relay URLs */
|
||||
selectedRelays: Set<string>;
|
||||
/** Toggle a relay's selection */
|
||||
toggleRelay: (url: string) => void;
|
||||
/** Add a new relay to the list */
|
||||
addRelay: (url: string) => boolean;
|
||||
/** Check if input looks like a valid relay URL */
|
||||
isValidRelayInput: (input: string) => boolean;
|
||||
/** Reset relay states to pending */
|
||||
resetRelayStates: () => void;
|
||||
/** Update status for a specific relay */
|
||||
updateRelayStatus: (url: string, status: RelayStatus, error?: string) => void;
|
||||
/** Set all selected relays to publishing */
|
||||
setPublishing: () => void;
|
||||
/** Get relay pool connection status */
|
||||
getConnectionStatus: (url: string) => boolean;
|
||||
/** Get relay auth state */
|
||||
getRelayAuthState: (
|
||||
url: string,
|
||||
) => ReturnType<typeof useRelayState>["getRelay"] extends (
|
||||
url: string,
|
||||
) => infer R
|
||||
? R
|
||||
: never;
|
||||
/** User's write relays (source list) */
|
||||
writeRelays: string[];
|
||||
/** Get added relays (not in writeRelays) */
|
||||
getAddedRelays: () => string[];
|
||||
/** Restore relay states (for draft loading) */
|
||||
restoreRelayStates: (selectedUrls: string[], addedUrls: string[]) => void;
|
||||
}
|
||||
|
||||
export function useRelaySelection(
|
||||
options: RelaySelectionOptions = {},
|
||||
): UseRelaySelectionResult {
|
||||
const { state } = useGrimoire();
|
||||
const { getRelay } = useRelayState();
|
||||
|
||||
// Get relay pool state for connection status
|
||||
const relayPoolMap = use$(pool.relays$);
|
||||
|
||||
// Determine write relays based on strategy
|
||||
const writeRelays = useMemo(() => {
|
||||
const { strategy, addressHints, contextRelay } = options;
|
||||
|
||||
// Context-only strategy uses only the context relay
|
||||
if (strategy?.type === "context-only" && contextRelay) {
|
||||
return [contextRelay];
|
||||
}
|
||||
|
||||
// Get user's write relays from account
|
||||
const userWriteRelays =
|
||||
state.activeAccount?.relays?.filter((r) => r.write).map((r) => r.url) ||
|
||||
[];
|
||||
|
||||
// Address-hints strategy: use hints with user outbox fallback
|
||||
if (strategy?.type === "address-hints" && addressHints?.length) {
|
||||
return addressHints.length > 0 ? addressHints : userWriteRelays;
|
||||
}
|
||||
|
||||
// Default: user-outbox or aggregator fallback
|
||||
return userWriteRelays.length > 0 ? userWriteRelays : AGGREGATOR_RELAYS;
|
||||
}, [state.activeAccount?.relays, options]);
|
||||
|
||||
// Relay states
|
||||
const [relayStates, setRelayStates] = useState<RelayPublishState[]>([]);
|
||||
const [selectedRelays, setSelectedRelays] = useState<Set<string>>(new Set());
|
||||
|
||||
// Initialize/update relay states when write relays change
|
||||
useEffect(() => {
|
||||
if (writeRelays.length > 0) {
|
||||
setRelayStates(
|
||||
writeRelays.map((url) => ({
|
||||
url,
|
||||
status: "pending" as RelayStatus,
|
||||
})),
|
||||
);
|
||||
setSelectedRelays(new Set(writeRelays));
|
||||
}
|
||||
}, [writeRelays]);
|
||||
|
||||
// Toggle relay selection
|
||||
const toggleRelay = useCallback((url: string) => {
|
||||
setSelectedRelays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(url)) {
|
||||
next.delete(url);
|
||||
} else {
|
||||
next.add(url);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Check if input looks like a valid relay URL
|
||||
const isValidRelayInput = useCallback((input: string): boolean => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
// Allow relay URLs with or without protocol
|
||||
const urlPattern =
|
||||
/^(wss?:\/\/)?[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}(:[0-9]{1,5})?(\/.*)?$/;
|
||||
|
||||
return urlPattern.test(trimmed);
|
||||
}, []);
|
||||
|
||||
// Add a new relay to the list
|
||||
const addRelay = useCallback(
|
||||
(input: string): boolean => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || !isValidRelayInput(trimmed)) return false;
|
||||
|
||||
try {
|
||||
const normalizedUrl = normalizeRelayURL(trimmed);
|
||||
|
||||
// Check if already in list
|
||||
const alreadyExists = relayStates.some((r) => r.url === normalizedUrl);
|
||||
if (alreadyExists) {
|
||||
toast.error("Relay already in list");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add to relay states
|
||||
setRelayStates((prev) => [
|
||||
...prev,
|
||||
{ url: normalizedUrl, status: "pending" as RelayStatus },
|
||||
]);
|
||||
|
||||
// Select the new relay
|
||||
setSelectedRelays((prev) => new Set([...prev, normalizedUrl]));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to add relay:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Invalid relay URL",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[isValidRelayInput, relayStates],
|
||||
);
|
||||
|
||||
// Reset relay states to pending
|
||||
const resetRelayStates = useCallback(() => {
|
||||
setRelayStates(
|
||||
writeRelays.map((url) => ({
|
||||
url,
|
||||
status: "pending" as RelayStatus,
|
||||
})),
|
||||
);
|
||||
setSelectedRelays(new Set(writeRelays));
|
||||
}, [writeRelays]);
|
||||
|
||||
// Update status for a specific relay
|
||||
const updateRelayStatus = useCallback(
|
||||
(url: string, status: RelayStatus, error?: string) => {
|
||||
setRelayStates((prev) =>
|
||||
prev.map((r) =>
|
||||
r.url === url ? { ...r, status, error: error ?? r.error } : r,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Set all selected relays to publishing
|
||||
const setPublishing = useCallback(() => {
|
||||
const selected = Array.from(selectedRelays);
|
||||
setRelayStates((prev) =>
|
||||
prev.map((r) =>
|
||||
selected.includes(r.url)
|
||||
? { ...r, status: "publishing" as RelayStatus }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
}, [selectedRelays]);
|
||||
|
||||
// Get relay connection status
|
||||
const getConnectionStatus = useCallback(
|
||||
(url: string): boolean => {
|
||||
const poolRelay = relayPoolMap?.get(url);
|
||||
return poolRelay?.connected ?? false;
|
||||
},
|
||||
[relayPoolMap],
|
||||
);
|
||||
|
||||
// Get relay auth state
|
||||
const getRelayAuthState = useCallback(
|
||||
(url: string) => {
|
||||
return getRelay(url);
|
||||
},
|
||||
[getRelay],
|
||||
);
|
||||
|
||||
// Get added relays (not in writeRelays)
|
||||
const getAddedRelays = useCallback(() => {
|
||||
return relayStates
|
||||
.filter((r) => !writeRelays.includes(r.url))
|
||||
.map((r) => r.url);
|
||||
}, [relayStates, writeRelays]);
|
||||
|
||||
// Restore relay states (for draft loading)
|
||||
const restoreRelayStates = useCallback(
|
||||
(selectedUrls: string[], addedUrls: string[]) => {
|
||||
// Set selected relays
|
||||
if (selectedUrls.length > 0) {
|
||||
setSelectedRelays(new Set(selectedUrls));
|
||||
}
|
||||
|
||||
// Add custom relays that aren't in the current list
|
||||
if (addedUrls.length > 0) {
|
||||
setRelayStates((prev) => {
|
||||
const currentUrls = new Set(prev.map((r) => r.url));
|
||||
const newRelays = addedUrls
|
||||
.filter((url) => !currentUrls.has(url))
|
||||
.map((url) => ({
|
||||
url,
|
||||
status: "pending" as RelayStatus,
|
||||
}));
|
||||
return newRelays.length > 0 ? [...prev, ...newRelays] : prev;
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
relayStates,
|
||||
selectedRelays,
|
||||
toggleRelay,
|
||||
addRelay,
|
||||
isValidRelayInput,
|
||||
resetRelayStates,
|
||||
updateRelayStatus,
|
||||
setPublishing,
|
||||
getConnectionStatus,
|
||||
getRelayAuthState,
|
||||
writeRelays,
|
||||
getAddedRelays,
|
||||
restoreRelayStates,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user