feat(post): add global persistent settings and event JSON preview

Settings (persist in localStorage, global across all post windows):
- "Include client tag" - toggle whether to add client tag to events (default: true)
- "Show event JSON" - display copyable JSON preview of event (default: false)

Features:
- Settings stored in localStorage at "grimoire-post-settings"
- Settings persist across sessions and windows
- Event JSON preview shows unsigned event initially, updates to signed version after signing
- Preview displays "(Signed)" or "(Unsigned)" label
- Uses CopyableJsonViewer component for syntax highlighting and copy button
- Preview limited to max-h-64 with scrolling

Draft persistence improvements:
- Added relays now saved in draft state under "addedRelays" field
- On draft load, custom relays are restored to relay list
- Relays identified as "added" if not in user's write relay list

Technical details:
- Added PostSettings interface with includeClientTag and showEventJson fields
- Settings loaded on mount from localStorage, saved on change via useEffect
- updateSetting callback handles individual setting updates
- previewEvent state holds current event (unsigned or signed)
- setPreviewEvent called before and after signing in handlePublish
- Draft format: { editorState, selectedRelays, addedRelays, timestamp }
- Draft loading checks addedRelays and restores non-duplicate relays
This commit is contained in:
Claude
2026-01-21 09:45:22 +00:00
parent 2400ce9151
commit 3163c8e498

View File

@@ -30,6 +30,7 @@ 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 { CopyableJsonViewer } from "./JsonViewer";
import pool from "@/services/relay-pool";
import eventStore from "@/services/event-store";
import { EventFactory } from "applesauce-core/event-factory";
@@ -48,8 +49,19 @@ interface RelayPublishState {
error?: string;
}
// Draft persistence key
// Storage keys
const DRAFT_STORAGE_KEY = "grimoire-post-draft";
const SETTINGS_STORAGE_KEY = "grimoire-post-settings";
interface PostSettings {
includeClientTag: boolean;
showEventJson: boolean;
}
const DEFAULT_SETTINGS: PostSettings = {
includeClientTag: true,
showEventJson: false,
};
interface PostViewerProps {
windowId?: string;
@@ -72,12 +84,39 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
const [isEditorEmpty, setIsEditorEmpty] = useState(true);
const [lastPublishedEvent, setLastPublishedEvent] = useState<any>(null);
const [showPublishedPreview, setShowPublishedPreview] = useState(false);
const [includeClientTag, setIncludeClientTag] = useState(true);
const [newRelayInput, setNewRelayInput] = useState("");
const [previewEvent, setPreviewEvent] = useState<any>(null);
// Load settings from localStorage
const [settings, setSettings] = useState<PostSettings>(() => {
try {
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
return stored ? JSON.parse(stored) : DEFAULT_SETTINGS;
} catch {
return DEFAULT_SETTINGS;
}
});
// Get relay pool state for connection status
const relayPoolMap = use$(pool.relays$);
// Persist settings to localStorage when they change
useEffect(() => {
try {
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
} catch (err) {
console.error("Failed to save post settings:", err);
}
}, [settings]);
// Update a single setting
const updateSetting = useCallback(
<K extends keyof PostSettings>(key: K, value: PostSettings[K]) => {
setSettings((prev) => ({ ...prev, [key]: value }));
},
[],
);
// Get active account's write relays from Grimoire state, fallback to aggregators
const writeRelays = useMemo(() => {
if (!state.activeAccount?.relays) return AGGREGATOR_RELAYS;
@@ -132,11 +171,25 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
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)) {
const currentRelayUrls = new Set(relayStates.map((r) => r.url));
const newRelays = draft.addedRelays
.filter((url: string) => !currentRelayUrls.has(url))
.map((url: string) => ({
url,
status: "pending" as RelayStatus,
}));
if (newRelays.length > 0) {
setRelayStates((prev) => [...prev, ...newRelays]);
}
}
} catch (err) {
console.error("Failed to load draft:", err);
}
}
}, [pubkey, windowId]);
}, [pubkey, windowId, relayStates]);
// Save draft to localStorage on content change
const saveDraft = useCallback(() => {
@@ -155,9 +208,15 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
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(),
};
@@ -166,7 +225,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
} catch (err) {
console.error("Failed to save draft:", err);
}
}, [pubkey, windowId, selectedRelays]);
}, [pubkey, windowId, selectedRelays, relayStates, writeRelays]);
// Debounced draft save (save every 2 seconds of inactivity)
useEffect(() => {
@@ -317,7 +376,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
}
// Add client tag (if enabled)
if (includeClientTag) {
if (settings.includeClientTag) {
tags.push(["client", "grimoire"]);
}
@@ -347,8 +406,15 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
content: content.trim(),
tags,
});
// Set preview event (unsigned)
setPreviewEvent(draft);
const event = await factory.sign(draft);
// Update preview event (signed)
setPreviewEvent(event);
// Store the signed event for potential retries
setLastPublishedEvent(event);
@@ -429,7 +495,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
setIsPublishing(false);
}
},
[canSign, signer, pubkey, selectedRelays, includeClientTag],
[canSign, signer, pubkey, selectedRelays, settings],
);
// Handle file paste
@@ -573,11 +639,21 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuCheckboxItem
checked={includeClientTag}
onCheckedChange={setIncludeClientTag}
checked={settings.includeClientTag}
onCheckedChange={(checked) =>
updateSetting("includeClientTag", checked)
}
>
Include client tag
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={settings.showEventJson}
onCheckedChange={(checked) =>
updateSetting("showEventJson", checked)
}
>
Show event JSON
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -614,6 +690,20 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
)}
</Button>
</div>
{/* Event JSON Preview */}
{settings.showEventJson && previewEvent && (
<div className="rounded-lg border border-border overflow-hidden">
<div className="bg-muted/50 px-3 py-2 text-xs font-medium text-muted-foreground border-b">
Event JSON {previewEvent.sig ? "(Signed)" : "(Unsigned)"}
</div>
<div className="max-h-64">
<CopyableJsonViewer
json={JSON.stringify(previewEvent, null, 2)}
/>
</div>
</div>
)}
</>
) : (
<>