diff --git a/src/components/DebugViewer.tsx b/src/components/DebugViewer.tsx
index f93e6f8..2df35d4 100644
--- a/src/components/DebugViewer.tsx
+++ b/src/components/DebugViewer.tsx
@@ -1,6 +1,7 @@
import { useGrimoire } from "@/core/state";
-import { Copy, Check } from "lucide-react";
import { useCopy } from "@/hooks/useCopy";
+import { SyntaxHighlight } from "@/components/SyntaxHighlight";
+import { CodeCopyButton } from "@/components/CodeCopyButton";
export function DebugViewer() {
const { state } = useGrimoire();
@@ -8,32 +9,26 @@ export function DebugViewer() {
const stateJson = JSON.stringify(state, null, 2);
+ const handleCopy = () => {
+ copy(stateJson);
+ };
+
return (
Application State
-
-
);
diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx
index eea9b0e..1e7f99e 100644
--- a/src/components/ReqViewer.tsx
+++ b/src/components/ReqViewer.tsx
@@ -55,7 +55,7 @@ import {
AccordionTrigger,
} from "./ui/accordion";
import { RelayLink } from "./nostr/RelayLink";
-import type { NostrFilter, NostrEvent } from "@/types/nostr";
+import type { NostrFilter } from "@/types/nostr";
import {
formatEventIds,
formatDTags,
@@ -678,8 +678,10 @@ export default function ReqViewer({
// Memoize fallbackRelays to prevent re-creation on every render
const fallbackRelays = useMemo(
() =>
- state.activeAccount?.relays?.inbox.map((r) => r.url) || AGGREGATOR_RELAYS,
- [state.activeAccount?.relays?.inbox],
+ state.activeAccount?.relays
+ ?.filter((r) => r.read)
+ .map((r) => r.url) || AGGREGATOR_RELAYS,
+ [state.activeAccount?.relays],
);
// Memoize outbox options to prevent object re-creation
@@ -749,34 +751,38 @@ export default function ReqViewer({
// Virtuoso scroll position preservation for prepending events
const STARTING_INDEX = 100000;
const [firstItemIndex, setFirstItemIndex] = useState(STARTING_INDEX);
- const prevEventsRef = useRef
([]);
+ const seenEventIdsRef = useRef>(new Set());
// Adjust firstItemIndex when new events are prepended to preserve scroll position
+ // Uses Set-based tracking to handle rapid batches correctly
useEffect(() => {
- const prevEvents = prevEventsRef.current;
- const currentEvents = events;
- const prevLength = prevEvents.length;
- const currentLength = currentEvents.length;
-
- // Reset on clear/query change (events array shrunk)
- if (currentLength < prevLength) {
+ // Reset on query change (events cleared)
+ if (events.length === 0) {
+ seenEventIdsRef.current = new Set();
setFirstItemIndex(STARTING_INDEX);
- prevEventsRef.current = currentEvents;
return;
}
- // Detect new events prepended (only in streaming mode after EOSE)
- const newEventsCount = currentLength - prevLength;
- if (newEventsCount > 0 && prevLength > 0 && stream && eoseReceived) {
- // Verify first event changed (events were prepended, not inserted in middle)
- const firstIdChanged = currentEvents[0]?.id !== prevEvents[0]?.id;
- if (firstIdChanged) {
- // Decrement firstItemIndex to maintain scroll position
- setFirstItemIndex((prev) => prev - newEventsCount);
+ // Find new events at the start of the array (prepended)
+ // This approach is immune to rapid updates because we track ALL seen IDs cumulatively
+ let prependCount = 0;
+ for (let i = 0; i < events.length; i++) {
+ const event = events[i];
+ if (!seenEventIdsRef.current.has(event.id)) {
+ // New event found at position i
+ prependCount++;
+ seenEventIdsRef.current.add(event.id);
+ } else {
+ // Found first existing event, stop counting
+ // All events after this are old (already seen)
+ break;
}
}
- prevEventsRef.current = currentEvents;
+ // Adjust index only in streaming mode after EOSE
+ if (prependCount > 0 && stream && eoseReceived) {
+ setFirstItemIndex((prev) => prev - prependCount);
+ }
}, [events, stream, eoseReceived]);
/**
diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx
index 3fc7f56..7edff47 100644
--- a/src/components/nostr/user-menu.tsx
+++ b/src/components/nostr/user-menu.tsx
@@ -107,14 +107,14 @@ export default function UserMenu() {
- {relays && relays.all.length > 0 && (
+ {relays && relays.length > 0 && (
<>
Relays
- {relays.all.map((relay) => (
+ {relays.map((relay) => (
{
if (!state.activeAccount) {
return state;
diff --git a/src/core/state.ts b/src/core/state.ts
index 4936a6d..96ba8c2 100644
--- a/src/core/state.ts
+++ b/src/core/state.ts
@@ -6,6 +6,7 @@ import {
AppId,
WindowInstance,
LayoutConfig,
+ RelayInfo,
} from "@/types/app";
import { useLocale } from "@/hooks/useLocale";
import * as Logic from "./logic";
@@ -255,7 +256,7 @@ export const useGrimoire = () => {
);
const setActiveAccountRelays = useCallback(
- (relays: any) =>
+ (relays: RelayInfo[]) =>
setState((prev) => Logic.setActiveAccountRelays(prev, relays)),
[setState],
);
diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts
index c101252..84dd6bc 100644
--- a/src/hooks/useAccountSync.ts
+++ b/src/hooks/useAccountSync.ts
@@ -2,9 +2,8 @@ import { useEffect } from "react";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import accounts from "@/services/accounts";
import { useGrimoire } from "@/core/state";
-import { getInboxes, getOutboxes } from "applesauce-core/helpers";
import { addressLoader } from "@/services/loaders";
-import type { RelayInfo, UserRelays } from "@/types/app";
+import type { RelayInfo } from "@/types/app";
import { normalizeRelayURL } from "@/lib/relay-url";
/**
@@ -48,12 +47,10 @@ export function useAccountSync() {
if (relayListEvent.id === lastRelayEventId) return;
lastRelayEventId = relayListEvent.id;
- // Parse inbox and outbox relays
- const inboxRelays = getInboxes(relayListEvent);
- const outboxRelays = getOutboxes(relayListEvent);
-
- // Get all relays from tags
- const allRelays: RelayInfo[] = [];
+ // Parse relays from tags (NIP-65 format)
+ // Tag format: ["r", "relay-url", "read|write"]
+ // If no marker, relay is used for both read and write
+ const relays: RelayInfo[] = [];
const seenUrls = new Set();
for (const tag of relayListEvent.tags) {
@@ -63,11 +60,11 @@ export function useAccountSync() {
if (seenUrls.has(url)) continue;
seenUrls.add(url);
- const type = tag[2];
- allRelays.push({
+ const marker = tag[2];
+ relays.push({
url,
- read: !type || type === "read",
- write: !type || type === "write",
+ read: !marker || marker === "read",
+ write: !marker || marker === "write",
});
} catch (error) {
console.warn(
@@ -78,36 +75,6 @@ export function useAccountSync() {
}
}
- const relays: UserRelays = {
- inbox: inboxRelays
- .map((url) => {
- try {
- return {
- url: normalizeRelayURL(url),
- read: true,
- write: false,
- };
- } catch {
- return null;
- }
- })
- .filter((r): r is RelayInfo => r !== null),
- outbox: outboxRelays
- .map((url) => {
- try {
- return {
- url: normalizeRelayURL(url),
- read: false,
- write: true,
- };
- } catch {
- return null;
- }
- })
- .filter((r): r is RelayInfo => r !== null),
- all: allRelays,
- };
-
setActiveAccountRelays(relays);
});
diff --git a/src/lib/migrations.ts b/src/lib/migrations.ts
index d75e931..9b9c37d 100644
--- a/src/lib/migrations.ts
+++ b/src/lib/migrations.ts
@@ -8,7 +8,7 @@
import { GrimoireState } from "@/types/app";
import { toast } from "sonner";
-export const CURRENT_VERSION = 8;
+export const CURRENT_VERSION = 9;
/**
* Migration function type
@@ -81,6 +81,30 @@ const migrations: Record = {
},
};
},
+ // Migration from v8 to v9 - simplifies relay structure
+ 8: (state: any) => {
+ // Simplify activeAccount.relays from {inbox, outbox, all} to just an array
+ // The 'all' array already has the correct read/write flags per relay
+ if (state.activeAccount?.relays) {
+ const oldRelays = state.activeAccount.relays;
+ // If it has the old structure (with inbox/outbox/all), migrate it
+ if (oldRelays.all && Array.isArray(oldRelays.all)) {
+ return {
+ ...state,
+ __version: 9,
+ activeAccount: {
+ ...state.activeAccount,
+ relays: oldRelays.all,
+ },
+ };
+ }
+ }
+ // No relays to migrate, just bump version
+ return {
+ ...state,
+ __version: 9,
+ };
+ },
};
/**
diff --git a/src/types/app.ts b/src/types/app.ts
index 27c142e..8b38e89 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -72,12 +72,6 @@ export interface RelayInfo {
write: boolean;
}
-export interface UserRelays {
- inbox: RelayInfo[];
- outbox: RelayInfo[];
- all: RelayInfo[];
-}
-
export interface GrimoireState {
__version: number; // Schema version for migrations
windows: Record;
@@ -86,7 +80,7 @@ export interface GrimoireState {
layoutConfig: LayoutConfig; // Global configuration for window insertion behavior
activeAccount?: {
pubkey: string;
- relays?: UserRelays;
+ relays?: RelayInfo[];
};
locale?: {
locale: string;
diff --git a/src/types/man.ts b/src/types/man.ts
index b49fe45..36bea80 100644
--- a/src/types/man.ts
+++ b/src/types/man.ts
@@ -108,18 +108,18 @@ export const manPages: Record = {
category: "Documentation",
defaultProps: {},
},
- // debug: {
- // name: "debug",
- // section: "1",
- // synopsis: "debug",
- // description:
- // "Display the current application state for debugging purposes. Shows windows, workspaces, active account, and other internal state in a formatted view.",
- // examples: ["debug View current application state"],
- // seeAlso: ["help"],
- // appId: "debug",
- // category: "System",
- // defaultProps: {},
- // },
+ debug: {
+ name: "debug",
+ section: "1",
+ synopsis: "debug",
+ description:
+ "Display the current application state for debugging purposes. Shows windows, workspaces, active account, and other internal state in a formatted view.",
+ examples: ["debug View current application state"],
+ seeAlso: ["help"],
+ appId: "debug",
+ category: "System",
+ defaultProps: {},
+ },
man: {
name: "man",
section: "1",