mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
* feat: centralize publish flow with RxJS-based PublishService Create a unified PublishService that: - Provides consistent relay selection (outbox + state + hints + fallbacks) - Emits RxJS observables for per-relay status updates - Handles EventStore integration automatically - Supports both fire-and-forget and observable-based publishing Refactor all publish locations to use the centralized service: - hub.ts: Use PublishService for ActionRunner publish - delete-event.ts: Use PublishService (fixes missing eventStore.add) - publish-spell.ts: Use PublishService with relay hint support - PostViewer.tsx: Use publishWithUpdates() for per-relay UI tracking This lays the groundwork for the event log feature by providing observable hooks into all publish operations. * feat: add LOG command for relay event introspection Add an ephemeral event log system that tracks relay operations: - EventLogService (src/services/event-log.ts): - Subscribes to PublishService for PUBLISH events with per-relay status - Monitors relay pool for CONNECT/DISCONNECT events - Tracks AUTH challenges and results - Captures NOTICE messages from relays - Uses RxJS BehaviorSubject for reactive updates - Circular buffer with configurable max entries (default 500) - useEventLog hook (src/hooks/useEventLog.ts): - React hook for filtering and accessing log entries - Filter by type, relay, or limit - Retry failed relays directly from the hook - EventLogViewer component (src/components/EventLogViewer.tsx): - Tab-based filtering (All/Publish/Connect/Auth/Notice) - Expandable PUBLISH entries showing per-relay status - Click to retry failed relays - Auto-scroll to new entries (pause on scroll) - Clear log button - LOG command accessible via Cmd+K palette * fix: prevent duplicate log entries and check relay OK response - EventLogService: Check for existing entry before creating new one when handling publish events (prevents duplicates from start/complete) - PublishService: Check response.ok from pool.publish() to detect relay rejections instead of assuming success on resolve - Update test mock to return proper publish response format * feat: keep relay selection in call site, compact logs * chore: cleanup * fix: make Timestamp component locale-aware via formatTimestamp Timestamp was hardcoded to "es" locale. Now uses formatTimestamp() from useLocale.ts for consistent locale-aware time formatting. Added Timestamp to CLAUDE.md shared components documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: improve event-log reliability, add ERROR type and per-relay timing Service improvements: - Fix notice$ duplicate logging with per-relay dedup tracking - Remove dead Array.isArray code path (notice$ emits strings) - Increase relay poll interval from 1s to 5s - Clean publishIdToEntryId map on terminal state, not just overflow - Immutable entry updates (spread instead of in-place mutation) - Extract NewEntry<T>/AddEntryInput helper types for clean addEntry signature - Clear lastNoticePerRelay on log clear New capabilities: - ERROR log type: subscribes to relay.error$ for connection failure reasons - RelayStatusEntry with updatedAt timestamp for per-relay response timing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: improve EventLogViewer with virtualization, timing, and error display - Virtualize log list with react-virtuoso for 500-entry buffer performance - Add ErrorEntry renderer for new ERROR log type (AlertTriangle icon) - Show per-relay response time (e.g. "142ms", "2.3s") in publish details - Make all entry types expandable (connect/disconnect now have details) - Show absolute timestamp in all expanded detail views - Group ERROR events under Connect tab filter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent duplicate PUBLISH log entries from completion event PublishService emits publish$ twice: once at start, once on completion. The eager publishIdToEntryId cleanup in handleStatusUpdate fired before the completion emission, causing handlePublishEvent to create a second entry. Removed eager cleanup — overflow eviction is sufficient. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
818 lines
26 KiB
TypeScript
818 lines
26 KiB
TypeScript
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";
|
|
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,
|
|
type BlobAttachment,
|
|
type EmojiTag,
|
|
} from "./editor/RichEditor";
|
|
import { RelayLink } from "./nostr/RelayLink";
|
|
import { Kind1Renderer } from "./nostr/kinds";
|
|
import pool from "@/services/relay-pool";
|
|
import publishService, {
|
|
type RelayPublishStatus,
|
|
} from "@/services/publish-service";
|
|
import { EventFactory } from "applesauce-core/event-factory";
|
|
import { NoteBlueprint } from "@/lib/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";
|
|
|
|
interface RelayPublishState {
|
|
url: string;
|
|
status: RelayPublishStatus;
|
|
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();
|
|
|
|
// 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 RelayPublishStatus,
|
|
})),
|
|
);
|
|
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 RelayPublishStatus,
|
|
}));
|
|
return newRelays.length > 0 ? [...prev, ...newRelays] : prev;
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load draft:", err);
|
|
}
|
|
} else {
|
|
draftLoadedRef.current = true;
|
|
}
|
|
}, [pubkey, windowId]);
|
|
|
|
// Save draft to localStorage on content change
|
|
const saveDraft = useCallback(() => {
|
|
if (!pubkey || !editorRef.current) return;
|
|
|
|
const content = editorRef.current.getContent();
|
|
const editorState = editorRef.current.getJSON();
|
|
|
|
const draftKey = windowId
|
|
? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}`
|
|
: `${DRAFT_STORAGE_KEY}-${pubkey}`;
|
|
|
|
if (!content.trim()) {
|
|
// Clear draft if empty
|
|
localStorage.removeItem(draftKey);
|
|
return;
|
|
}
|
|
|
|
// Identify added relays (those not in writeRelays)
|
|
const addedRelays = relayStates
|
|
.filter((r) => !writeRelays.includes(r.url))
|
|
.map((r) => r.url);
|
|
|
|
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(),
|
|
};
|
|
|
|
try {
|
|
localStorage.setItem(draftKey, JSON.stringify(draft));
|
|
} catch (err) {
|
|
console.error("Failed to save draft:", err);
|
|
}
|
|
}, [pubkey, windowId, selectedRelays, relayStates, writeRelays]);
|
|
|
|
// Debounced draft save on editor changes
|
|
const draftSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
null,
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
// Update status to publishing
|
|
setRelayStates((prev) =>
|
|
prev.map((r) =>
|
|
r.url === relayUrl
|
|
? { ...r, status: "publishing" as RelayPublishStatus }
|
|
: r,
|
|
),
|
|
);
|
|
|
|
// Retry via PublishService (skipEventStore since it's already in store)
|
|
const result = await publishService.retryRelays(lastPublishedEvent, [
|
|
relayUrl,
|
|
]);
|
|
|
|
if (result.ok) {
|
|
setRelayStates((prev) =>
|
|
prev.map((r) =>
|
|
r.url === relayUrl
|
|
? {
|
|
...r,
|
|
status: "success" as RelayPublishStatus,
|
|
error: undefined,
|
|
}
|
|
: r,
|
|
),
|
|
);
|
|
toast.success(`Published to ${relayUrl.replace(/^wss?:\/\//, "")}`);
|
|
} else {
|
|
const error = result.failed[0]?.error || "Unknown error";
|
|
setRelayStates((prev) =>
|
|
prev.map((r) =>
|
|
r.url === relayUrl
|
|
? {
|
|
...r,
|
|
status: "error" as RelayPublishStatus,
|
|
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,
|
|
address: e.address,
|
|
})),
|
|
});
|
|
|
|
// 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
|
|
// Store the signed event for potential retries
|
|
setLastPublishedEvent(event);
|
|
|
|
// Use PublishService with status updates
|
|
const { updates$, result } = publishService.publishWithUpdates(
|
|
event,
|
|
selected,
|
|
);
|
|
|
|
// Subscribe to per-relay status updates for UI
|
|
const subscription = updates$.subscribe((update) => {
|
|
setRelayStates((prev) =>
|
|
prev.map((r) =>
|
|
r.url === update.relay
|
|
? {
|
|
...r,
|
|
status: update.status,
|
|
error: update.error,
|
|
}
|
|
: r,
|
|
),
|
|
);
|
|
});
|
|
|
|
try {
|
|
// Wait for publish to complete
|
|
const publishResult = await result;
|
|
|
|
// Unsubscribe from updates
|
|
subscription.unsubscribe();
|
|
|
|
const successCount = publishResult.successful.length;
|
|
|
|
if (publishResult.ok) {
|
|
// 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) {
|
|
subscription.unsubscribe();
|
|
console.error("Failed to publish:", error);
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Failed to publish note",
|
|
);
|
|
|
|
// Reset relay states to error on publishing error
|
|
setRelayStates((prev) =>
|
|
prev.map((r) => ({
|
|
...r,
|
|
status: "error" as RelayPublishStatus,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
})),
|
|
);
|
|
} finally {
|
|
setIsPublishing(false);
|
|
}
|
|
},
|
|
[canSign, signer, pubkey, selectedRelays, settings, windowId],
|
|
);
|
|
|
|
// 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 RelayPublishStatus },
|
|
]);
|
|
|
|
// 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) {
|
|
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>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|