App.svelte raw
1 <script>
2 // Svelte component imports
3 import LoginModal from "./LoginModal.svelte";
4 import ManagedACL from "./ManagedACL.svelte";
5 import Header from "./Header.svelte";
6 import Sidebar from "./Sidebar.svelte";
7 import SidebarAccordion from "./SidebarAccordion.svelte";
8 import UserMenu from "./UserMenu.svelte";
9 import AboutView from "./AboutView.svelte";
10 import SearchOverlay from "./SearchOverlay.svelte";
11 import NotificationDropdown from "./NotificationDropdown.svelte";
12 import FeedView from "./FeedView.svelte";
13 import ChatView from "./ChatView.svelte";
14 import LibraryView from "./LibraryView.svelte";
15 import ExportView from "./ExportView.svelte";
16 import ImportView from "./ImportView.svelte";
17 import EventsView from "./EventsView.svelte";
18 import ComposeView from "./ComposeView.svelte";
19 import RecoveryView from "./RecoveryView.svelte";
20 import SprocketView from "./SprocketView.svelte";
21 import PolicyView from "./PolicyView.svelte";
22 import CurationView from "./CurationView.svelte";
23 import BlossomView from "./BlossomView.svelte";
24 import LogView from "./LogView.svelte";
25 import RelayConnectView from "./RelayConnectView.svelte";
26 import SearchResultsView from "./SearchResultsView.svelte";
27 import FilterDisplay from "./FilterDisplay.svelte";
28 import RelayConnectModal from "./RelayConnectModal.svelte";
29
30 // Relay config imports
31 import { isStandalone, hasRelayConfigured, fetchRelayInfoFromUrl, getApiBase } from "./config.js";
32 import { isStandaloneMode, relayUrl, relayInfo as relayInfoStore, relayConnectionStatus, isOrlyRelay, activeView, expandedSection, userMenuOpen } from "./stores.js";
33 import { activeChatTab } from "./chatStores.js";
34
35 // Utility imports
36 import { buildFilter } from "./helpers.tsx";
37 import { replaceableKinds, kindNames, CACHE_DURATION } from "./constants.js";
38 import { getKindName, truncatePubkey, truncateContent, formatTimestamp, escapeHtml, aboutToHtml, copyToClipboard, showCopyFeedback } from "./utils.js";
39 import * as api from "./api.js";
40
41 // Nostr library imports
42 import {
43 initializeNostrClient,
44 fetchUserProfile,
45 fetchUserRelayList,
46 fetchUserContactList,
47 fetchAllEvents,
48 fetchUserEvents,
49 searchEvents,
50 fetchEvents,
51 fetchEventById,
52 fetchDeleteEventsByTarget,
53 queryEvents,
54 queryEventsFromDB,
55 debugIndexedDB,
56 clearIndexedDBCache,
57 nostrClient,
58 NostrClient,
59 Nip07Signer,
60 PrivateKeySigner,
61 } from "./nostr.js";
62 import { publishEventWithAuth } from "./websocket-auth.js";
63
64 // Expose debug function globally for console access
65 if (typeof window !== "undefined") {
66 window.debugIndexedDB = debugIndexedDB;
67 }
68
69 let isDarkTheme = false;
70 let showLoginModal = false;
71 let showRelayConnectModal = false;
72 let isLoggedIn = false;
73 let userPubkey = "";
74 let authMethod = "";
75 let userProfile = null;
76 let userRelayList = null;
77 let userContactList = null;
78 let userRole = "";
79 let userSigner = null;
80 let showSettingsDrawer = false;
81 let mobileMenuOpen = false;
82 let selectedTab = localStorage.getItem("selectedTab") || "export";
83
84 // Sync activeView with selectedTab on init: if activeView is an admin view, set selectedTab
85 {
86 const storedView = localStorage.getItem("activeView") || "feed";
87 const tabMap = {
88 "admin-export": "export", "admin-import": "import", "admin-events": "events",
89 "admin-blossom": "blossom", "admin-compose": "compose", "admin-recovery": "recovery",
90 "admin-managed-acl": "managed-acl", "admin-curation": "curation",
91 "admin-sprocket": "sprocket", "admin-policy": "policy",
92 "admin-relay-connect": "relay-connect", "admin-logs": "logs",
93 };
94 if (tabMap[storedView]) {
95 selectedTab = tabMap[storedView];
96 }
97 }
98
99 let showFilterBuilder = false; // Show filter builder in events view
100 let eventsViewFilter = {}; // Active filter for events view
101 let searchTabs = [];
102 let allEvents = [];
103 let selectedFile = null;
104 let importMessage = ""; // Message shown after import completes
105 let expandedEvents = new Set();
106 let isLoadingEvents = false;
107 let hasMoreEvents = true;
108 let eventsPerPage = 100;
109 let oldestEventTimestamp = null; // For timestamp-based pagination
110 let newestEventTimestamp = null; // For loading newer events
111 let showPermissionMenu = false;
112 let viewAsRole = "";
113
114 // Search results state
115 let searchResults = new Map(); // Map of searchTabId -> { filter, events, isLoading, hasMore, oldestTimestamp }
116 let isLoadingSearch = false;
117
118 // Screen-filling events view state
119 let eventsPerScreen = 20; // Default, will be calculated based on screen size
120
121 // Global events cache system
122 let globalEventsCache = []; // All events cache
123 let globalCacheTimestamp = 0;
124 // CACHE_DURATION is imported from constants.js
125
126 // Events filter toggle
127 let showOnlyMyEvents = false;
128
129 // My Events state
130 let myEvents = [];
131 let isLoadingMyEvents = false;
132 let hasMoreMyEvents = true;
133 let oldestMyEventTimestamp = null;
134 let newestMyEventTimestamp = null;
135
136 // Sprocket management state
137 let sprocketScript = "";
138 let sprocketStatus = null;
139 let sprocketVersions = [];
140 let isLoadingSprocket = false;
141 let sprocketMessage = "";
142 let sprocketMessageType = "info";
143 let sprocketEnabled = false;
144 let sprocketUploadFile = null;
145
146 // Policy management state
147 let policyJson = "";
148 let policyEnabled = false;
149 let isPolicyAdmin = false;
150 let isLoadingPolicy = false;
151 let policyMessage = "";
152 let policyMessageType = "info";
153 let policyValidationErrors = [];
154 let policyFollows = [];
155
156 // NRC (Nostr Relay Connect) state
157 let nrcEnabled = false;
158
159 // Blossom (blob storage) state
160 let blossomEnabled = true; // Default to true for backward compatibility
161 // Update blossomEnabled when relay info changes
162 $: if ($relayInfoStore && typeof $relayInfoStore.blossom_enabled === "boolean") {
163 blossomEnabled = $relayInfoStore.blossom_enabled;
164 }
165
166 // ACL mode
167 let aclMode = "";
168
169 // Relay version
170 let relayVersion = "";
171
172 // Compose tab state
173 let composeEventJson = "";
174 let composePublishError = "";
175 let composeLocalOnly = true;
176
177 // Recovery tab state
178 let recoverySelectedKind = null;
179 let recoveryCustomKind = "";
180 let recoveryEvents = [];
181 let isLoadingRecovery = false;
182 let recoveryHasMore = true;
183 let recoveryOldestTimestamp = null;
184 let recoveryNewestTimestamp = null;
185
186 // replaceableKinds is now imported from constants.js
187
188 // Helper functions imported from utils.js:
189 // - getKindName, truncatePubkey, truncateContent, formatTimestamp, escapeHtml, copyToClipboard, showCopyFeedback
190
191 function toggleEventExpansion(eventId) {
192 if (expandedEvents.has(eventId)) {
193 expandedEvents.delete(eventId);
194 } else {
195 expandedEvents.add(eventId);
196 }
197 expandedEvents = expandedEvents; // Trigger reactivity
198 }
199
200 async function copyEventToClipboard(eventData, clickEvent) {
201 const minifiedJson = JSON.stringify(eventData);
202 const success = await copyToClipboard(minifiedJson);
203 const button = clickEvent.target.closest(".copy-json-btn");
204 showCopyFeedback(button, success);
205 if (!success) {
206 alert("Failed to copy to clipboard. Please copy manually.");
207 }
208 }
209
210 async function handleToggleChange() {
211 // Toggle state is already updated by bind:checked
212 console.log("Toggle changed, showOnlyMyEvents:", showOnlyMyEvents);
213
214 // Reset the attempt flag to allow reloading with new filter
215 hasAttemptedEventLoad = false;
216
217 // Reload events with the new filter
218 const authors =
219 showOnlyMyEvents && isLoggedIn && userPubkey ? [userPubkey] : null;
220 await loadAllEvents(true, authors);
221 }
222
223 // Events are filtered server-side, but add client-side filtering as backup
224 // Sort events by created_at timestamp (newest first)
225 $: filteredEvents = (
226 showOnlyMyEvents && isLoggedIn && userPubkey
227 ? allEvents.filter(
228 (event) => event.pubkey && event.pubkey === userPubkey,
229 )
230 : allEvents
231 ).sort((a, b) => b.created_at - a.created_at);
232
233 async function deleteEvent(eventId) {
234 if (!isLoggedIn) {
235 alert("Please log in first");
236 return;
237 }
238
239 // Find the event to check if user can delete it
240 const event = allEvents.find((e) => e.id === eventId);
241 if (!event) {
242 alert("Event not found");
243 return;
244 }
245
246 // Check permissions: admin/owner can delete any event, write users can only delete their own events
247 const canDelete =
248 userRole === "admin" ||
249 userRole === "owner" ||
250 (userRole === "write" &&
251 event.pubkey &&
252 event.pubkey === userPubkey);
253
254 if (!canDelete) {
255 alert("You do not have permission to delete this event");
256 return;
257 }
258
259 if (!confirm("Are you sure you want to delete this event?")) {
260 return;
261 }
262
263 try {
264 // Check if signer is available
265 if (!userSigner) {
266 throw new Error("Signer not available for signing");
267 }
268
269 // Create the delete event template (unsigned)
270 const deleteEventTemplate = {
271 kind: 5,
272 created_at: Math.floor(Date.now() / 1000),
273 tags: [["e", eventId]], // e-tag referencing the event to delete
274 content: "",
275 // Don't set pubkey - let the signer set it
276 };
277
278 console.log("Created delete event template:", deleteEventTemplate);
279 console.log("User pubkey:", userPubkey);
280 console.log("Target event:", event);
281 console.log("Target event pubkey:", event.pubkey);
282
283 // Sign the event using the signer
284 const signedDeleteEvent =
285 await userSigner.signEvent(deleteEventTemplate);
286 console.log("Signed delete event:", signedDeleteEvent);
287 console.log(
288 "Signed delete event pubkey:",
289 signedDeleteEvent.pubkey,
290 );
291 console.log("Delete event tags:", signedDeleteEvent.tags);
292
293 // Publish to the ORLY relay using WebSocket authentication
294 const wsProtocol = window.location.protocol.startsWith('https') ? 'wss:' : 'ws:';
295 const relayUrl = `${wsProtocol}//${window.location.host}/`;
296
297 try {
298 const result = await publishEventWithAuth(
299 relayUrl,
300 signedDeleteEvent,
301 userSigner,
302 userPubkey,
303 );
304
305 if (result.success) {
306 console.log(
307 "Delete event published successfully to ORLY relay",
308 );
309 } else {
310 console.error(
311 "Failed to publish delete event:",
312 result.reason,
313 );
314 }
315 } catch (error) {
316 console.error("Error publishing delete event:", error);
317 }
318
319 // Determine if we should publish to external relays
320 // Only publish to external relays if:
321 // 1. User is deleting their own event, OR
322 // 2. User is admin/owner AND deleting their own event
323 const isDeletingOwnEvent =
324 event.pubkey && event.pubkey === userPubkey;
325 const isAdminOrOwner = userRole === "admin" || userRole === "owner";
326 const shouldPublishToExternalRelays = isDeletingOwnEvent;
327
328 if (shouldPublishToExternalRelays) {
329 // Publish the delete event to all relays (including external ones)
330 const result = await nostrClient.publish(signedDeleteEvent);
331 console.log("Delete event published:", result);
332
333 if (result.success && result.okCount > 0) {
334 // Wait a moment for the deletion to propagate
335 await new Promise((resolve) => setTimeout(resolve, 2000));
336
337 // Verify the event was actually deleted by trying to fetch it
338 try {
339 const deletedEvent = await fetchEventById(eventId, {
340 timeout: 5000,
341 });
342 if (deletedEvent) {
343 console.warn(
344 "Event still exists after deletion attempt:",
345 deletedEvent,
346 );
347 alert(
348 `Warning: Delete event was accepted by ${result.okCount} relay(s), but the event still exists on the relay. This may indicate the relay does not properly handle delete events.`,
349 );
350 } else {
351 console.log(
352 "Event successfully deleted and verified",
353 );
354 }
355 } catch (fetchError) {
356 console.log(
357 "Could not fetch event after deletion (likely deleted):",
358 fetchError.message,
359 );
360 }
361
362 // Also verify that the delete event has been saved
363 try {
364 const deleteEvents = await fetchDeleteEventsByTarget(
365 eventId,
366 { timeout: 5000 },
367 );
368 if (deleteEvents.length > 0) {
369 console.log(
370 `Delete event verification: Found ${deleteEvents.length} delete event(s) targeting ${eventId}`,
371 );
372 // Check if our delete event is among them
373 const ourDeleteEvent = deleteEvents.find(
374 (de) => de.pubkey && de.pubkey === userPubkey,
375 );
376 if (ourDeleteEvent) {
377 console.log(
378 "Our delete event found in database:",
379 ourDeleteEvent.id,
380 );
381 } else {
382 console.warn(
383 "Our delete event not found in database, but other delete events exist",
384 );
385 }
386 } else {
387 console.warn(
388 "No delete events found in database for target event:",
389 eventId,
390 );
391 }
392 } catch (deleteFetchError) {
393 console.log(
394 "Could not verify delete event in database:",
395 deleteFetchError.message,
396 );
397 }
398
399 // Remove from local lists
400 allEvents = allEvents.filter(
401 (event) => event.id !== eventId,
402 );
403 myEvents = myEvents.filter((event) => event.id !== eventId);
404
405 // Remove from global cache
406 globalEventsCache = globalEventsCache.filter(
407 (event) => event.id !== eventId,
408 );
409
410 // Remove from search results cache
411 for (const [tabId, searchResult] of searchResults) {
412 if (searchResult.events) {
413 searchResult.events = searchResult.events.filter(
414 (event) => event.id !== eventId,
415 );
416 searchResults.set(tabId, searchResult);
417 }
418 }
419
420 // Update persistent state
421 savePersistentState();
422
423 // Reload events to show the new delete event at the top
424 console.log("Reloading events to show delete event...");
425 const authors =
426 showOnlyMyEvents && isLoggedIn && userPubkey
427 ? [userPubkey]
428 : null;
429 await loadAllEvents(true, authors);
430
431 alert(
432 `Event deleted successfully (accepted by ${result.okCount} relay(s))`,
433 );
434 } else {
435 throw new Error("No relays accepted the delete event");
436 }
437 } else {
438 // Admin/owner deleting someone else's event - only publish to local relay
439 // We need to publish only to the local relay, not external ones
440 const wsProto = window.location.protocol.startsWith('https') ? 'wss:' : 'ws:';
441 const localRelayUrl = `${wsProto}//${window.location.host}/`;
442
443 // Create a modified client that only connects to the local relay
444 const localClient = new NostrClient();
445 await localClient.connectToRelay(localRelayUrl);
446
447 const result = await localClient.publish(signedDeleteEvent);
448 console.log(
449 "Delete event published to local relay only:",
450 result,
451 );
452
453 if (result.success && result.okCount > 0) {
454 // Wait a moment for the deletion to propagate
455 await new Promise((resolve) => setTimeout(resolve, 2000));
456
457 // Verify the event was actually deleted by trying to fetch it
458 try {
459 const deletedEvent = await fetchEventById(eventId, {
460 timeout: 5000,
461 });
462 if (deletedEvent) {
463 console.warn(
464 "Event still exists after deletion attempt:",
465 deletedEvent,
466 );
467 alert(
468 `Warning: Delete event was accepted by ${result.okCount} relay(s), but the event still exists on the relay. This may indicate the relay does not properly handle delete events.`,
469 );
470 } else {
471 console.log(
472 "Event successfully deleted and verified",
473 );
474 }
475 } catch (fetchError) {
476 console.log(
477 "Could not fetch event after deletion (likely deleted):",
478 fetchError.message,
479 );
480 }
481
482 // Also verify that the delete event has been saved
483 try {
484 const deleteEvents = await fetchDeleteEventsByTarget(
485 eventId,
486 { timeout: 5000 },
487 );
488 if (deleteEvents.length > 0) {
489 console.log(
490 `Delete event verification: Found ${deleteEvents.length} delete event(s) targeting ${eventId}`,
491 );
492 // Check if our delete event is among them
493 const ourDeleteEvent = deleteEvents.find(
494 (de) => de.pubkey && de.pubkey === userPubkey,
495 );
496 if (ourDeleteEvent) {
497 console.log(
498 "Our delete event found in database:",
499 ourDeleteEvent.id,
500 );
501 } else {
502 console.warn(
503 "Our delete event not found in database, but other delete events exist",
504 );
505 }
506 } else {
507 console.warn(
508 "No delete events found in database for target event:",
509 eventId,
510 );
511 }
512 } catch (deleteFetchError) {
513 console.log(
514 "Could not verify delete event in database:",
515 deleteFetchError.message,
516 );
517 }
518
519 // Remove from local lists
520 allEvents = allEvents.filter(
521 (event) => event.id !== eventId,
522 );
523 myEvents = myEvents.filter((event) => event.id !== eventId);
524
525 // Remove from global cache
526 globalEventsCache = globalEventsCache.filter(
527 (event) => event.id !== eventId,
528 );
529
530 // Remove from search results cache
531 for (const [tabId, searchResult] of searchResults) {
532 if (searchResult.events) {
533 searchResult.events = searchResult.events.filter(
534 (event) => event.id !== eventId,
535 );
536 searchResults.set(tabId, searchResult);
537 }
538 }
539
540 // Update persistent state
541 savePersistentState();
542
543 // Reload events to show the new delete event at the top
544 console.log("Reloading events to show delete event...");
545 const authors =
546 showOnlyMyEvents && isLoggedIn && userPubkey
547 ? [userPubkey]
548 : null;
549 await loadAllEvents(true, authors);
550
551 alert(
552 `Event deleted successfully (local relay only - admin/owner deleting other user's event)`,
553 );
554 } else {
555 throw new Error(
556 "Local relay did not accept the delete event",
557 );
558 }
559 }
560 } catch (error) {
561 console.error("Failed to delete event:", error);
562 alert("Failed to delete event: " + error.message);
563 }
564 }
565
566 // escapeHtml is imported from utils.js
567
568 // Recovery tab functions
569 async function loadRecoveryEvents() {
570 const kindToUse = recoveryCustomKind
571 ? parseInt(recoveryCustomKind)
572 : recoverySelectedKind;
573
574 if (kindToUse === null || kindToUse === undefined || isNaN(kindToUse)) {
575 console.log("No valid kind to load, kindToUse:", kindToUse);
576 return;
577 }
578
579 if (!isLoggedIn) {
580 console.log("Not logged in, cannot load recovery events");
581 return;
582 }
583
584 console.log(
585 "Loading recovery events for kind:",
586 kindToUse,
587 "user:",
588 userPubkey,
589 );
590 isLoadingRecovery = true;
591 try {
592 // Fetch multiple versions using limit parameter
593 // For replaceable events, limit > 1 returns multiple versions
594 const filters = [
595 {
596 kinds: [kindToUse],
597 authors: [userPubkey],
598 limit: 100, // Get up to 100 versions
599 },
600 ];
601
602 if (recoveryOldestTimestamp) {
603 filters[0].until = recoveryOldestTimestamp;
604 }
605
606 console.log("Recovery filters:", filters);
607
608 // Use queryEvents which checks IndexedDB cache first, then relay
609 const events = await queryEvents(filters, {
610 timeout: 30000,
611 cacheFirst: true, // Check cache first
612 });
613
614 console.log("Recovery events received:", events.length);
615 console.log(
616 "Recovery events kinds:",
617 events.map((e) => e.kind),
618 );
619
620 if (recoveryOldestTimestamp) {
621 // Append to existing events
622 recoveryEvents = [...recoveryEvents, ...events];
623 } else {
624 // Replace events
625 recoveryEvents = events;
626 }
627
628 if (events.length > 0) {
629 recoveryOldestTimestamp = Math.min(
630 ...events.map((e) => e.created_at),
631 );
632 recoveryHasMore = events.length === 100;
633 } else {
634 recoveryHasMore = false;
635 }
636 } catch (error) {
637 console.error("Failed to load recovery events:", error);
638 } finally {
639 isLoadingRecovery = false;
640 }
641 }
642
643 // Get user's write relays from relay list event (kind 10002)
644 async function getUserWriteRelays() {
645 if (!userPubkey) {
646 return [];
647 }
648
649 try {
650 // Query for the user's relay list event (kind 10002)
651 const relayListEvents = await queryEventsFromDB([
652 {
653 kinds: [10002],
654 authors: [userPubkey],
655 limit: 1,
656 },
657 ]);
658
659 if (relayListEvents.length === 0) {
660 console.log("No relay list event found for user");
661 return [];
662 }
663
664 const relayListEvent = relayListEvents[0];
665 console.log("Found relay list event:", relayListEvent);
666
667 const writeRelays = [];
668
669 // Parse r tags to extract write relays
670 for (const tag of relayListEvent.tags) {
671 if (tag[0] === "r" && tag.length >= 2) {
672 const relayUrl = tag[1];
673 const permission = tag.length >= 3 ? tag[2] : null;
674
675 // Include relay if it's explicitly marked for write or has no permission specified (default is read+write)
676 if (!permission || permission === "write") {
677 writeRelays.push(relayUrl);
678 }
679 }
680 }
681
682 console.log("Found write relays:", writeRelays);
683 return writeRelays;
684 } catch (error) {
685 console.error("Error fetching user write relays:", error);
686 return [];
687 }
688 }
689
690 async function repostEvent(event) {
691 if (!confirm("Are you sure you want to repost this event?")) {
692 return;
693 }
694
695 try {
696 const wsProto = window.location.protocol.startsWith('https') ? 'wss:' : 'ws:';
697 const localRelayUrl = `${wsProto}//${window.location.host}/`;
698 console.log(
699 "Reposting event to local relay:",
700 localRelayUrl,
701 event,
702 );
703
704 // Create a new event with updated timestamp
705 const newEvent = { ...event };
706 newEvent.created_at = Math.floor(Date.now() / 1000);
707 newEvent.id = ""; // Clear the old ID so it gets recalculated
708 newEvent.sig = ""; // Clear the old signature
709
710 // For addressable events, ensure the d tag matches
711 if (event.kind >= 30000 && event.kind <= 39999) {
712 const dTag = event.tags.find((tag) => tag[0] === "d");
713 if (dTag) {
714 newEvent.tags = newEvent.tags.filter(
715 (tag) => tag[0] !== "d",
716 );
717 newEvent.tags.push(dTag);
718 }
719 }
720
721 // Sign the event before publishing
722 if (userSigner) {
723 const signedEvent = await userSigner.signEvent(newEvent);
724 console.log("Signed event for repost:", signedEvent);
725
726 const result = await nostrClient.publish(signedEvent, [
727 localRelayUrl,
728 ]);
729 console.log("Repost publish result:", result);
730
731 if (result.success && result.okCount > 0) {
732 alert("Event reposted successfully!");
733 recoveryHasMore = false; // Reset to allow reloading
734 await loadRecoveryEvents(); // Reload the events to show the new version
735 } else {
736 alert("Failed to repost event. Check console for details.");
737 }
738 } else {
739 alert("No signer available. Please log in.");
740 }
741 } catch (error) {
742 console.error("Error reposting event:", error);
743 alert("Error reposting event: " + error.message);
744 }
745 }
746
747 async function repostEventToAll(event) {
748 if (
749 !confirm(
750 "Are you sure you want to repost this event to all your write relays?",
751 )
752 ) {
753 return;
754 }
755
756 try {
757 // Get user's write relays
758 const writeRelays = await getUserWriteRelays();
759 const wsProto = window.location.protocol.startsWith('https') ? 'wss:' : 'ws:';
760 const localRelayUrl = `${wsProto}//${window.location.host}/`;
761
762 // Always include local relay
763 const allRelays = [
764 localRelayUrl,
765 ...writeRelays.filter((url) => url !== localRelayUrl),
766 ];
767
768 if (allRelays.length === 1) {
769 alert(
770 "No write relays found in your relay list. Only posting to local relay.",
771 );
772 }
773
774 console.log("Reposting event to all relays:", allRelays, event);
775
776 // Create a new event with updated timestamp
777 const newEvent = { ...event };
778 newEvent.created_at = Math.floor(Date.now() / 1000);
779 newEvent.id = ""; // Clear the old ID so it gets recalculated
780 newEvent.sig = ""; // Clear the old signature
781
782 // For addressable events, ensure the d tag matches
783 if (event.kind >= 30000 && event.kind <= 39999) {
784 const dTag = event.tags.find((tag) => tag[0] === "d");
785 if (dTag) {
786 newEvent.tags = newEvent.tags.filter(
787 (tag) => tag[0] !== "d",
788 );
789 newEvent.tags.push(dTag);
790 }
791 }
792
793 // Sign the event before publishing
794 if (userSigner) {
795 const signedEvent = await userSigner.signEvent(newEvent);
796 console.log("Signed event for repost to all:", signedEvent);
797
798 const result = await nostrClient.publish(
799 signedEvent,
800 allRelays,
801 );
802 console.log("Repost to all publish result:", result);
803
804 if (result.success && result.okCount > 0) {
805 alert(
806 `Event reposted successfully to ${allRelays.length} relays!`,
807 );
808 recoveryHasMore = false; // Reset to allow reloading
809 await loadRecoveryEvents(); // Reload the events to show the new version
810 } else {
811 alert("Failed to repost event. Check console for details.");
812 }
813 } else {
814 alert("No signer available. Please log in.");
815 }
816 } catch (error) {
817 console.error("Error reposting event to all:", error);
818 alert("Error reposting event to all: " + error.message);
819 }
820 }
821
822 function selectRecoveryKind() {
823 console.log(
824 "selectRecoveryKind called, recoverySelectedKind:",
825 recoverySelectedKind,
826 );
827 if (
828 recoverySelectedKind === null ||
829 recoverySelectedKind === undefined
830 ) {
831 console.log("No kind selected, skipping load");
832 return;
833 }
834 recoveryCustomKind = ""; // Clear custom kind when selecting from dropdown
835 recoveryEvents = [];
836 recoveryOldestTimestamp = null;
837 recoveryHasMore = true;
838 loadRecoveryEvents();
839 }
840
841 function handleCustomKindInput() {
842 console.log(
843 "handleCustomKindInput called, recoveryCustomKind:",
844 recoveryCustomKind,
845 );
846 // Check if a valid number was entered (including 0)
847 const kindNum = parseInt(recoveryCustomKind);
848 if (recoveryCustomKind !== "" && !isNaN(kindNum) && kindNum >= 0) {
849 recoverySelectedKind = null; // Clear dropdown selection when using custom
850 recoveryEvents = [];
851 recoveryOldestTimestamp = null;
852 recoveryHasMore = true;
853 loadRecoveryEvents();
854 }
855 }
856
857 function isCurrentVersion(event) {
858 // Find all events with the same kind and pubkey
859 const sameKindEvents = recoveryEvents.filter(
860 (e) => e.kind === event.kind && e.pubkey === event.pubkey,
861 );
862
863 // Check if this event has the highest timestamp
864 const maxTimestamp = Math.max(
865 ...sameKindEvents.map((e) => e.created_at),
866 );
867 return event.created_at === maxTimestamp;
868 }
869
870 // Always show all versions - no filtering needed
871
872 $: aboutHtml = userProfile?.about
873 ? escapeHtml(userProfile.about).replace(/\n{2,}/g, "<br>")
874 : "";
875
876 // Theme configuration: "auto" follows system, "light"/"dark" are forced
877 let configuredTheme = "auto";
878
879 // Detect system theme preference and listen for changes
880 if (typeof window !== "undefined" && window.matchMedia) {
881 const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
882 isDarkTheme = darkModeQuery.matches;
883
884 // Listen for system theme changes (only applies when theme is "auto")
885 darkModeQuery.addEventListener("change", (e) => {
886 if (configuredTheme === "auto") {
887 isDarkTheme = e.matches;
888 }
889 });
890
891 // Fetch relay info to get configured theme and feature flags
892 (async () => {
893 try {
894 const relayInfo = await api.fetchRelayInfo();
895 if (relayInfo?.theme && relayInfo.theme !== "auto") {
896 configuredTheme = relayInfo.theme;
897 isDarkTheme = relayInfo.theme === "dark";
898 }
899 // Check if blossom is enabled (default to true for backward compatibility)
900 if (relayInfo && typeof relayInfo.blossom_enabled === "boolean") {
901 blossomEnabled = relayInfo.blossom_enabled;
902 }
903 } catch (e) {
904 console.log("Could not fetch relay theme config:", e);
905 }
906 })();
907 }
908
909 function toggleTheme() {
910 isDarkTheme = !isDarkTheme;
911 configuredTheme = isDarkTheme ? "dark" : "light";
912 }
913
914 // Load state from localStorage
915 if (typeof localStorage !== "undefined") {
916 // Check for existing authentication
917 const storedAuthMethod = localStorage.getItem("nostr_auth_method");
918 const storedPubkey = localStorage.getItem("nostr_pubkey");
919
920 if (storedAuthMethod && storedPubkey) {
921 isLoggedIn = true;
922 userPubkey = storedPubkey;
923 authMethod = storedAuthMethod;
924
925 // Restore signer for extension method
926 if (storedAuthMethod === "extension" && window.nostr) {
927 userSigner = window.nostr;
928 }
929 }
930
931 // Load persistent app state
932 loadPersistentState();
933
934 // Initialize relay connection first
935 // In standalone mode without a relay, this will show the modal
936 // and skip the API calls until a relay is connected
937 initializeRelayConnection();
938 }
939
940 // Load relay-dependent data (called after relay is confirmed)
941 async function loadRelayData() {
942 // Fetch user role for already logged in users
943 if (isLoggedIn) {
944 await fetchUserRole();
945 }
946 await fetchACLMode();
947
948 // Load sprocket configuration
949 loadSprocketConfig();
950
951 // Load NRC configuration
952 loadNRCConfig();
953
954 // Load policy configuration
955 loadPolicyConfig();
956
957 // Load relay version
958 fetchRelayVersion();
959 }
960
961 // Handle relay change from header dropdown
962 async function handleRelayChange(event) {
963 console.log("Relay changed:", event.detail?.info?.name);
964
965 // Reset the NostrClient to use new relay
966 nostrClient.reset();
967
968 // Clear IndexedDB cache (contains events from old relay)
969 await clearIndexedDBCache();
970
971 // Clear the events cache when switching relays
972 globalEventsCache = [];
973 globalCacheTimestamp = 0;
974 hasAttemptedEventLoad = false;
975
976 // Clear displayed events
977 allEvents = [];
978 myEvents = [];
979
980 // Reset pagination state
981 hasMoreEvents = true;
982 hasMoreMyEvents = true;
983 oldestEventTimestamp = null;
984 newestEventTimestamp = null;
985
986 // Clear search results
987 searchResults.clear();
988 searchTabs = [];
989
990 // Reload all relay-dependent data
991 loadRelayData();
992
993 // If the events tab is currently active, reload events
994 if (selectedTab === "events" && isLoggedIn) {
995 loadAllEvents(true);
996 } else if (selectedTab === "myevents" && isLoggedIn) {
997 loadMyEvents(true);
998 }
999 }
1000
1001 // Initialize relay connection
1002 // In standalone mode with no relay configured, show the connection modal
1003 // Otherwise, try to connect to the configured relay
1004 async function initializeRelayConnection() {
1005 if (isStandalone()) {
1006 if (!hasRelayConfigured()) {
1007 // No relay configured - show the connection modal
1008 // Don't load relay data yet
1009 showRelayConnectModal = true;
1010 return;
1011 } else {
1012 // Try to fetch relay info to verify connection
1013 await fetchRelayInfoFromUrl();
1014 }
1015 } else {
1016 // Embedded mode - fetch relay info from same origin
1017 await fetchRelayInfoFromUrl();
1018 }
1019
1020 // Relay is configured/connected - load relay-dependent data
1021 await loadRelayData();
1022 }
1023
1024 function openRelayConnectModal() {
1025 showRelayConnectModal = true;
1026 }
1027
1028 function closeRelayConnectModal() {
1029 showRelayConnectModal = false;
1030 }
1031
1032 async function handleRelayConnected(event) {
1033 // Relay connected successfully - reload data
1034 console.log("Connected to relay:", event.detail?.info?.name);
1035
1036 // Refresh nostr client with new relay
1037 if (nostrClient) {
1038 nostrClient.refreshRelays();
1039 }
1040
1041 // Load all relay-dependent data
1042 await loadRelayData();
1043 }
1044
1045 function savePersistentState() {
1046 if (typeof localStorage === "undefined") return;
1047
1048 const state = {
1049 selectedTab,
1050 expandedEvents: Array.from(expandedEvents),
1051 globalEventsCache,
1052 globalCacheTimestamp,
1053 hasMoreEvents,
1054 oldestEventTimestamp,
1055 };
1056
1057 localStorage.setItem("app_state", JSON.stringify(state));
1058 }
1059
1060 function loadPersistentState() {
1061 if (typeof localStorage === "undefined") return;
1062
1063 try {
1064 const savedState = localStorage.getItem("app_state");
1065 if (savedState) {
1066 const state = JSON.parse(savedState);
1067
1068 // Restore tab state
1069 if (
1070 state.selectedTab &&
1071 baseTabs.some((tab) => tab.id === state.selectedTab)
1072 ) {
1073 selectedTab = state.selectedTab;
1074 }
1075
1076 // Restore expanded events
1077 if (state.expandedEvents) {
1078 expandedEvents = new Set(state.expandedEvents);
1079 }
1080
1081 // Restore cache data
1082
1083 if (state.globalEventsCache) {
1084 globalEventsCache = state.globalEventsCache;
1085 }
1086
1087 if (state.globalCacheTimestamp) {
1088 globalCacheTimestamp = state.globalCacheTimestamp;
1089 }
1090
1091 if (state.hasMoreEvents !== undefined) {
1092 hasMoreEvents = state.hasMoreEvents;
1093 }
1094
1095 if (state.oldestEventTimestamp) {
1096 oldestEventTimestamp = state.oldestEventTimestamp;
1097 }
1098
1099 if (state.hasMoreMyEvents !== undefined) {
1100 hasMoreMyEvents = state.hasMoreMyEvents;
1101 }
1102
1103 if (state.oldestMyEventTimestamp) {
1104 oldestMyEventTimestamp = state.oldestMyEventTimestamp;
1105 }
1106
1107 // Restore events from cache
1108 restoreEventsFromCache();
1109 }
1110 } catch (error) {
1111 console.error("Failed to load persistent state:", error);
1112 }
1113 }
1114
1115 function restoreEventsFromCache() {
1116 // Restore global events cache
1117 if (
1118 globalEventsCache.length > 0 &&
1119 isCacheValid(globalCacheTimestamp)
1120 ) {
1121 allEvents = globalEventsCache;
1122 }
1123 }
1124
1125 function isCacheValid(timestamp) {
1126 if (!timestamp) return false;
1127 return Date.now() - timestamp < CACHE_DURATION;
1128 }
1129
1130 function updateGlobalCache(events) {
1131 globalEventsCache = events.sort((a, b) => b.created_at - a.created_at);
1132 globalCacheTimestamp = Date.now();
1133 savePersistentState();
1134 }
1135
1136 function clearCache() {
1137 globalEventsCache = [];
1138 globalCacheTimestamp = 0;
1139 savePersistentState();
1140 }
1141
1142 // Sprocket management functions
1143 async function loadSprocketConfig() {
1144 try {
1145 const response = await fetch(`${getApiBase()}/api/sprocket/config`, {
1146 method: "GET",
1147 headers: {
1148 "Content-Type": "application/json",
1149 },
1150 });
1151
1152 if (response.ok) {
1153 const config = await response.json();
1154 sprocketEnabled = config.enabled;
1155 } else if (response.status === 404) {
1156 // Non-ORLY relay - sprocket not available
1157 sprocketEnabled = false;
1158 }
1159 } catch (error) {
1160 // Non-ORLY relay or network error - sprocket not available
1161 sprocketEnabled = false;
1162 }
1163 }
1164
1165 async function loadNRCConfig() {
1166 try {
1167 const config = await api.fetchNRCConfig();
1168 nrcEnabled = config.enabled;
1169 } catch (error) {
1170 // Non-ORLY relay or network error - NRC not available
1171 nrcEnabled = false;
1172 }
1173 }
1174
1175 async function loadPolicyConfig() {
1176 try {
1177 const response = await fetch(`${getApiBase()}/api/policy/config`, {
1178 method: "GET",
1179 headers: {
1180 "Content-Type": "application/json",
1181 },
1182 });
1183
1184 if (response.ok) {
1185 const config = await response.json();
1186 policyEnabled = config.enabled || false;
1187 } else if (response.status === 404) {
1188 // Non-ORLY relay - policy not available
1189 policyEnabled = false;
1190 }
1191 } catch (error) {
1192 // Non-ORLY relay or network error - policy not available
1193 policyEnabled = false;
1194 }
1195 }
1196
1197 async function loadSprocketStatus() {
1198 if (!isLoggedIn || userRole !== "owner" || !sprocketEnabled) return;
1199
1200 try {
1201 isLoadingSprocket = true;
1202 const response = await fetch(`${getApiBase()}/api/sprocket/status`, {
1203 method: "GET",
1204 headers: {
1205 Authorization: `Nostr ${await createNIP98Auth("GET", `${getApiBase()}/api/sprocket/status`)}`,
1206 "Content-Type": "application/json",
1207 },
1208 });
1209
1210 if (response.ok) {
1211 sprocketStatus = await response.json();
1212 } else {
1213 showSprocketMessage("Failed to load sprocket status", "error");
1214 }
1215 } catch (error) {
1216 showSprocketMessage(
1217 `Error loading sprocket status: ${error.message}`,
1218 "error",
1219 );
1220 } finally {
1221 isLoadingSprocket = false;
1222 }
1223 }
1224
1225 async function loadSprocket() {
1226 if (!isLoggedIn || userRole !== "owner") return;
1227
1228 try {
1229 isLoadingSprocket = true;
1230 const response = await fetch(`${getApiBase()}/api/sprocket/status`, {
1231 method: "GET",
1232 headers: {
1233 Authorization: `Nostr ${await createNIP98Auth("GET", `${getApiBase()}/api/sprocket/status`)}`,
1234 "Content-Type": "application/json",
1235 },
1236 });
1237
1238 if (response.ok) {
1239 const status = await response.json();
1240 sprocketScript = status.script_content || "";
1241 sprocketStatus = status;
1242 showSprocketMessage("Script loaded successfully", "success");
1243 } else {
1244 showSprocketMessage("Failed to load script", "error");
1245 }
1246 } catch (error) {
1247 showSprocketMessage(
1248 `Error loading script: ${error.message}`,
1249 "error",
1250 );
1251 } finally {
1252 isLoadingSprocket = false;
1253 }
1254 }
1255
1256 async function saveSprocket() {
1257 if (!isLoggedIn || userRole !== "owner") return;
1258
1259 try {
1260 isLoadingSprocket = true;
1261 const response = await fetch(`${getApiBase()}/api/sprocket/update`, {
1262 method: "POST",
1263 headers: {
1264 Authorization: `Nostr ${await createNIP98Auth("POST", `${getApiBase()}/api/sprocket/update`)}`,
1265 "Content-Type": "text/plain",
1266 },
1267 body: sprocketScript,
1268 });
1269
1270 if (response.ok) {
1271 showSprocketMessage(
1272 "Script saved and updated successfully",
1273 "success",
1274 );
1275 await loadSprocketStatus();
1276 await loadVersions();
1277 } else {
1278 const errorText = await response.text();
1279 showSprocketMessage(
1280 `Failed to save script: ${errorText}`,
1281 "error",
1282 );
1283 }
1284 } catch (error) {
1285 showSprocketMessage(
1286 `Error saving script: ${error.message}`,
1287 "error",
1288 );
1289 } finally {
1290 isLoadingSprocket = false;
1291 }
1292 }
1293
1294 async function restartSprocket() {
1295 if (!isLoggedIn || userRole !== "owner") return;
1296
1297 try {
1298 isLoadingSprocket = true;
1299 const response = await fetch(`${getApiBase()}/api/sprocket/restart`, {
1300 method: "POST",
1301 headers: {
1302 Authorization: `Nostr ${await createNIP98Auth("POST", `${getApiBase()}/api/sprocket/restart`)}`,
1303 "Content-Type": "application/json",
1304 },
1305 });
1306
1307 if (response.ok) {
1308 showSprocketMessage(
1309 "Sprocket restarted successfully",
1310 "success",
1311 );
1312 await loadSprocketStatus();
1313 } else {
1314 const errorText = await response.text();
1315 showSprocketMessage(
1316 `Failed to restart sprocket: ${errorText}`,
1317 "error",
1318 );
1319 }
1320 } catch (error) {
1321 showSprocketMessage(
1322 `Error restarting sprocket: ${error.message}`,
1323 "error",
1324 );
1325 } finally {
1326 isLoadingSprocket = false;
1327 }
1328 }
1329
1330 async function deleteSprocket() {
1331 if (!isLoggedIn || userRole !== "owner") return;
1332
1333 if (
1334 !confirm(
1335 "Are you sure you want to delete the sprocket script? This will stop the current process.",
1336 )
1337 ) {
1338 return;
1339 }
1340
1341 try {
1342 isLoadingSprocket = true;
1343 const response = await fetch(`${getApiBase()}/api/sprocket/update`, {
1344 method: "POST",
1345 headers: {
1346 Authorization: `Nostr ${await createNIP98Auth("POST", `${getApiBase()}/api/sprocket/update`)}`,
1347 "Content-Type": "text/plain",
1348 },
1349 body: "", // Empty body deletes the script
1350 });
1351
1352 if (response.ok) {
1353 sprocketScript = "";
1354 showSprocketMessage(
1355 "Sprocket script deleted successfully",
1356 "success",
1357 );
1358 await loadSprocketStatus();
1359 await loadVersions();
1360 } else {
1361 const errorText = await response.text();
1362 showSprocketMessage(
1363 `Failed to delete script: ${errorText}`,
1364 "error",
1365 );
1366 }
1367 } catch (error) {
1368 showSprocketMessage(
1369 `Error deleting script: ${error.message}`,
1370 "error",
1371 );
1372 } finally {
1373 isLoadingSprocket = false;
1374 }
1375 }
1376
1377 async function loadVersions() {
1378 if (!isLoggedIn || userRole !== "owner") return;
1379
1380 try {
1381 isLoadingSprocket = true;
1382 const response = await fetch(`${getApiBase()}/api/sprocket/versions`, {
1383 method: "GET",
1384 headers: {
1385 Authorization: `Nostr ${await createNIP98Auth("GET", `${getApiBase()}/api/sprocket/versions`)}`,
1386 "Content-Type": "application/json",
1387 },
1388 });
1389
1390 if (response.ok) {
1391 sprocketVersions = await response.json();
1392 } else {
1393 showSprocketMessage("Failed to load versions", "error");
1394 }
1395 } catch (error) {
1396 showSprocketMessage(
1397 `Error loading versions: ${error.message}`,
1398 "error",
1399 );
1400 } finally {
1401 isLoadingSprocket = false;
1402 }
1403 }
1404
1405 async function loadVersion(version) {
1406 if (!isLoggedIn || userRole !== "owner") return;
1407
1408 sprocketScript = version.content;
1409 showSprocketMessage(`Loaded version: ${version.name}`, "success");
1410 }
1411
1412 async function deleteVersion(filename) {
1413 if (!isLoggedIn || userRole !== "owner") return;
1414
1415 if (!confirm(`Are you sure you want to delete version ${filename}?`)) {
1416 return;
1417 }
1418
1419 try {
1420 isLoadingSprocket = true;
1421 const response = await fetch(`${getApiBase()}/api/sprocket/delete-version`, {
1422 method: "POST",
1423 headers: {
1424 Authorization: `Nostr ${await createNIP98Auth("POST", `${getApiBase()}/api/sprocket/delete-version`)}`,
1425 "Content-Type": "application/json",
1426 },
1427 body: JSON.stringify({ filename }),
1428 });
1429
1430 if (response.ok) {
1431 showSprocketMessage(
1432 `Version ${filename} deleted successfully`,
1433 "success",
1434 );
1435 await loadVersions();
1436 } else {
1437 const errorText = await response.text();
1438 showSprocketMessage(
1439 `Failed to delete version: ${errorText}`,
1440 "error",
1441 );
1442 }
1443 } catch (error) {
1444 showSprocketMessage(
1445 `Error deleting version: ${error.message}`,
1446 "error",
1447 );
1448 } finally {
1449 isLoadingSprocket = false;
1450 }
1451 }
1452
1453 function showSprocketMessage(message, type = "info") {
1454 sprocketMessage = message;
1455 sprocketMessageType = type;
1456
1457 // Auto-hide message after 5 seconds
1458 setTimeout(() => {
1459 sprocketMessage = "";
1460 }, 5000);
1461 }
1462
1463 // Policy management functions
1464 function showPolicyMessage(message, type = "info") {
1465 policyMessage = message;
1466 policyMessageType = type;
1467
1468 // Auto-hide message after 5 seconds for non-errors
1469 if (type !== "error") {
1470 setTimeout(() => {
1471 policyMessage = "";
1472 }, 5000);
1473 }
1474 }
1475
1476 async function loadPolicy() {
1477 if (!isLoggedIn || (userRole !== "owner" && !isPolicyAdmin)) return;
1478
1479 try {
1480 isLoadingPolicy = true;
1481 policyValidationErrors = [];
1482
1483 // Query for the most recent kind 12345 event (policy config)
1484 const filter = { kinds: [12345], limit: 1 };
1485 const events = await queryEvents(filter);
1486
1487 if (events && events.length > 0) {
1488 policyJson = events[0].content;
1489 // Try to format it nicely
1490 try {
1491 policyJson = JSON.stringify(JSON.parse(policyJson), null, 2);
1492 } catch (e) {
1493 // Keep as-is if not valid JSON
1494 }
1495 showPolicyMessage("Policy loaded successfully", "success");
1496 } else {
1497 // No policy event found, try to load from file via API
1498 const response = await fetch(`${getApiBase()}/api/policy`, {
1499 method: "GET",
1500 headers: {
1501 Authorization: `Nostr ${await createNIP98Auth("GET", `${getApiBase()}/api/policy`)}`,
1502 "Content-Type": "application/json",
1503 },
1504 });
1505
1506 if (response.ok) {
1507 const data = await response.json();
1508 policyJson = JSON.stringify(data, null, 2);
1509 showPolicyMessage("Policy loaded from file", "success");
1510 } else {
1511 showPolicyMessage("No policy configuration found", "info");
1512 policyJson = "";
1513 }
1514 }
1515 } catch (error) {
1516 showPolicyMessage(`Error loading policy: ${error.message}`, "error");
1517 } finally {
1518 isLoadingPolicy = false;
1519 }
1520 }
1521
1522 async function validatePolicy() {
1523 policyValidationErrors = [];
1524
1525 if (!policyJson.trim()) {
1526 policyValidationErrors = ["Policy JSON is empty"];
1527 showPolicyMessage("Validation failed", "error");
1528 return false;
1529 }
1530
1531 try {
1532 const parsed = JSON.parse(policyJson);
1533
1534 // Basic structure validation
1535 if (typeof parsed !== "object" || parsed === null) {
1536 policyValidationErrors = ["Policy must be a JSON object"];
1537 showPolicyMessage("Validation failed", "error");
1538 return false;
1539 }
1540
1541 // Validate policy_admins if present
1542 if (parsed.policy_admins) {
1543 if (!Array.isArray(parsed.policy_admins)) {
1544 policyValidationErrors.push("policy_admins must be an array");
1545 } else {
1546 for (const admin of parsed.policy_admins) {
1547 if (typeof admin !== "string" || !/^[0-9a-fA-F]{64}$/.test(admin)) {
1548 policyValidationErrors.push(`Invalid policy_admin pubkey: ${admin}`);
1549 }
1550 }
1551 }
1552 }
1553
1554 // Validate rules if present
1555 if (parsed.rules) {
1556 if (typeof parsed.rules !== "object") {
1557 policyValidationErrors.push("rules must be an object");
1558 } else {
1559 for (const [kindStr, rule] of Object.entries(parsed.rules)) {
1560 if (!/^\d+$/.test(kindStr)) {
1561 policyValidationErrors.push(`Invalid kind number: ${kindStr}`);
1562 }
1563 if (rule.tag_validation && typeof rule.tag_validation === "object") {
1564 for (const [tag, pattern] of Object.entries(rule.tag_validation)) {
1565 try {
1566 new RegExp(pattern);
1567 } catch (e) {
1568 policyValidationErrors.push(`Invalid regex for tag '${tag}': ${pattern}`);
1569 }
1570 }
1571 }
1572 }
1573 }
1574 }
1575
1576 // Validate default_policy if present
1577 if (parsed.default_policy && !["allow", "deny"].includes(parsed.default_policy)) {
1578 policyValidationErrors.push("default_policy must be 'allow' or 'deny'");
1579 }
1580
1581 if (policyValidationErrors.length > 0) {
1582 showPolicyMessage("Validation failed - see errors below", "error");
1583 return false;
1584 }
1585
1586 showPolicyMessage("Validation passed", "success");
1587 return true;
1588 } catch (error) {
1589 policyValidationErrors = [`JSON parse error: ${error.message}`];
1590 showPolicyMessage("Invalid JSON syntax", "error");
1591 return false;
1592 }
1593 }
1594
1595 async function savePolicy() {
1596 if (!isLoggedIn || (userRole !== "owner" && !isPolicyAdmin)) return;
1597
1598 // Validate first
1599 const isValid = await validatePolicy();
1600 if (!isValid) return;
1601
1602 try {
1603 isLoadingPolicy = true;
1604
1605 // Create and publish kind 12345 event
1606 const policyEvent = {
1607 kind: 12345,
1608 created_at: Math.floor(Date.now() / 1000),
1609 tags: [],
1610 content: policyJson,
1611 };
1612
1613 // Sign and publish the event
1614 const result = await publishEventWithAuth(policyEvent, userSigner);
1615
1616 if (result.success) {
1617 showPolicyMessage("Policy updated successfully", "success");
1618 } else {
1619 showPolicyMessage(`Failed to publish policy: ${result.error || "Unknown error"}`, "error");
1620 }
1621 } catch (error) {
1622 showPolicyMessage(`Error saving policy: ${error.message}`, "error");
1623 } finally {
1624 isLoadingPolicy = false;
1625 }
1626 }
1627
1628 function formatPolicyJson() {
1629 try {
1630 const parsed = JSON.parse(policyJson);
1631 policyJson = JSON.stringify(parsed, null, 2);
1632 showPolicyMessage("JSON formatted", "success");
1633 } catch (error) {
1634 showPolicyMessage(`Cannot format: ${error.message}`, "error");
1635 }
1636 }
1637
1638 // Convert npub to hex pubkey
1639 function npubToHex(input) {
1640 if (!input) return null;
1641
1642 // If already hex (64 characters)
1643 if (/^[0-9a-fA-F]{64}$/.test(input)) {
1644 return input.toLowerCase();
1645 }
1646
1647 // If npub, decode it
1648 if (input.startsWith("npub1")) {
1649 try {
1650 // Bech32 decode - simplified implementation
1651 const ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1652 const data = input.slice(5); // Remove "npub1" prefix
1653
1654 let bits = [];
1655 for (const char of data) {
1656 const value = ALPHABET.indexOf(char.toLowerCase());
1657 if (value === -1) throw new Error("Invalid character in npub");
1658 bits.push(...[...Array(5)].map((_, i) => (value >> (4 - i)) & 1));
1659 }
1660
1661 // Remove checksum (last 30 bits = 6 characters * 5 bits)
1662 bits = bits.slice(0, -30);
1663
1664 // Convert 5-bit groups to 8-bit bytes
1665 const bytes = [];
1666 for (let i = 0; i + 8 <= bits.length; i += 8) {
1667 let byte = 0;
1668 for (let j = 0; j < 8; j++) {
1669 byte = (byte << 1) | bits[i + j];
1670 }
1671 bytes.push(byte);
1672 }
1673
1674 // Convert to hex
1675 return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
1676 } catch (e) {
1677 console.error("Failed to decode npub:", e);
1678 return null;
1679 }
1680 }
1681
1682 return null;
1683 }
1684
1685 function addPolicyAdmin(event) {
1686 const input = event.detail;
1687 if (!input) {
1688 showPolicyMessage("Please enter a pubkey", "error");
1689 return;
1690 }
1691
1692 const hexPubkey = npubToHex(input);
1693 if (!hexPubkey || hexPubkey.length !== 64) {
1694 showPolicyMessage("Invalid pubkey format. Use hex (64 chars) or npub", "error");
1695 return;
1696 }
1697
1698 try {
1699 const config = JSON.parse(policyJson || "{}");
1700 if (!config.policy_admins) {
1701 config.policy_admins = [];
1702 }
1703
1704 if (config.policy_admins.includes(hexPubkey)) {
1705 showPolicyMessage("Admin already in list", "warning");
1706 return;
1707 }
1708
1709 config.policy_admins.push(hexPubkey);
1710 policyJson = JSON.stringify(config, null, 2);
1711 showPolicyMessage("Admin added - click 'Save & Publish' to apply", "info");
1712 } catch (error) {
1713 showPolicyMessage(`Error adding admin: ${error.message}`, "error");
1714 }
1715 }
1716
1717 function removePolicyAdmin(event) {
1718 const pubkey = event.detail;
1719
1720 try {
1721 const config = JSON.parse(policyJson || "{}");
1722 if (config.policy_admins) {
1723 config.policy_admins = config.policy_admins.filter(p => p !== pubkey);
1724 policyJson = JSON.stringify(config, null, 2);
1725 showPolicyMessage("Admin removed - click 'Save & Publish' to apply", "info");
1726 }
1727 } catch (error) {
1728 showPolicyMessage(`Error removing admin: ${error.message}`, "error");
1729 }
1730 }
1731
1732 async function refreshFollows() {
1733 if (!isLoggedIn || (userRole !== "owner" && !isPolicyAdmin)) return;
1734
1735 try {
1736 isLoadingPolicy = true;
1737 policyFollows = [];
1738
1739 // Parse current policy to get admin list
1740 let admins = [];
1741 try {
1742 const config = JSON.parse(policyJson || "{}");
1743 admins = config.policy_admins || [];
1744 } catch (e) {
1745 showPolicyMessage("Cannot parse policy JSON to get admins", "error");
1746 return;
1747 }
1748
1749 if (admins.length === 0) {
1750 showPolicyMessage("No policy admins configured", "warning");
1751 return;
1752 }
1753
1754 // Query kind 3 events from policy admins
1755 const filter = {
1756 kinds: [3],
1757 authors: admins,
1758 limit: admins.length
1759 };
1760
1761 const events = await queryEvents(filter);
1762
1763 // Extract p-tags from all follow lists
1764 const followsSet = new Set();
1765 for (const event of events) {
1766 if (event.tags) {
1767 for (const tag of event.tags) {
1768 if (tag[0] === 'p' && tag[1] && tag[1].length === 64) {
1769 followsSet.add(tag[1]);
1770 }
1771 }
1772 }
1773 }
1774
1775 policyFollows = Array.from(followsSet);
1776 showPolicyMessage(`Loaded ${policyFollows.length} follows from ${events.length} admin(s)`, "success");
1777 } catch (error) {
1778 showPolicyMessage(`Error loading follows: ${error.message}`, "error");
1779 } finally {
1780 isLoadingPolicy = false;
1781 }
1782 }
1783
1784 function handleSprocketFileSelect(event) {
1785 sprocketUploadFile = event.target.files[0];
1786 }
1787
1788 async function uploadSprocketScript() {
1789 if (!isLoggedIn || userRole !== "owner" || !sprocketUploadFile) return;
1790
1791 try {
1792 isLoadingSprocket = true;
1793
1794 // Read the file content
1795 const fileContent = await sprocketUploadFile.text();
1796
1797 // Upload the script
1798 const response = await fetch(`${getApiBase()}/api/sprocket/update`, {
1799 method: "POST",
1800 headers: {
1801 Authorization: `Nostr ${await createNIP98Auth("POST", `${getApiBase()}/api/sprocket/update`)}`,
1802 "Content-Type": "text/plain",
1803 },
1804 body: fileContent,
1805 });
1806
1807 if (response.ok) {
1808 sprocketScript = fileContent;
1809 showSprocketMessage(
1810 "Script uploaded and updated successfully",
1811 "success",
1812 );
1813 await loadSprocketStatus();
1814 await loadVersions();
1815 } else {
1816 const errorText = await response.text();
1817 showSprocketMessage(
1818 `Failed to upload script: ${errorText}`,
1819 "error",
1820 );
1821 }
1822 } catch (error) {
1823 showSprocketMessage(
1824 `Error uploading script: ${error.message}`,
1825 "error",
1826 );
1827 } finally {
1828 isLoadingSprocket = false;
1829 sprocketUploadFile = null;
1830 // Clear the file input
1831 const fileInput = document.getElementById("sprocket-upload-file");
1832 if (fileInput) {
1833 fileInput.value = "";
1834 }
1835 }
1836 }
1837
1838 const baseTabs = [
1839 { id: "export", icon: "📤", label: "Export" },
1840 { id: "import", icon: "💾", label: "Import", requiresAdmin: true },
1841 { id: "events", icon: "📡", label: "Events" },
1842 { id: "blossom", icon: "🌸", label: "Blossom" },
1843 { id: "compose", icon: "✏️", label: "Compose", requiresWrite: true },
1844 { id: "recovery", icon: "🔄", label: "Recovery" },
1845 {
1846 id: "managed-acl",
1847 icon: "🛡️",
1848 label: "Managed ACL",
1849 requiresOwner: true,
1850 },
1851 {
1852 id: "curation",
1853 icon: "📋",
1854 label: "Curation",
1855 requiresOwner: true,
1856 },
1857 { id: "sprocket", icon: "⚙️", label: "Sprocket", requiresOwner: true },
1858 { id: "policy", icon: "📜", label: "Policy", requiresOwner: true },
1859 { id: "relay-connect", icon: "🔗", label: "Relay Connect", requiresOwner: true },
1860 { id: "logs", icon: "📋", label: "Logs", requiresOwner: true },
1861 ];
1862
1863 // Filter tabs based on current effective role (including view-as setting)
1864 $: filteredBaseTabs = baseTabs.filter((tab) => {
1865 const currentRole = currentEffectiveRole;
1866
1867 if (
1868 tab.requiresAdmin &&
1869 (!isLoggedIn ||
1870 (currentRole !== "admin" && currentRole !== "owner"))
1871 ) {
1872 return false;
1873 }
1874 if (tab.requiresOwner && (!isLoggedIn || currentRole !== "owner")) {
1875 return false;
1876 }
1877 if (tab.requiresWrite && (!isLoggedIn || currentRole === "read")) {
1878 return false;
1879 }
1880 // Hide ORLY-specific tabs when connected to a non-ORLY relay
1881 const orlyOnlyTabs = ["sprocket", "policy", "managed-acl", "curation", "logs", "relay-connect"];
1882 if (orlyOnlyTabs.includes(tab.id) && !$isOrlyRelay) {
1883 return false;
1884 }
1885 // Hide sprocket tab if not enabled
1886 if (tab.id === "sprocket" && !sprocketEnabled) {
1887 return false;
1888 }
1889 // Hide policy tab if not enabled
1890 if (tab.id === "policy" && !policyEnabled) {
1891 return false;
1892 }
1893 // Hide relay-connect tab if NRC is not enabled
1894 if (tab.id === "relay-connect" && !nrcEnabled) {
1895 return false;
1896 }
1897 // Hide managed ACL tab if not in managed mode
1898 if (tab.id === "managed-acl" && aclMode !== "managed") {
1899 return false;
1900 }
1901 // Hide curation tab if not in curating mode
1902 if (tab.id === "curation" && aclMode !== "curating") {
1903 return false;
1904 }
1905 // Hide blossom tab if not enabled
1906 if (tab.id === "blossom" && !blossomEnabled) {
1907 return false;
1908 }
1909 // Debug logging for tab filtering
1910 console.log(`Tab ${tab.id} filter check:`, {
1911 isLoggedIn,
1912 userRole,
1913 viewAsRole,
1914 currentRole,
1915 requiresAdmin: tab.requiresAdmin,
1916 requiresOwner: tab.requiresOwner,
1917 requiresWrite: tab.requiresWrite,
1918 visible: true,
1919 });
1920 return true;
1921 });
1922
1923 $: tabs = [...filteredBaseTabs, ...searchTabs];
1924
1925 // Debug logging for tabs
1926 $: console.log("Tabs debug:", {
1927 isLoggedIn,
1928 userRole,
1929 aclMode,
1930 filteredBaseTabs: filteredBaseTabs.map((t) => t.id),
1931 allTabs: tabs.map((t) => t.id),
1932 });
1933
1934 function selectTab(tabId) {
1935 selectedTab = tabId;
1936
1937 // Load sprocket data when switching to sprocket tab
1938 if (
1939 tabId === "sprocket" &&
1940 isLoggedIn &&
1941 userRole === "owner" &&
1942 sprocketEnabled
1943 ) {
1944 loadSprocketStatus();
1945 loadVersions();
1946 }
1947
1948 savePersistentState();
1949 }
1950
1951 // Map accordion view IDs to old selectedTab IDs
1952 const adminViewToTab = {
1953 "admin-export": "export",
1954 "admin-import": "import",
1955 "admin-events": "events",
1956 "admin-blossom": "blossom",
1957 "admin-compose": "compose",
1958 "admin-recovery": "recovery",
1959 "admin-managed-acl": "managed-acl",
1960 "admin-curation": "curation",
1961 "admin-sprocket": "sprocket",
1962 "admin-policy": "policy",
1963 "admin-relay-connect": "relay-connect",
1964 "admin-logs": "logs",
1965 };
1966
1967 function handleAccordionNavigate(event) {
1968 const viewId = event.detail;
1969 activeView.set(viewId);
1970
1971 // Sync chat sub-tab when navigating to chat views
1972 if (viewId === "chat-inbox") activeChatTab.set("inbox");
1973 else if (viewId === "chat-channels") activeChatTab.set("channels");
1974
1975 // If it's an admin sub-view, bridge to old selectedTab system
1976 if (adminViewToTab[viewId]) {
1977 selectTab(adminViewToTab[viewId]);
1978 }
1979 }
1980
1981 let showAboutModal = false;
1982
1983 function handleShowAbout() {
1984 showAboutModal = true;
1985 }
1986
1987 function openLoginModal() {
1988 if (!isLoggedIn) {
1989 showLoginModal = true;
1990 }
1991 }
1992
1993 async function handleLogin(event) {
1994 const { method, pubkey, privateKey, signer } = event.detail;
1995 isLoggedIn = true;
1996 userPubkey = pubkey;
1997 authMethod = method;
1998 userSigner = signer;
1999 showLoginModal = false;
2000
2001 // Initialize Nostr client and fetch profile
2002 try {
2003 await initializeNostrClient();
2004
2005 // Set up NDK signer based on authentication method
2006 if (method === "extension" && signer) {
2007 // Extension signer (NIP-07 compatible)
2008 nostrClient.setSigner(signer);
2009 } else if (method === "nsec" && privateKey) {
2010 // Private key signer for nsec
2011 const keySigner = new PrivateKeySigner(privateKey);
2012 nostrClient.setSigner(keySigner);
2013 }
2014
2015 userProfile = await fetchUserProfile(pubkey);
2016 console.log("Profile loaded:", userProfile);
2017
2018 // Fetch user's relay list (NIP-65) and contact list
2019 userRelayList = await fetchUserRelayList(pubkey);
2020 if (userRelayList) {
2021 console.log("User relay list loaded:", userRelayList.all.length, "relays");
2022 }
2023
2024 userContactList = await fetchUserContactList(pubkey);
2025 if (userContactList) {
2026 console.log("User contact list loaded:", userContactList.follows.length, "follows");
2027 }
2028 } catch (error) {
2029 console.error("Failed to load profile:", error);
2030 }
2031
2032 // Fetch user role/permissions
2033 await fetchUserRole();
2034 await fetchACLMode();
2035
2036 // Trigger event loading if currently on events tab
2037 if (selectedTab === "events") {
2038 hasAttemptedEventLoad = false;
2039 const authors =
2040 showOnlyMyEvents && userPubkey ? [userPubkey] : null;
2041 loadAllEvents(true, authors);
2042 }
2043 }
2044
2045 function handleLogout() {
2046 isLoggedIn = false;
2047 userPubkey = "";
2048 authMethod = "";
2049 userProfile = null;
2050 userRelayList = null;
2051 userContactList = null;
2052 userRole = "";
2053 userSigner = null;
2054 userPrivkey = null;
2055 showSettingsDrawer = false;
2056
2057 // Clear events
2058 myEvents = [];
2059 allEvents = [];
2060
2061 // Clear cache
2062 clearCache();
2063
2064 // Clear stored authentication
2065 if (typeof localStorage !== "undefined") {
2066 localStorage.removeItem("nostr_auth_method");
2067 localStorage.removeItem("nostr_pubkey");
2068 localStorage.removeItem("nostr_privkey");
2069 }
2070 }
2071
2072 function closeLoginModal() {
2073 showLoginModal = false;
2074 }
2075
2076 function openSettingsDrawer() {
2077 showSettingsDrawer = true;
2078 }
2079
2080 function closeSettingsDrawer() {
2081 showSettingsDrawer = false;
2082 }
2083
2084 function toggleMobileMenu() {
2085 mobileMenuOpen = !mobileMenuOpen;
2086 }
2087
2088 function closeMobileMenu() {
2089 mobileMenuOpen = false;
2090 }
2091
2092 function toggleFilterBuilder() {
2093 showFilterBuilder = !showFilterBuilder;
2094 }
2095
2096 function createSearchTab(filter, label) {
2097 const searchTabId = `search-${Date.now()}`;
2098 const newSearchTab = {
2099 id: searchTabId,
2100 icon: "🔍",
2101 label: label,
2102 isSearchTab: true,
2103 filter: filter,
2104 };
2105 searchTabs = [...searchTabs, newSearchTab];
2106 selectedTab = searchTabId;
2107
2108 // Initialize search results for this tab
2109 searchResults.set(searchTabId, {
2110 filter: filter,
2111 events: [],
2112 isLoading: false,
2113 hasMore: true,
2114 oldestTimestamp: null,
2115 });
2116
2117 // Start loading search results
2118 loadSearchResults(searchTabId, true);
2119 }
2120
2121 function handleFilterApply(event) {
2122 const { searchText, selectedKinds, pubkeys, eventIds, tags, sinceTimestamp, untilTimestamp, limit } = event.detail;
2123
2124 // Build the filter for the events view
2125 const filter = buildFilter({
2126 searchText,
2127 kinds: selectedKinds,
2128 authors: pubkeys,
2129 ids: eventIds,
2130 tags,
2131 since: sinceTimestamp,
2132 until: untilTimestamp,
2133 limit: limit || 100,
2134 });
2135
2136 // Store the active filter and reload events with it
2137 eventsViewFilter = filter;
2138 loadAllEvents(true, null);
2139 }
2140
2141 function handleFilterClear() {
2142 // Clear the filter and reload all events
2143 eventsViewFilter = {};
2144 loadAllEvents(true, null);
2145 }
2146
2147 function handleFilterSweep(searchTabId) {
2148 // Close the search tab
2149 closeSearchTab(searchTabId);
2150 }
2151
2152 function closeSearchTab(tabId) {
2153 searchTabs = searchTabs.filter((tab) => tab.id !== tabId);
2154 searchResults.delete(tabId); // Clean up search results
2155 if (selectedTab === tabId) {
2156 selectedTab = "export"; // Fall back to export tab
2157 }
2158 }
2159
2160 async function loadSearchResults(searchTabId, reset = true) {
2161 const searchResult = searchResults.get(searchTabId);
2162 if (!searchResult || searchResult.isLoading) return;
2163
2164 // Update loading state
2165 searchResult.isLoading = true;
2166 searchResults.set(searchTabId, searchResult);
2167
2168 try {
2169 const filter = { ...searchResult.filter };
2170
2171 // Apply timestamp-based pagination
2172 if (!reset && searchResult.oldestTimestamp) {
2173 filter.until = searchResult.oldestTimestamp;
2174 }
2175
2176 // Override limit for pagination
2177 if (!reset) {
2178 filter.limit = 200;
2179 }
2180
2181 console.log(
2182 "Loading search results with filter:",
2183 filter,
2184 );
2185
2186 // Use fetchEvents with the filter array
2187 const events = await fetchEvents([filter], { timeout: 30000 });
2188 console.log("Received search results:", events.length, "events");
2189
2190 if (reset) {
2191 searchResult.events = events.sort(
2192 (a, b) => b.created_at - a.created_at,
2193 );
2194 } else {
2195 searchResult.events = [...searchResult.events, ...events].sort(
2196 (a, b) => b.created_at - a.created_at,
2197 );
2198 }
2199
2200 // Update oldest timestamp for next pagination
2201 if (events.length > 0) {
2202 const oldestInBatch = Math.min(
2203 ...events.map((e) => e.created_at),
2204 );
2205 if (
2206 !searchResult.oldestTimestamp ||
2207 oldestInBatch < searchResult.oldestTimestamp
2208 ) {
2209 searchResult.oldestTimestamp = oldestInBatch;
2210 }
2211 }
2212
2213 searchResult.hasMore = events.length === (reset ? filter.limit || 100 : 200);
2214 searchResult.isLoading = false;
2215 searchResults.set(searchTabId, searchResult);
2216 } catch (error) {
2217 console.error("Failed to load search results:", error);
2218 searchResult.isLoading = false;
2219 searchResults.set(searchTabId, searchResult);
2220 alert("Failed to load search results: " + error.message);
2221 }
2222 }
2223
2224 async function loadMoreSearchResults(searchTabId) {
2225 await loadSearchResults(searchTabId, false);
2226 }
2227
2228 function handleSearchScroll(event, searchTabId) {
2229 const { scrollTop, scrollHeight, clientHeight } = event.target;
2230 const threshold = 100; // Load more when 100px from bottom
2231
2232 if (scrollHeight - scrollTop - clientHeight < threshold) {
2233 const searchResult = searchResults.get(searchTabId);
2234 if (
2235 searchResult &&
2236 !searchResult.isLoading &&
2237 searchResult.hasMore
2238 ) {
2239 loadMoreSearchResults(searchTabId);
2240 }
2241 }
2242 }
2243
2244 $: if (typeof document !== "undefined") {
2245 if (isDarkTheme) {
2246 document.body.classList.add("dark-theme");
2247 } else {
2248 document.body.classList.remove("dark-theme");
2249 }
2250 }
2251
2252 // Auto-fetch profile when user is logged in but profile is missing
2253 $: if (isLoggedIn && userPubkey && !userProfile) {
2254 fetchProfileIfMissing();
2255 }
2256
2257 async function fetchProfileIfMissing() {
2258 if (!isLoggedIn || !userPubkey || userProfile) {
2259 return; // Don't fetch if not logged in, no pubkey, or profile already exists
2260 }
2261
2262 try {
2263 console.log("Auto-fetching profile for:", userPubkey);
2264 await initializeNostrClient();
2265 userProfile = await fetchUserProfile(userPubkey);
2266 console.log("Profile auto-loaded:", userProfile);
2267 } catch (error) {
2268 console.error("Failed to auto-load profile:", error);
2269 }
2270 }
2271
2272 async function fetchUserRole() {
2273 if (!isLoggedIn || !userPubkey) {
2274 userRole = "";
2275 return;
2276 }
2277
2278 // npub login is always read-only — no signer means no signing
2279 if (authMethod === "npub") {
2280 userRole = "read";
2281 console.log("User role: read (npub read-only login)");
2282 return;
2283 }
2284
2285 try {
2286 const url = `${getApiBase()}/api/permissions/${userPubkey}`;
2287 const response = await fetch(url);
2288 if (response.ok) {
2289 const data = await response.json();
2290 userRole = data.permission || "";
2291 isOrlyRelay.set(true);
2292 console.log("User role loaded:", userRole);
2293 console.log("Is owner?", userRole === "owner");
2294 } else if (response.status === 404) {
2295 // Non-ORLY relay - fallback to write permission based on NIP-11
2296 console.log("ORLY API not available, using NIP-11 fallback");
2297 isOrlyRelay.set(false);
2298 userRole = determineRoleFromNIP11();
2299 } else {
2300 console.error("Failed to fetch user role:", response.status);
2301 userRole = "";
2302 }
2303 } catch (error) {
2304 // Network error or non-ORLY relay - fallback to NIP-11
2305 console.log("Error fetching user role, using NIP-11 fallback:", error.message);
2306 isOrlyRelay.set(false);
2307 userRole = determineRoleFromNIP11();
2308 }
2309 }
2310
2311 /**
2312 * Determine user role from NIP-11 relay info when ORLY API is not available.
2313 * For generic relays, assume write permission unless NIP-11 indicates restrictions.
2314 */
2315 function determineRoleFromNIP11() {
2316 const info = $relayInfoStore;
2317 if (!info) {
2318 // No relay info, assume write access
2319 return "write";
2320 }
2321
2322 // Check NIP-11 limitation fields
2323 const limitation = info.limitation || {};
2324
2325 // If auth_required is true and we're logged in, assume write
2326 // If auth_required is false or not set, also assume write
2327 if (limitation.auth_required === false || !limitation.auth_required) {
2328 console.log("NIP-11: No auth required, granting write access");
2329 return "write";
2330 }
2331
2332 // If we're logged in and relay requires auth, assume write
2333 if (isLoggedIn) {
2334 console.log("NIP-11: Auth required and user is logged in, granting write access");
2335 return "write";
2336 }
2337
2338 // Otherwise, read-only
2339 return "read";
2340 }
2341
2342 async function fetchACLMode() {
2343 try {
2344 const response = await fetch(`${getApiBase()}/api/acl-mode`);
2345 if (response.ok) {
2346 const data = await response.json();
2347 aclMode = data.acl_mode || "";
2348 console.log("ACL mode loaded:", aclMode);
2349 } else if (response.status === 404) {
2350 // Non-ORLY relay - default to "none" (open relay mode)
2351 console.log("ACL API not available, defaulting to 'none'");
2352 aclMode = "none";
2353 } else {
2354 console.error("Failed to fetch ACL mode:", response.status);
2355 aclMode = "";
2356 }
2357 } catch (error) {
2358 // Non-ORLY relay - default to "none"
2359 console.log("Error fetching ACL mode, defaulting to 'none':", error.message);
2360 aclMode = "none";
2361 }
2362 }
2363
2364 async function fetchRelayVersion() {
2365 try {
2366 const info = await api.fetchRelayInfo();
2367 if (info && info.version) {
2368 relayVersion = info.version;
2369 }
2370 } catch (error) {
2371 console.error("Error fetching relay version:", error);
2372 }
2373 }
2374
2375 // Export functionality
2376 let isExporting = false;
2377
2378 async function exportEvents(pubkeys = []) {
2379 // Skip login check when ACL is "none" (open relay mode)
2380 if (aclMode !== "none" && !isLoggedIn) {
2381 alert("Please log in first");
2382 return;
2383 }
2384
2385 // Check permissions for exporting all events using current effective role
2386 // Skip permission check when ACL is "none"
2387 if (
2388 aclMode !== "none" &&
2389 pubkeys.length === 0 &&
2390 currentEffectiveRole !== "admin" &&
2391 currentEffectiveRole !== "owner"
2392 ) {
2393 alert("Admin or owner permission required to export all events");
2394 return;
2395 }
2396
2397 // For open relays (no auth needed), use a direct GET which lets the
2398 // browser handle the download natively via Content-Disposition header.
2399 if (aclMode === "none" || !isLoggedIn) {
2400 const base = getApiBase();
2401 let url = `${base}/api/export`;
2402 if (pubkeys.length > 0) {
2403 const params = pubkeys.map(pk => `pubkey=${encodeURIComponent(pk)}`).join("&");
2404 url += `?${params}`;
2405 }
2406 // Use a hidden iframe/link to trigger native browser download
2407 const a = document.createElement("a");
2408 a.href = url;
2409 a.download = "";
2410 document.body.appendChild(a);
2411 a.click();
2412 document.body.removeChild(a);
2413 return;
2414 }
2415
2416 // Authenticated export via fetch with NIP-98
2417 isExporting = true;
2418 try {
2419 const exportUrl = `${getApiBase()}/api/export`;
2420 const headers = {
2421 "Content-Type": "application/json",
2422 };
2423 headers.Authorization = await createNIP98AuthHeader(exportUrl, "POST");
2424
2425 const response = await fetch(exportUrl, {
2426 method: "POST",
2427 headers,
2428 body: JSON.stringify({ pubkeys }),
2429 });
2430
2431 if (!response.ok) {
2432 throw new Error(
2433 `Export failed: ${response.status} ${response.statusText}`,
2434 );
2435 }
2436
2437 const blob = await response.blob();
2438 const url = window.URL.createObjectURL(blob);
2439 const a = document.createElement("a");
2440 a.href = url;
2441
2442 // Get filename from response headers or use default
2443 const contentDisposition = response.headers.get(
2444 "Content-Disposition",
2445 );
2446 let filename = "events.jsonl";
2447 if (contentDisposition) {
2448 const filenameMatch =
2449 contentDisposition.match(/filename="([^"]+)"/);
2450 if (filenameMatch) {
2451 filename = filenameMatch[1];
2452 }
2453 }
2454
2455 a.download = filename;
2456 document.body.appendChild(a);
2457 a.click();
2458 document.body.removeChild(a);
2459 window.URL.revokeObjectURL(url);
2460 } catch (error) {
2461 console.error("Export failed:", error);
2462 alert("Export failed: " + error.message);
2463 } finally {
2464 isExporting = false;
2465 }
2466 }
2467
2468 async function exportAllEvents() {
2469 await exportEvents([]);
2470 }
2471
2472 async function exportMyEvents() {
2473 await exportEvents([userPubkey]); // Export only current user's events
2474 }
2475
2476 // Import functionality
2477 function handleFileSelect(event) {
2478 // event.detail contains the original DOM event from the child component
2479 selectedFile = event.detail.target.files[0];
2480 }
2481
2482 async function importEvents() {
2483 // Skip login/permission check when ACL is "none" (open relay mode)
2484 if (aclMode !== "none" && (!isLoggedIn || (userRole !== "admin" && userRole !== "owner"))) {
2485 importMessage = "Admin or owner permission required";
2486 setTimeout(() => { importMessage = ""; }, 5000);
2487 return;
2488 }
2489
2490 if (!selectedFile) {
2491 importMessage = "Please select a file";
2492 setTimeout(() => { importMessage = ""; }, 5000);
2493 return;
2494 }
2495
2496 try {
2497 // Show uploading message
2498 importMessage = "Uploading...";
2499
2500 // Build headers - only include auth when ACL is not "none"
2501 const headers = {};
2502 if (aclMode !== "none" && isLoggedIn) {
2503 headers.Authorization = await createNIP98AuthHeader(
2504 `${getApiBase()}/api/import`,
2505 "POST",
2506 );
2507 }
2508 const formData = new FormData();
2509 formData.append("file", selectedFile);
2510
2511 const response = await fetch(`${getApiBase()}/api/import`, {
2512 method: "POST",
2513 headers,
2514 body: formData,
2515 });
2516
2517 if (!response.ok) {
2518 throw new Error(
2519 `Import failed: ${response.status} ${response.statusText}`,
2520 );
2521 }
2522
2523 const result = await response.json();
2524 importMessage = "Upload complete";
2525 selectedFile = null;
2526 document.getElementById("import-file").value = "";
2527 // Clear message after 5 seconds
2528 setTimeout(() => { importMessage = ""; }, 5000);
2529 } catch (error) {
2530 console.error("Import failed:", error);
2531 importMessage = "Import failed: " + error.message;
2532 setTimeout(() => { importMessage = ""; }, 5000);
2533 }
2534 }
2535
2536 // Events loading functionality
2537 async function loadMyEvents(reset = false) {
2538 if (!isLoggedIn) {
2539 alert("Please log in first");
2540 return;
2541 }
2542
2543 if (isLoadingMyEvents) return;
2544
2545 // Always load fresh data when feed becomes visible (reset = true)
2546 // Skip cache check to ensure fresh data every time
2547
2548 isLoadingMyEvents = true;
2549
2550 // Reset timestamps when doing a fresh load
2551 if (reset) {
2552 oldestMyEventTimestamp = null;
2553 newestMyEventTimestamp = null;
2554 }
2555
2556 try {
2557 // Use WebSocket REQ to fetch user events with timestamp-based pagination
2558 // Load 1000 events on initial load, otherwise use 200 for pagination
2559 const events = await fetchUserEvents(userPubkey, {
2560 limit: reset ? 1000 : 200,
2561 until: reset ? null : oldestMyEventTimestamp,
2562 });
2563
2564 if (reset) {
2565 myEvents = events.sort((a, b) => b.created_at - a.created_at);
2566 } else {
2567 myEvents = [...myEvents, ...events].sort(
2568 (a, b) => b.created_at - a.created_at,
2569 );
2570 }
2571
2572 // Update oldest timestamp for next pagination
2573 if (events.length > 0) {
2574 const oldestInBatch = Math.min(
2575 ...events.map((e) => e.created_at),
2576 );
2577 if (
2578 !oldestMyEventTimestamp ||
2579 oldestInBatch < oldestMyEventTimestamp
2580 ) {
2581 oldestMyEventTimestamp = oldestInBatch;
2582 }
2583 }
2584
2585 hasMoreMyEvents = events.length === (reset ? 1000 : 200);
2586
2587 // Auto-load more events if content doesn't fill viewport and more events are available
2588 // Only do this on initial load (reset = true) to avoid interfering with scroll-based loading
2589 if (reset && hasMoreMyEvents) {
2590 setTimeout(() => {
2591 // Only check viewport if we're currently on the My Events tab
2592 if (selectedTab === "myevents") {
2593 const eventsContainers = document.querySelectorAll(
2594 ".events-view-content",
2595 );
2596 // The My Events container should be the first one (before All Events)
2597 const myEventsContainer = eventsContainers[0];
2598 if (
2599 myEventsContainer &&
2600 myEventsContainer.scrollHeight <=
2601 myEventsContainer.clientHeight
2602 ) {
2603 // Content doesn't fill viewport, load more automatically
2604 loadMoreMyEvents();
2605 }
2606 }
2607 }, 100); // Small delay to ensure DOM is updated
2608 }
2609 } catch (error) {
2610 console.error("Failed to load events:", error);
2611 alert("Failed to load events: " + error.message);
2612 } finally {
2613 isLoadingMyEvents = false;
2614 }
2615 }
2616
2617 async function loadMoreMyEvents() {
2618 if (!isLoadingMyEvents && hasMoreMyEvents) {
2619 await loadMyEvents(false);
2620 }
2621 }
2622
2623 function handleMyEventsScroll(event) {
2624 const { scrollTop, scrollHeight, clientHeight } = event.target;
2625 const threshold = 100; // Load more when 100px from bottom
2626
2627 if (scrollHeight - scrollTop - clientHeight < threshold) {
2628 loadMoreMyEvents();
2629 }
2630 }
2631
2632 async function loadAllEvents(reset = false, authors = null) {
2633 if (
2634 !isLoggedIn ||
2635 (userRole !== "read" &&
2636 userRole !== "write" &&
2637 userRole !== "admin" &&
2638 userRole !== "owner")
2639 ) {
2640 alert("Read, write, admin, or owner permission required");
2641 return;
2642 }
2643
2644 if (isLoadingEvents) return;
2645
2646 // Always load fresh data when feed becomes visible (reset = true)
2647 // Skip cache check to ensure fresh data every time
2648
2649 isLoadingEvents = true;
2650
2651 // Reset timestamps when doing a fresh load
2652 if (reset) {
2653 oldestEventTimestamp = null;
2654 newestEventTimestamp = null;
2655 }
2656
2657 try {
2658 // Use Nostr WebSocket to fetch events with timestamp-based pagination
2659 // Load 100 events on initial load, otherwise use 200 for pagination
2660 console.log(
2661 "Loading events with authors filter:",
2662 authors,
2663 "including delete events",
2664 );
2665
2666 // For reset, use current timestamp to get the most recent events
2667 const untilTimestamp = reset
2668 ? Math.floor(Date.now() / 1000)
2669 : oldestEventTimestamp;
2670
2671 // Merge eventsViewFilter with pagination params
2672 // eventsViewFilter takes precedence for authors if set, otherwise use the authors param
2673 const filterAuthors = eventsViewFilter.authors || authors;
2674 const events = await fetchAllEvents({
2675 ...eventsViewFilter,
2676 limit: reset ? 100 : 200,
2677 until: eventsViewFilter.until || untilTimestamp,
2678 authors: filterAuthors,
2679 });
2680 console.log("Received events:", events.length, "events");
2681 if (authors && events.length > 0) {
2682 const nonUserEvents = events.filter(
2683 (event) => event.pubkey && event.pubkey !== userPubkey,
2684 );
2685 if (nonUserEvents.length > 0) {
2686 console.warn(
2687 "Server returned non-user events:",
2688 nonUserEvents.length,
2689 "out of",
2690 events.length,
2691 );
2692 }
2693 }
2694
2695 if (reset) {
2696 allEvents = events.sort((a, b) => b.created_at - a.created_at);
2697 // Update global cache
2698 updateGlobalCache(events);
2699 } else {
2700 allEvents = [...allEvents, ...events].sort(
2701 (a, b) => b.created_at - a.created_at,
2702 );
2703 // Update global cache with all events
2704 updateGlobalCache(allEvents);
2705 }
2706
2707 // Update oldest timestamp for next pagination
2708 if (events.length > 0) {
2709 const oldestInBatch = Math.min(
2710 ...events.map((e) => e.created_at),
2711 );
2712 if (
2713 !oldestEventTimestamp ||
2714 oldestInBatch < oldestEventTimestamp
2715 ) {
2716 oldestEventTimestamp = oldestInBatch;
2717 }
2718 }
2719
2720 hasMoreEvents = events.length === (reset ? 1000 : 200);
2721
2722 // Auto-load more events if content doesn't fill viewport and more events are available
2723 // Only do this on initial load (reset = true) to avoid interfering with scroll-based loading
2724 if (reset && hasMoreEvents) {
2725 setTimeout(() => {
2726 // Only check viewport if we're currently on the All Events tab
2727 if (selectedTab === "events") {
2728 const eventsContainers = document.querySelectorAll(
2729 ".events-view-content",
2730 );
2731 // The All Events container should be the first one (only container now)
2732 const allEventsContainer = eventsContainers[0];
2733 if (
2734 allEventsContainer &&
2735 allEventsContainer.scrollHeight <=
2736 allEventsContainer.clientHeight
2737 ) {
2738 // Content doesn't fill viewport, load more automatically
2739 loadMoreEvents();
2740 }
2741 }
2742 }, 100); // Small delay to ensure DOM is updated
2743 }
2744 } catch (error) {
2745 console.error("Failed to load events:", error);
2746 alert("Failed to load events: " + error.message);
2747 } finally {
2748 isLoadingEvents = false;
2749 }
2750 }
2751
2752 async function loadMoreEvents() {
2753 await loadAllEvents(false);
2754 }
2755
2756 function handleScroll(event) {
2757 const { scrollTop, scrollHeight, clientHeight } = event.target;
2758 const threshold = 100; // Load more when 100px from bottom
2759
2760 if (scrollHeight - scrollTop - clientHeight < threshold) {
2761 loadMoreEvents();
2762 }
2763 }
2764
2765 // Load events when events tab is selected (only if no events loaded yet)
2766 // Track if we've already attempted to load events to prevent infinite loops
2767 let hasAttemptedEventLoad = false;
2768
2769 $: if (
2770 selectedTab === "events" &&
2771 isLoggedIn &&
2772 (userRole === "read" ||
2773 userRole === "write" ||
2774 userRole === "admin" ||
2775 userRole === "owner") &&
2776 allEvents.length === 0 &&
2777 !hasAttemptedEventLoad &&
2778 !isLoadingEvents
2779 ) {
2780 hasAttemptedEventLoad = true;
2781 const authors = showOnlyMyEvents && userPubkey ? [userPubkey] : null;
2782 loadAllEvents(true, authors);
2783 }
2784
2785 // Reset the attempt flag when switching tabs or when showOnlyMyEvents changes
2786 $: if (
2787 selectedTab !== "events" ||
2788 (selectedTab === "events" && allEvents.length > 0)
2789 ) {
2790 hasAttemptedEventLoad = false;
2791 }
2792
2793 // NIP-98 authentication helper
2794 async function createNIP98AuthHeader(url, method) {
2795 if (!isLoggedIn || !userPubkey) {
2796 throw new Error("Not logged in");
2797 }
2798 if (!userSigner) {
2799 throw new Error("No valid signer available");
2800 }
2801
2802 // Create NIP-98 auth event
2803 const authEvent = {
2804 kind: 27235,
2805 created_at: Math.floor(Date.now() / 1000),
2806 tags: [
2807 ["u", url],
2808 ["method", method.toUpperCase()],
2809 ],
2810 content: "",
2811 pubkey: userPubkey,
2812 };
2813
2814 const signedEvent = await userSigner.signEvent(authEvent);
2815
2816 // Encode as base64
2817 const eventJson = JSON.stringify(signedEvent);
2818 const base64Event = btoa(eventJson);
2819
2820 return `Nostr ${base64Event}`;
2821 }
2822
2823 // NIP-98 authentication helper (for sprocket functions)
2824 async function createNIP98Auth(method, url) {
2825 if (!isLoggedIn || !userPubkey) {
2826 throw new Error("Not logged in");
2827 }
2828 if (!userSigner) {
2829 throw new Error("No valid signer available");
2830 }
2831
2832 // Create NIP-98 auth event
2833 const authEvent = {
2834 kind: 27235,
2835 created_at: Math.floor(Date.now() / 1000),
2836 tags: [
2837 ["u", url],
2838 ["method", method.toUpperCase()],
2839 ],
2840 content: "",
2841 pubkey: userPubkey,
2842 };
2843
2844 const signedEvent = await userSigner.signEvent(authEvent);
2845
2846 // Encode as base64
2847 const eventJson = JSON.stringify(signedEvent);
2848 const base64Event = btoa(eventJson);
2849
2850 return base64Event;
2851 }
2852
2853 // Compose tab functions
2854 function reformatJson() {
2855 try {
2856 if (!composeEventJson.trim()) {
2857 alert("Please enter some JSON to reformat");
2858 return;
2859 }
2860 const parsed = JSON.parse(composeEventJson);
2861 composeEventJson = JSON.stringify(parsed, null, 2);
2862 } catch (error) {
2863 alert("Invalid JSON: " + error.message);
2864 }
2865 }
2866
2867 async function signEvent() {
2868 try {
2869 if (!composeEventJson.trim()) {
2870 alert("Please enter an event to sign");
2871 return;
2872 }
2873
2874 if (!isLoggedIn || !userPubkey) {
2875 alert("Please log in to sign events");
2876 return;
2877 }
2878
2879 if (!userSigner) {
2880 alert(
2881 "No signer available. Please log in with a valid authentication method.",
2882 );
2883 return;
2884 }
2885
2886 const event = JSON.parse(composeEventJson);
2887
2888 // Update event with current user's pubkey and timestamp
2889 event.pubkey = userPubkey;
2890 event.created_at = Math.floor(Date.now() / 1000);
2891
2892 // Remove any existing id and sig to ensure fresh signing
2893 delete event.id;
2894 delete event.sig;
2895
2896 // Sign the event using the real signer
2897 const signedEvent = await userSigner.signEvent(event);
2898
2899 // Update the compose area with the signed event
2900 composeEventJson = JSON.stringify(signedEvent, null, 2);
2901
2902 // Show success feedback
2903 alert("Event signed successfully!");
2904 } catch (error) {
2905 console.error("Error signing event:", error);
2906 alert("Error signing event: " + error.message);
2907 }
2908 }
2909
2910 async function publishEvent() {
2911 // Clear any previous errors
2912 composePublishError = "";
2913
2914 try {
2915 if (!composeEventJson.trim()) {
2916 composePublishError = "Please enter an event to publish";
2917 return;
2918 }
2919
2920 if (!isLoggedIn) {
2921 composePublishError = "Please log in to publish events";
2922 return;
2923 }
2924
2925 if (!userSigner) {
2926 composePublishError = "No signer available. Please log in with a valid authentication method.";
2927 return;
2928 }
2929
2930 let event;
2931 try {
2932 event = JSON.parse(composeEventJson);
2933 } catch (parseError) {
2934 composePublishError = `Invalid JSON: ${parseError.message}`;
2935 return;
2936 }
2937
2938 // Validate that the event has required fields
2939 if (!event.id || !event.sig) {
2940 composePublishError = 'Event must be signed before publishing. Please click "Sign" first.';
2941 return;
2942 }
2943
2944 // Pre-check: validate user has write permission
2945 if (userRole === "read") {
2946 composePublishError = `Permission denied: Your current role is "${userRole}" which does not allow publishing events. Contact a relay administrator to upgrade your permissions.`;
2947 return;
2948 }
2949
2950 // Publish to the ORLY relay using WebSocket (same address as current page)
2951 const wsProtocol = window.location.protocol.startsWith('https') ? 'wss:' : 'ws:';
2952 const relayUrl = `${wsProtocol}//${window.location.host}/`;
2953
2954 // Use the authentication module to publish the event
2955 const result = await publishEventWithAuth(
2956 relayUrl,
2957 event,
2958 userSigner,
2959 userPubkey,
2960 );
2961
2962 if (result.success) {
2963 composePublishError = "";
2964
2965 if (!composeLocalOnly) {
2966 // Also broadcast to configured relays
2967 try {
2968 await nostrClient.publish(event);
2969 } catch (broadcastErr) {
2970 console.warn("Broadcast to other relays failed:", broadcastErr);
2971 }
2972 alert("Event published and broadcast to relays!");
2973 } else {
2974 alert("Event published to this relay only.");
2975 }
2976 } else {
2977 // Parse the error reason and provide helpful guidance
2978 const reason = result.reason || "Unknown error";
2979 composePublishError = formatPublishError(reason, event.kind);
2980 }
2981 } catch (error) {
2982 console.error("Error publishing event:", error);
2983 const errorMsg = error.message || "Unknown error";
2984 composePublishError = formatPublishError(errorMsg, null);
2985 }
2986 }
2987
2988 // Helper function to format publish errors with helpful guidance
2989 function formatPublishError(reason, eventKind) {
2990 const lowerReason = reason.toLowerCase();
2991
2992 // Check for policy-related errors
2993 if (lowerReason.includes("policy") || lowerReason.includes("blocked") || lowerReason.includes("denied")) {
2994 let msg = `Policy Error: ${reason}`;
2995 if (eventKind !== null) {
2996 msg += `\n\nKind ${eventKind} may be restricted by the relay's policy configuration.`;
2997 }
2998 if (policyEnabled) {
2999 msg += "\n\nThe relay has policy enforcement enabled. Contact a relay administrator to allow this event kind or adjust your permissions.";
3000 }
3001 return msg;
3002 }
3003
3004 // Check for permission/auth errors
3005 if (lowerReason.includes("auth") || lowerReason.includes("permission") || lowerReason.includes("unauthorized")) {
3006 return `Permission Error: ${reason}\n\nYour current permissions may not allow publishing this type of event. Current role: ${userRole || "unknown"}. Contact a relay administrator to upgrade your permissions.`;
3007 }
3008
3009 // Check for kind-specific restrictions
3010 if (lowerReason.includes("kind") || lowerReason.includes("not allowed") || lowerReason.includes("restricted")) {
3011 let msg = `Event Type Error: ${reason}`;
3012 if (eventKind !== null) {
3013 msg += `\n\nKind ${eventKind} is not currently allowed on this relay.`;
3014 }
3015 msg += "\n\nThe relay administrator may need to update the policy configuration to allow this event kind.";
3016 return msg;
3017 }
3018
3019 // Check for rate limiting
3020 if (lowerReason.includes("rate") || lowerReason.includes("limit") || lowerReason.includes("too many")) {
3021 return `Rate Limit Error: ${reason}\n\nPlease wait a moment before trying again.`;
3022 }
3023
3024 // Check for size limits
3025 if (lowerReason.includes("size") || lowerReason.includes("too large") || lowerReason.includes("content")) {
3026 return `Size Limit Error: ${reason}\n\nThe event may exceed the relay's size limits. Try reducing the content length.`;
3027 }
3028
3029 // Default error message
3030 return `Publishing failed: ${reason}`;
3031 }
3032
3033 // Clear the compose publish error
3034 function clearComposeError() {
3035 composePublishError = "";
3036 }
3037
3038 // Persist selected tab to local storage
3039 $: {
3040 localStorage.setItem("selectedTab", selectedTab);
3041 }
3042
3043 // Handle permission view switching
3044 function setViewAsRole(role) {
3045 viewAsRole = role;
3046 localStorage.setItem("viewAsRole", role);
3047 console.log(
3048 "View as role changed to:",
3049 role,
3050 "Current effective role:",
3051 currentEffectiveRole,
3052 );
3053 }
3054
3055 // Reactive statement for current effective role
3056 $: currentEffectiveRole =
3057 viewAsRole && viewAsRole !== "" ? viewAsRole : userRole;
3058
3059 // Initialize viewAsRole from local storage if available
3060 viewAsRole = localStorage.getItem("viewAsRole") || "";
3061
3062 // Get available roles based on user's actual role
3063 function getAvailableRoles() {
3064 const allRoles = ["owner", "admin", "write", "read"];
3065 const userRoleIndex = allRoles.indexOf(userRole);
3066 if (userRoleIndex === -1) return ["read"]; // Default to read if role not found
3067 return allRoles.slice(userRoleIndex); // Return current role and all lower roles
3068 }
3069 </script>
3070
3071 <!-- Header -->
3072 <Header
3073 {isDarkTheme}
3074 {isLoggedIn}
3075 {userRole}
3076 {currentEffectiveRole}
3077 on:openRelayModal={openRelayConnectModal}
3078 on:relayChanged={handleRelayChange}
3079 on:toggleMobileMenu={toggleMobileMenu}
3080 />
3081
3082 <!-- User Menu Dropdown -->
3083 <UserMenu
3084 {isLoggedIn}
3085 {userProfile}
3086 {userPubkey}
3087 {userRole}
3088 {currentEffectiveRole}
3089 {isDarkTheme}
3090 on:logout={handleLogout}
3091 on:toggleTheme={toggleTheme}
3092 on:setViewAsRole={(e) => setViewAsRole(e.detail)}
3093 on:openRelayModal={openRelayConnectModal}
3094 />
3095
3096 <!-- Main Content Area -->
3097 <div class="app-container" class:dark-theme={isDarkTheme}>
3098 <!-- Sidebar Accordion -->
3099 <SidebarAccordion
3100 {isLoggedIn}
3101 {userProfile}
3102 {userPubkey}
3103 {currentEffectiveRole}
3104 version={relayVersion}
3105 mobileOpen={mobileMenuOpen}
3106 {aclMode}
3107 {sprocketEnabled}
3108 {policyEnabled}
3109 {nrcEnabled}
3110 {blossomEnabled}
3111 isOrlyRelay={$isOrlyRelay}
3112 on:navigate={handleAccordionNavigate}
3113 on:closeMobileMenu={closeMobileMenu}
3114 on:showAbout={handleShowAbout}
3115 on:openLoginModal={openLoginModal}
3116 />
3117
3118 <!-- Main Content -->
3119 <main class="main-content">
3120 {#if $activeView === "feed"}
3121 <FeedView
3122 {isLoggedIn}
3123 {userPubkey}
3124 {userContactList}
3125 />
3126 {:else if $activeView?.startsWith("chat-")}
3127 <ChatView
3128 {isLoggedIn}
3129 {userPubkey}
3130 {userSigner}
3131 />
3132 {:else if $activeView?.startsWith("library-")}
3133 <LibraryView
3134 {isLoggedIn}
3135 {userPubkey}
3136 {userSigner}
3137 subView={$activeView.replace("library-", "")}
3138 />
3139 {:else if $activeView?.startsWith("admin-")}
3140 <!-- Admin views: bridge to existing selectedTab routing -->
3141 {#if selectedTab === "export"}
3142 <ExportView
3143 {isLoggedIn}
3144 {currentEffectiveRole}
3145 {aclMode}
3146 {isExporting}
3147 on:exportMyEvents={exportMyEvents}
3148 on:exportAllEvents={exportAllEvents}
3149 on:openLoginModal={openLoginModal}
3150 />
3151 {:else if selectedTab === "import"}
3152 <ImportView
3153 {isLoggedIn}
3154 {currentEffectiveRole}
3155 {selectedFile}
3156 {aclMode}
3157 {importMessage}
3158 on:fileSelect={handleFileSelect}
3159 on:importEvents={importEvents}
3160 on:openLoginModal={openLoginModal}
3161 />
3162 {:else if selectedTab === "events"}
3163 {#if isLoggedIn && (userRole === "read" || userRole === "write" || userRole === "admin" || userRole === "owner")}
3164 <EventsView
3165 {isLoggedIn}
3166 {userRole}
3167 {userPubkey}
3168 {filteredEvents}
3169 {expandedEvents}
3170 {isLoadingEvents}
3171 {showOnlyMyEvents}
3172 {showFilterBuilder}
3173 on:scroll={handleScroll}
3174 on:toggleEventExpansion={(e) => toggleEventExpansion(e.detail)}
3175 on:deleteEvent={(e) => deleteEvent(e.detail)}
3176 on:copyEventToClipboard={(e) =>
3177 copyEventToClipboard(e.detail.event, e.detail.e)}
3178 on:toggleChange={handleToggleChange}
3179 on:loadAllEvents={(e) =>
3180 loadAllEvents(e.detail.refresh, e.detail.authors)}
3181 on:toggleFilterBuilder={toggleFilterBuilder}
3182 on:filterApply={handleFilterApply}
3183 on:filterClear={handleFilterClear}
3184 />
3185 {:else if isLoggedIn && !userRole}
3186 <div class="events-loading-permissions">
3187 <div class="spinner"></div>
3188 <p>Checking permissions...</p>
3189 </div>
3190 {:else}
3191 <div class="permission-denied">
3192 <p>Read, write, admin, or owner permission required to view events.</p>
3193 </div>
3194 {/if}
3195 {:else if selectedTab === "blossom"}
3196 {#key $relayUrl}
3197 <BlossomView
3198 {isLoggedIn}
3199 {userPubkey}
3200 {userSigner}
3201 {currentEffectiveRole}
3202 on:openLoginModal={openLoginModal}
3203 />
3204 {/key}
3205 {:else if selectedTab === "compose"}
3206 <ComposeView
3207 bind:composeEventJson
3208 bind:localOnly={composeLocalOnly}
3209 {userPubkey}
3210 {userRole}
3211 {policyEnabled}
3212 publishError={composePublishError}
3213 on:reformatJson={reformatJson}
3214 on:signEvent={signEvent}
3215 on:publishEvent={publishEvent}
3216 on:clearError={clearComposeError}
3217 />
3218 {:else if selectedTab === "managed-acl"}
3219 <div class="managed-acl-view">
3220 {#if aclMode !== "managed"}
3221 <div class="acl-mode-warning">
3222 <h3>⚠️ Managed ACL Mode Not Active</h3>
3223 <p>
3224 To use the Managed ACL interface, you need to set
3225 the ACL mode to "managed" in your relay
3226 configuration.
3227 </p>
3228 <p>
3229 Current ACL mode: <strong
3230 >{aclMode || "unknown"}</strong
3231 >
3232 </p>
3233 <p>
3234 Please set <code>ORLY_ACL_MODE=managed</code> in your
3235 environment variables and restart the relay.
3236 </p>
3237 </div>
3238 {:else if isLoggedIn && userRole === "owner"}
3239 {#key $relayUrl}
3240 <ManagedACL {userSigner} {userPubkey} />
3241 {/key}
3242 {:else}
3243 <div class="access-denied">
3244 <p>
3245 Please log in with owner permissions to access
3246 managed ACL configuration.
3247 </p>
3248 <button class="login-btn" on:click={openLoginModal}
3249 >Log In</button
3250 >
3251 </div>
3252 {/if}
3253 </div>
3254 {:else if selectedTab === "curation"}
3255 <div class="curation-view-container">
3256 {#if aclMode !== "curating"}
3257 <div class="acl-mode-warning">
3258 <h3>Curating Mode Not Active</h3>
3259 <p>
3260 To use the Curation interface, you need to set
3261 the ACL mode to "curating" in your relay
3262 configuration.
3263 </p>
3264 <p>
3265 Current ACL mode: <strong
3266 >{aclMode || "unknown"}</strong
3267 >
3268 </p>
3269 <p>
3270 Please set <code>ORLY_ACL_MODE=curating</code> in your
3271 environment variables and restart the relay.
3272 </p>
3273 </div>
3274 {:else if isLoggedIn && userRole === "owner"}
3275 {#key $relayUrl}
3276 <CurationView {userSigner} {userPubkey} />
3277 {/key}
3278 {:else}
3279 <div class="access-denied">
3280 <p>
3281 Please log in with owner permissions to access
3282 curation configuration.
3283 </p>
3284 <button class="login-btn" on:click={openLoginModal}
3285 >Log In</button
3286 >
3287 </div>
3288 {/if}
3289 </div>
3290 {:else if selectedTab === "sprocket"}
3291 <SprocketView
3292 {isLoggedIn}
3293 {userRole}
3294 {sprocketStatus}
3295 {isLoadingSprocket}
3296 {sprocketUploadFile}
3297 bind:sprocketScript
3298 {sprocketMessage}
3299 {sprocketMessageType}
3300 {sprocketVersions}
3301 on:restartSprocket={restartSprocket}
3302 on:deleteSprocket={deleteSprocket}
3303 on:sprocketFileSelect={handleSprocketFileSelect}
3304 on:uploadSprocketScript={uploadSprocketScript}
3305 on:saveSprocket={saveSprocket}
3306 on:loadSprocket={loadSprocket}
3307 on:loadVersions={loadVersions}
3308 on:loadVersion={(e) => loadVersion(e.detail)}
3309 on:deleteVersion={(e) => deleteVersion(e.detail)}
3310 on:openLoginModal={openLoginModal}
3311 />
3312 {:else if selectedTab === "policy"}
3313 <PolicyView
3314 {isLoggedIn}
3315 {userRole}
3316 {isPolicyAdmin}
3317 {policyEnabled}
3318 bind:policyJson
3319 {isLoadingPolicy}
3320 {policyMessage}
3321 {policyMessageType}
3322 validationErrors={policyValidationErrors}
3323 {policyFollows}
3324 on:loadPolicy={loadPolicy}
3325 on:validatePolicy={validatePolicy}
3326 on:savePolicy={savePolicy}
3327 on:formatJson={formatPolicyJson}
3328 on:addPolicyAdmin={addPolicyAdmin}
3329 on:removePolicyAdmin={removePolicyAdmin}
3330 on:refreshFollows={refreshFollows}
3331 on:openLoginModal={openLoginModal}
3332 />
3333 {:else if selectedTab === "relay-connect"}
3334 {#key $relayUrl}
3335 <RelayConnectView
3336 {isLoggedIn}
3337 {userRole}
3338 {userSigner}
3339 {userPubkey}
3340 on:openLoginModal={openLoginModal}
3341 />
3342 {/key}
3343 {:else if selectedTab === "logs"}
3344 {#key $relayUrl}
3345 <LogView
3346 {isLoggedIn}
3347 {userRole}
3348 {userSigner}
3349 on:openLoginModal={openLoginModal}
3350 />
3351 {/key}
3352 {:else if selectedTab === "recovery"}
3353 <div class="recovery-tab">
3354 <div>
3355 <h3>Event Recovery</h3>
3356 <p>Search and recover old versions of replaceable events</p>
3357 </div>
3358
3359 <div class="recovery-controls-card">
3360 <div class="recovery-controls">
3361 <div class="kind-selector">
3362 <label for="recovery-kind">Select Event Kind:</label
3363 >
3364 <select
3365 id="recovery-kind"
3366 bind:value={recoverySelectedKind}
3367 on:change={selectRecoveryKind}
3368 >
3369 <option value={null}
3370 >Choose a replaceable kind...</option
3371 >
3372 {#each replaceableKinds as kind}
3373 <option value={kind.value}
3374 >{kind.label}</option
3375 >
3376 {/each}
3377 </select>
3378 </div>
3379
3380 <div class="custom-kind-input">
3381 <label for="custom-kind"
3382 >Or enter custom kind number:</label
3383 >
3384 <input
3385 id="custom-kind"
3386 type="number"
3387 bind:value={recoveryCustomKind}
3388 on:input={handleCustomKindInput}
3389 placeholder="e.g., 10001"
3390 min="0"
3391 />
3392 </div>
3393 </div>
3394 </div>
3395
3396 {#if (recoverySelectedKind !== null && recoverySelectedKind !== undefined && recoverySelectedKind >= 0) || (recoveryCustomKind !== "" && parseInt(recoveryCustomKind) >= 0)}
3397 <div class="recovery-results">
3398 {#if isLoadingRecovery}
3399 <div class="loading">Loading events...</div>
3400 {:else if recoveryEvents.length === 0}
3401 <div class="no-events">
3402 No events found for this kind
3403 </div>
3404 {:else}
3405 <div class="events-list">
3406 {#each recoveryEvents as event}
3407 {@const isCurrent = isCurrentVersion(event)}
3408 <div
3409 class="event-item"
3410 class:old-version={!isCurrent}
3411 >
3412 <div class="event-header">
3413 <div class="event-header-left">
3414 <span class="event-kind">
3415 {#if isCurrent}
3416 Current Version{/if}</span
3417 >
3418 <span class="event-timestamp">
3419 {new Date(
3420 event.created_at * 1000,
3421 ).toLocaleString()}
3422 </span>
3423 </div>
3424 <div class="event-header-actions">
3425 {#if !isCurrent}
3426 <button
3427 class="repost-all-button"
3428 on:click={() =>
3429 repostEventToAll(
3430 event,
3431 )}
3432 >
3433 🌐 Repost to All
3434 </button>
3435 {#if currentEffectiveRole !== "read"}
3436 <button
3437 class="repost-button"
3438 on:click={() =>
3439 repostEvent(
3440 event,
3441 )}
3442 >
3443 🔄 Repost
3444 </button>
3445 {/if}
3446 {/if}
3447 <button
3448 class="copy-json-btn"
3449 on:click|stopPropagation={(
3450 e,
3451 ) =>
3452 copyEventToClipboard(
3453 event,
3454 e,
3455 )}
3456 >
3457 📋 Copy JSON
3458 </button>
3459 </div>
3460 </div>
3461
3462 <div class="event-content">
3463 <pre
3464 class="event-json">{JSON.stringify(
3465 event,
3466 null,
3467 2,
3468 )}</pre>
3469 </div>
3470 </div>
3471 {/each}
3472 </div>
3473
3474 {#if recoveryHasMore}
3475 <button
3476 class="load-more"
3477 on:click={loadRecoveryEvents}
3478 disabled={isLoadingRecovery}
3479 >
3480 Load More Events
3481 </button>
3482 {/if}
3483 {/if}
3484 </div>
3485 {/if}
3486 </div>
3487 {:else if searchTabs.some((tab) => tab.id === selectedTab)}
3488 {#each searchTabs as searchTab}
3489 {#if searchTab.id === selectedTab}
3490 <div class="search-results-view">
3491 <div class="search-results-header">
3492 <h2>🔍 {searchTab.label}</h2>
3493 <button
3494 class="refresh-btn"
3495 on:click={() =>
3496 loadSearchResults(
3497 searchTab.id,
3498 true,
3499 )}
3500 disabled={searchResults.get(searchTab.id)
3501 ?.isLoading}
3502 >
3503 🔄 Refresh
3504 </button>
3505 </div>
3506
3507 <!-- FilterDisplay - show active filter -->
3508 <FilterDisplay
3509 filter={searchResults.get(searchTab.id)?.filter || {}}
3510 on:sweep={() => handleFilterSweep(searchTab.id)}
3511 />
3512
3513 <div
3514 class="search-results-content"
3515 on:scroll={(e) =>
3516 handleSearchScroll(e, searchTab.id)}
3517 >
3518 {#if searchResults.get(searchTab.id)?.events?.length > 0}
3519 {#each searchResults.get(searchTab.id).events as event}
3520 <div
3521 class="search-result-item"
3522 class:expanded={expandedEvents.has(
3523 event.id,
3524 )}
3525 >
3526 <div
3527 class="search-result-row"
3528 on:click={() =>
3529 toggleEventExpansion(event.id)}
3530 on:keydown={(e) =>
3531 e.key === "Enter" &&
3532 toggleEventExpansion(event.id)}
3533 role="button"
3534 tabindex="0"
3535 >
3536 <div class="search-result-avatar">
3537 <div class="avatar-placeholder">
3538 👤
3539 </div>
3540 </div>
3541 <div class="search-result-info">
3542 <div
3543 class="search-result-author"
3544 >
3545 {truncatePubkey(
3546 event.pubkey,
3547 )}
3548 </div>
3549 <div class="search-result-kind">
3550 <span class="kind-number"
3551 >{event.kind}</span
3552 >
3553 <span class="kind-name"
3554 >{getKindName(
3555 event.kind,
3556 )}</span
3557 >
3558 </div>
3559 </div>
3560 <div class="search-result-content">
3561 <div class="event-timestamp">
3562 {formatTimestamp(
3563 event.created_at,
3564 )}
3565 </div>
3566 <div
3567 class="event-content-single-line"
3568 >
3569 {truncateContent(
3570 event.content,
3571 )}
3572 </div>
3573 </div>
3574 {#if event.kind !== 5 && (userRole === "admin" || userRole === "owner" || (userRole === "write" && event.pubkey && event.pubkey === userPubkey))}
3575 <button
3576 class="delete-btn"
3577 on:click|stopPropagation={() =>
3578 deleteEvent(event.id)}
3579 >
3580 🗑️
3581 </button>
3582 {/if}
3583 </div>
3584 {#if expandedEvents.has(event.id)}
3585 <div class="search-result-details">
3586 <div class="json-container">
3587 <pre
3588 class="event-json">{JSON.stringify(
3589 event,
3590 null,
3591 2,
3592 )}</pre>
3593 <button
3594 class="copy-json-btn"
3595 on:click|stopPropagation={(
3596 e,
3597 ) =>
3598 copyEventToClipboard(
3599 event,
3600 e,
3601 )}
3602 title="Copy minified JSON to clipboard"
3603 >
3604 📋
3605 </button>
3606 </div>
3607 </div>
3608 {/if}
3609 </div>
3610 {/each}
3611 {:else if !searchResults.get(searchTab.id)?.isLoading}
3612 <div class="no-search-results">
3613 <p>
3614 No search results found.
3615 </p>
3616 </div>
3617 {/if}
3618
3619 {#if searchResults.get(searchTab.id)?.isLoading}
3620 <div class="loading-search-results">
3621 <div class="loading-spinner"></div>
3622 <p>Searching...</p>
3623 </div>
3624 {/if}
3625
3626 {#if !searchResults.get(searchTab.id)?.hasMore && searchResults.get(searchTab.id)?.events?.length > 0}
3627 <div class="end-of-search-results">
3628 <p>No more search results to load.</p>
3629 </div>
3630 {/if}
3631 </div>
3632 </div>
3633 {/if}
3634 {/each}
3635 {:else}
3636 <div class="welcome-message">
3637 <p>Select a tab from the Admin section.</p>
3638 </div>
3639 {/if}
3640 {:else}
3641 <div class="welcome-message">
3642 {#if isLoggedIn}
3643 <p>
3644 Welcome {userProfile?.name ||
3645 userPubkey.slice(0, 8) + "..."}
3646 </p>
3647 {:else}
3648 <p>Log in to access your user dashboard</p>
3649 {/if}
3650 </div>
3651 {/if}
3652 </main>
3653 </div>
3654
3655 <!-- Settings Drawer -->
3656 {#if showSettingsDrawer}
3657 <div
3658 class="drawer-overlay"
3659 on:click={closeSettingsDrawer}
3660 on:keydown={(e) => e.key === "Escape" && closeSettingsDrawer()}
3661 role="button"
3662 tabindex="0"
3663 >
3664 <div
3665 class="settings-drawer"
3666 class:dark-theme={isDarkTheme}
3667 on:click|stopPropagation
3668 on:keydown|stopPropagation
3669 >
3670 <div class="drawer-header">
3671 <h2>Settings</h2>
3672 <button class="close-btn" on:click={closeSettingsDrawer}
3673 >✕</button
3674 >
3675 </div>
3676 <div class="drawer-content">
3677 {#if userProfile}
3678 <div class="profile-section">
3679 <div class="profile-hero">
3680 {#if userProfile.banner}
3681 <img
3682 src={userProfile.banner}
3683 alt="Profile banner"
3684 class="profile-banner"
3685 />
3686 {/if}
3687 <!-- Logout button floating in top-right corner of banner -->
3688 <button
3689 class="logout-btn floating"
3690 on:click={handleLogout}>Log out</button
3691 >
3692 <!-- Avatar overlaps the bottom edge of the banner by 50% -->
3693 {#if userProfile.picture}
3694 <img
3695 src={userProfile.picture}
3696 alt="User avatar"
3697 class="profile-avatar overlap"
3698 />
3699 {:else}
3700 <div class="profile-avatar-placeholder overlap">
3701 👤
3702 </div>
3703 {/if}
3704 <!-- Username and nip05 to the right of the avatar, above the bottom edge -->
3705 <div class="name-row">
3706 <h3 class="profile-username">
3707 {userProfile.name || "Unknown User"}
3708 </h3>
3709 {#if userProfile.nip05}
3710 <span class="profile-nip05-inline"
3711 >{userProfile.nip05}</span
3712 >
3713 {/if}
3714 </div>
3715 </div>
3716
3717 <!-- About text in a box underneath, with avatar overlapping its top edge -->
3718 {#if userProfile.about}
3719 <div class="about-card">
3720 <p class="profile-about">{@html aboutHtml}</p>
3721 </div>
3722 {/if}
3723 </div>
3724
3725 <!-- View as section -->
3726 {#if userRole && userRole !== "read"}
3727 <div class="view-as-section">
3728 <h3>View as Role</h3>
3729 <p>
3730 See the interface as it appears for different
3731 permission levels:
3732 </p>
3733 <div class="radio-group">
3734 {#each getAvailableRoles() as role}
3735 <label class="radio-label">
3736 <input
3737 type="radio"
3738 name="viewAsRole"
3739 value={role}
3740 checked={currentEffectiveRole ===
3741 role}
3742 on:change={() =>
3743 setViewAsRole(
3744 role === userRole
3745 ? ""
3746 : role,
3747 )}
3748 />
3749 {role.charAt(0).toUpperCase() +
3750 role.slice(1)}{role === userRole
3751 ? " (Default)"
3752 : ""}
3753 </label>
3754 {/each}
3755 </div>
3756 </div>
3757 {/if}
3758 {:else if isLoggedIn && userPubkey}
3759 <div class="profile-loading-section">
3760 <!-- Logout button in top-right corner -->
3761 <button
3762 class="logout-btn floating"
3763 on:click={handleLogout}>Log out</button
3764 >
3765 <h3>Profile Loading</h3>
3766 <p>Your profile metadata is being loaded...</p>
3767 <button
3768 class="retry-profile-btn"
3769 on:click={fetchProfileIfMissing}
3770 >
3771 Retry Loading Profile
3772 </button>
3773 <div class="user-pubkey-display">
3774 <strong>Public Key:</strong>
3775 {userPubkey.slice(0, 16)}...{userPubkey.slice(-8)}
3776 </div>
3777 </div>
3778 {/if}
3779
3780 <!-- Relay Connection Section (for standalone mode) -->
3781 {#if $isStandaloneMode}
3782 <div class="relay-section">
3783 <h3>Connected Relay</h3>
3784 {#if $relayInfoStore}
3785 <div class="relay-info-card">
3786 <div class="relay-name">{$relayInfoStore.name || "Unknown relay"}</div>
3787 {#if $relayInfoStore.description}
3788 <div class="relay-description">{$relayInfoStore.description}</div>
3789 {/if}
3790 <div class="relay-url">{$relayUrl}</div>
3791 </div>
3792 {:else}
3793 <div class="relay-disconnected">
3794 <span class="status-dot disconnected"></span>
3795 Not connected
3796 </div>
3797 {/if}
3798 <button class="change-relay-btn" on:click={() => { closeSettingsDrawer(); openRelayConnectModal(); }}>
3799 {$relayInfoStore ? "Change Relay" : "Connect to Relay"}
3800 </button>
3801 </div>
3802 {/if}
3803 </div>
3804 </div>
3805 </div>
3806 {/if}
3807
3808 <!-- Login Modal -->
3809 <LoginModal
3810 bind:showModal={showLoginModal}
3811 {isDarkTheme}
3812 on:login={handleLogin}
3813 on:close={closeLoginModal}
3814 />
3815
3816 <!-- Relay Connect Modal (for standalone mode) -->
3817 <RelayConnectModal
3818 bind:showModal={showRelayConnectModal}
3819 {isDarkTheme}
3820 on:connected={handleRelayConnected}
3821 on:close={closeRelayConnectModal}
3822 />
3823
3824 <!-- About Modal -->
3825 <AboutView
3826 show={showAboutModal}
3827 version={relayVersion}
3828 on:close={() => showAboutModal = false}
3829 />
3830
3831 <!-- Notification Dropdown -->
3832 <NotificationDropdown {userPubkey} {isLoggedIn} />
3833
3834 <!-- Search Overlay -->
3835 <SearchOverlay
3836 on:search={(e) => {
3837 // Bridge to existing search tab system
3838 const filter = { search: e.detail };
3839 createSearchTab(filter, `Search: ${e.detail}`);
3840 }}
3841 />
3842
3843 <style>
3844 :global(html),
3845 :global(body) {
3846 margin: 0;
3847 padding: 0;
3848 overflow: hidden;
3849 height: 100%;
3850 /* Base colors — Light theme */
3851 --bg-color: #FFFFFF;
3852 --header-bg: #F5F5F5;
3853 --sidebar-bg: #F5F5F5;
3854 --card-bg: #F5F5F5;
3855 --panel-bg: #F5F5F5;
3856 --border-color: #E5E5E5;
3857 --text-color: #171717;
3858 --text-muted: #525252;
3859 --input-border: #D4D4D4;
3860 --input-bg: #FFFFFF;
3861 --input-text-color: #171717;
3862 --button-bg: #E5E5E5;
3863 --button-hover-bg: #D4D4D4;
3864 --button-text: #171717;
3865 --button-hover-border: #A3A3A3;
3866
3867 /* Theme colors — Amber accent */
3868 --primary: #F59E0B;
3869 --primary-bg: rgba(245, 158, 11, 0.1);
3870 --secondary: #525252;
3871 --success: #22C55E;
3872 --success-bg: #DCFCE7;
3873 --success-text: #166534;
3874 --info: #F59E0B;
3875 --warning: #EF4444;
3876 --warning-bg: #FEF2F2;
3877 --danger: #EF4444;
3878 --danger-bg: #FEF2F2;
3879 --danger-text: #991B1B;
3880 --error-bg: #FEF2F2;
3881 --error-text: #991B1B;
3882
3883 /* Code colors */
3884 --code-bg: #F5F5F5;
3885 --code-text: #171717;
3886
3887 /* Tab colors */
3888 --tab-inactive-bg: #E5E5E5;
3889
3890 /* Accent colors */
3891 --accent-color: #F59E0B;
3892 --accent-hover-color: #D97706;
3893 }
3894
3895 :global(body.dark-theme) {
3896 /* Base colors — True black for OLED */
3897 --bg-color: #000000;
3898 --header-bg: #0A0A0A;
3899 --sidebar-bg: #0A0A0A;
3900 --card-bg: #0A0A0A;
3901 --panel-bg: #0A0A0A;
3902 --border-color: #1F1F1F;
3903 --text-color: #FFFFFF;
3904 --text-muted: #A3A3A3;
3905 --input-border: #2A2A2A;
3906 --input-bg: #141414;
3907 --input-text-color: #FFFFFF;
3908 --button-bg: #141414;
3909 --button-hover-bg: #1F1F1F;
3910 --button-text: #FFFFFF;
3911 --button-hover-border: #3A3A3A;
3912
3913 /* Theme colors — Amber accent */
3914 --primary: #F59E0B;
3915 --primary-bg: rgba(245, 158, 11, 0.15);
3916 --secondary: #A3A3A3;
3917 --success: #22C55E;
3918 --success-bg: #052E16;
3919 --success-text: #DCFCE7;
3920 --info: #F59E0B;
3921 --warning: #EF4444;
3922 --warning-bg: #450A0A;
3923 --danger: #EF4444;
3924 --danger-bg: #450A0A;
3925 --danger-text: #FEF2F2;
3926 --error-bg: #450A0A;
3927 --error-text: #FEF2F2;
3928
3929 /* Code colors */
3930 --code-bg: #141414;
3931 --code-text: #FFFFFF;
3932
3933 /* Tab colors */
3934 --tab-inactive-bg: #141414;
3935
3936 /* Accent colors */
3937 --accent-color: #F59E0B;
3938 --accent-hover-color: #D97706;
3939 }
3940
3941 .login-btn {
3942 padding: 0.5em 1em;
3943 border: none;
3944 border-radius: 6px;
3945 background-color: var(--primary);
3946 color: #000000;
3947 cursor: pointer;
3948 font-size: 1rem;
3949 font-weight: 500;
3950 transition: background-color 0.2s;
3951 display: inline-flex;
3952 align-items: center;
3953 justify-content: center;
3954 vertical-align: middle;
3955 margin: 0 auto;
3956 padding: 0.5em 1em;
3957 }
3958
3959 .login-btn:hover {
3960 background-color: #45a049;
3961 }
3962
3963 .acl-mode-warning {
3964 padding: 1em;
3965 background-color: #fff3cd;
3966 border: 1px solid #ffeaa7;
3967 border-radius: 8px;
3968 color: #856404;
3969 margin: 20px 0;
3970 }
3971
3972 .acl-mode-warning h3 {
3973 margin: 0 0 15px 0;
3974 color: #856404;
3975 }
3976
3977 .acl-mode-warning p {
3978 margin: 10px 0;
3979 line-height: 1.5;
3980 }
3981
3982 .acl-mode-warning code {
3983 background-color: #f8f9fa;
3984 padding: 2px 6px;
3985 border-radius: 4px;
3986 font-family: monospace;
3987 color: #495057;
3988 }
3989
3990 .placeholder-view {
3991 text-align: center;
3992 padding: 3em 1em;
3993 color: var(--text-muted);
3994 }
3995
3996 .placeholder-view h2 {
3997 margin: 0 0 0.5em;
3998 color: var(--text-color);
3999 font-size: 1.4rem;
4000 }
4001
4002 .placeholder-view p {
4003 margin: 0;
4004 font-size: 0.95rem;
4005 }
4006
4007 /* App Container */
4008 .app-container {
4009 display: flex;
4010 margin-top: 3em;
4011 height: calc(100vh - 3em);
4012 }
4013
4014 /* Main Content */
4015 .main-content {
4016 position: fixed;
4017 left: 200px;
4018 top: 3em;
4019 right: 0;
4020 bottom: 0;
4021 padding: 0;
4022 overflow-y: auto;
4023 background-color: var(--bg-color);
4024 color: var(--text-color);
4025 display: flex;
4026 align-items: center;
4027 justify-content: flex-start;
4028 flex-direction: column;
4029 }
4030
4031 .welcome-message {
4032 text-align: center;
4033 }
4034
4035 .welcome-message p {
4036 font-size: 1.2rem;
4037 }
4038
4039 .logout-btn {
4040 padding: 0.5rem 1rem;
4041 border: none;
4042 border-radius: 6px;
4043 background-color: var(--warning);
4044 color: var(--text-color);
4045 cursor: pointer;
4046 font-size: 1rem;
4047 font-weight: 500;
4048 transition: background-color 0.2s;
4049 display: flex;
4050 align-items: center;
4051 justify-content: center;
4052 gap: 0.5rem;
4053 }
4054
4055 .logout-btn:hover {
4056 background-color: #e53935;
4057 }
4058
4059 .logout-btn.floating {
4060 position: absolute;
4061 top: 0.5em;
4062 right: 0.5em;
4063 z-index: 10;
4064 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
4065 }
4066
4067 /* Settings Drawer */
4068 .drawer-overlay {
4069 position: fixed;
4070 top: 0;
4071 left: 0;
4072 width: 100%;
4073 height: 100%;
4074 background: rgba(0, 0, 0, 0.5);
4075 z-index: 1000;
4076 display: flex;
4077 justify-content: flex-end;
4078 }
4079
4080 .settings-drawer {
4081 width: 640px;
4082 height: 100%;
4083 background: var(--bg-color);
4084 /*border-left: 1px solid var(--border-color);*/
4085 overflow-y: auto;
4086 animation: slideIn 0.3s ease;
4087 }
4088
4089 @keyframes slideIn {
4090 from {
4091 transform: translateX(100%);
4092 }
4093 to {
4094 transform: translateX(0);
4095 }
4096 }
4097
4098 .drawer-header {
4099 display: flex;
4100 align-items: center;
4101 justify-content: space-between;
4102 background: var(--header-bg);
4103 }
4104
4105 .drawer-header h2 {
4106 margin: 0;
4107 color: var(--text-color);
4108 font-size: 1em;
4109 padding: 1rem;
4110 }
4111
4112 .close-btn {
4113 background: none;
4114 border: none;
4115 font-size: 1em;
4116 cursor: pointer;
4117 color: var(--text-color);
4118 padding: 0.5em;
4119 transition: background-color 0.2s;
4120 align-items: center;
4121 }
4122
4123 .close-btn:hover {
4124 background: var(--button-hover-bg);
4125 }
4126
4127 .profile-section {
4128 margin-bottom: 2rem;
4129 }
4130
4131 .profile-hero {
4132 position: relative;
4133 }
4134
4135 .profile-banner {
4136 width: 100%;
4137 height: 160px;
4138 object-fit: cover;
4139 border-radius: 0;
4140 display: block;
4141 }
4142
4143 /* Avatar sits half over the bottom edge of the banner */
4144 .profile-avatar,
4145 .profile-avatar-placeholder {
4146 width: 72px;
4147 height: 72px;
4148 border-radius: 50%;
4149 object-fit: cover;
4150 flex-shrink: 0;
4151 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
4152 border: 2px solid var(--bg-color);
4153 }
4154
4155 .overlap {
4156 position: absolute;
4157 left: 12px;
4158 bottom: -36px; /* half out of the banner */
4159 z-index: 2;
4160 background: var(--button-hover-bg);
4161 display: flex;
4162 align-items: center;
4163 justify-content: center;
4164 font-size: 1.5rem;
4165 }
4166
4167 /* Username and nip05 on the banner, to the right of avatar */
4168 .name-row {
4169 position: absolute;
4170 left: calc(12px + 72px + 12px);
4171 bottom: 8px;
4172 right: 12px;
4173 display: flex;
4174 align-items: baseline;
4175 gap: 8px;
4176 z-index: 1;
4177 background: var(--bg-color);
4178 padding: 0.2em 0.5em;
4179 border-radius: 0.5em;
4180 width: fit-content;
4181 }
4182
4183 .profile-username {
4184 margin: 0;
4185 font-size: 1.1rem;
4186 color: var(--text-color);
4187 }
4188
4189 .profile-nip05-inline {
4190 font-size: 0.85rem;
4191 color: var(--text-color);
4192 font-family: monospace;
4193 opacity: 0.95;
4194 }
4195
4196 /* About box below with overlap space for avatar */
4197 .about-card {
4198 background: var(--header-bg);
4199 padding: 12px 12px 12px 96px; /* offset text from overlapping avatar */
4200 position: relative;
4201 word-break: auto-phrase;
4202 }
4203
4204 .profile-about {
4205 margin: 0;
4206 color: var(--text-color);
4207 font-size: 0.9rem;
4208 line-height: 1.4;
4209 }
4210
4211 .profile-loading-section {
4212 padding: 1rem;
4213 text-align: center;
4214 position: relative; /* Allow absolute positioning of floating logout button */
4215 }
4216
4217 .profile-loading-section h3 {
4218 margin: 0 0 1rem 0;
4219 color: var(--text-color);
4220 font-size: 1.1rem;
4221 }
4222
4223 .profile-loading-section p {
4224 margin: 0 0 1rem 0;
4225 color: var(--text-color);
4226 opacity: 0.8;
4227 }
4228
4229 .retry-profile-btn {
4230 padding: 0.5rem 1rem;
4231 background: var(--primary);
4232 color: var(--text-color);
4233 border: none;
4234 border-radius: 4px;
4235 cursor: pointer;
4236 font-size: 0.9rem;
4237 margin-bottom: 1rem;
4238 transition: background-color 0.2s;
4239 }
4240
4241 .retry-profile-btn:hover {
4242 background: #00acc1;
4243 }
4244
4245 .user-pubkey-display {
4246 font-family: monospace;
4247 font-size: 0.8rem;
4248 color: var(--text-color);
4249 opacity: 0.7;
4250 background: var(--button-bg);
4251 padding: 0.5rem;
4252 border-radius: 4px;
4253 word-break: break-all;
4254 }
4255
4256 /* Managed ACL View */
4257 .managed-acl-view {
4258 padding: 20px;
4259 max-width: 1200px;
4260 margin: 0;
4261 background: var(--header-bg);
4262 color: var(--text-color);
4263 border-radius: 8px;
4264 }
4265
4266 .refresh-btn {
4267 padding: 0.5rem 1rem;
4268 background: var(--primary);
4269 color: var(--text-color);
4270 border: none;
4271 border-radius: 4px;
4272 cursor: pointer;
4273 font-size: 0.875rem;
4274 font-weight: 500;
4275 transition: background-color 0.2s;
4276 display: inline-flex;
4277 align-items: center;
4278 gap: 0.25rem;
4279 height: 2em;
4280 margin: 1em;
4281 }
4282
4283 .refresh-btn:hover {
4284 background: #00acc1;
4285 }
4286
4287 @keyframes spin {
4288 0% {
4289 transform: rotate(0deg);
4290 }
4291 100% {
4292 transform: rotate(360deg);
4293 }
4294 }
4295
4296 /* View as Section */
4297 .view-as-section {
4298 color: var(--text-color);
4299 padding: 1rem;
4300 border-radius: 0.5em;
4301 margin-bottom: 1rem;
4302 }
4303
4304 .view-as-section h3 {
4305 margin-top: 0;
4306 margin-bottom: 0.5rem;
4307 font-size: 1rem;
4308 color: var(--primary);
4309 }
4310
4311 .view-as-section p {
4312 margin: 0.5rem 0;
4313 font-size: 0.9rem;
4314 opacity: 0.8;
4315 }
4316
4317 .radio-group {
4318 display: flex;
4319 flex-direction: column;
4320 gap: 0.5rem;
4321 }
4322
4323 .radio-label {
4324 display: flex;
4325 align-items: center;
4326 gap: 0.5rem;
4327 cursor: pointer;
4328 padding: 0.25rem;
4329 border-radius: 0.5em;
4330 transition: background 0.2s;
4331 }
4332
4333 .radio-label:hover {
4334 background: rgba(255, 255, 255, 0.1);
4335 }
4336
4337 .radio-label input {
4338 margin: 0;
4339 }
4340
4341 .avatar-placeholder {
4342 width: 1.5rem;
4343 height: 1.5rem;
4344 border-radius: 50%;
4345 background: var(--button-bg);
4346 display: flex;
4347 align-items: center;
4348 justify-content: center;
4349 font-size: 0.7rem;
4350 }
4351
4352 .kind-number {
4353 background: var(--primary);
4354 color: var(--text-color);
4355 padding: 0.125rem 0.375rem;
4356 border-radius: 0.5em;
4357 font-size: 0.7rem;
4358 font-weight: 500;
4359 font-family: monospace;
4360 }
4361
4362 .kind-name {
4363 font-size: 0.75rem;
4364 color: var(--text-color);
4365 opacity: 0.7;
4366 font-weight: 500;
4367 }
4368
4369 .event-timestamp {
4370 font-size: 0.75rem;
4371 color: var(--text-color);
4372 opacity: 0.7;
4373 margin-bottom: 0.25rem;
4374 font-weight: 500;
4375 }
4376
4377 .event-content-single-line {
4378 white-space: nowrap;
4379 overflow: hidden;
4380 text-overflow: ellipsis;
4381 line-height: 1.2;
4382 }
4383
4384 .delete-btn {
4385 flex-shrink: 0;
4386 background: none;
4387 border: none;
4388 cursor: pointer;
4389 padding: 0.2rem;
4390 border-radius: 0.5em;
4391 transition: background-color 0.2s;
4392 font-size: 1.6rem;
4393 display: flex;
4394 align-items: center;
4395 justify-content: center;
4396 width: 1.5rem;
4397 height: 1.5rem;
4398 }
4399
4400 .delete-btn:hover {
4401 background: var(--warning);
4402 color: var(--text-color);
4403 }
4404
4405 .json-container {
4406 position: relative;
4407 }
4408
4409 .copy-json-btn {
4410 color: var(--text-color);
4411 background: var(--accent-color);
4412 border: 0;
4413 border-radius: 0.5rem;
4414 padding: 0.5rem;
4415 font-size: 1rem;
4416 cursor: pointer;
4417 width: auto;
4418 height: auto;
4419 display: flex;
4420 align-items: center;
4421 justify-content: center;
4422 }
4423
4424 .copy-json-btn:hover {
4425 background: var(--accent-hover-color);
4426 }
4427
4428 .event-json {
4429 background: var(--bg-color);
4430 padding: 1rem;
4431 margin: 0;
4432 font-family: "Courier New", monospace;
4433 font-size: 0.8rem;
4434 line-height: 1.4;
4435 color: var(--text-color);
4436 white-space: pre-wrap;
4437 word-break: break-word;
4438 overflow-x: auto;
4439 }
4440
4441 .no-events {
4442 padding: 2rem;
4443 text-align: center;
4444 color: var(--text-color);
4445 opacity: 0.7;
4446 }
4447
4448 .loading-spinner {
4449 width: 2rem;
4450 height: 2rem;
4451 border: 3px solid var(--border-color);
4452 border-top: 3px solid var(--primary);
4453 border-radius: 50%;
4454 animation: spin 1s linear infinite;
4455 margin: 0 auto 1rem auto;
4456 }
4457
4458 @keyframes spin {
4459 0% {
4460 transform: rotate(0deg);
4461 }
4462 100% {
4463 transform: rotate(360deg);
4464 }
4465 }
4466
4467 /* Search Results Styles */
4468 .search-results-view {
4469 position: fixed;
4470 top: 3em;
4471 left: 200px;
4472 right: 0;
4473 bottom: 0;
4474 background: var(--bg-color);
4475 color: var(--text-color);
4476 display: flex;
4477 flex-direction: column;
4478 overflow: hidden;
4479 }
4480
4481 .search-results-header {
4482 padding: 0.5rem 1rem;
4483 background: var(--header-bg);
4484 border-bottom: 1px solid var(--border-color);
4485 flex-shrink: 0;
4486 display: flex;
4487 justify-content: space-between;
4488 align-items: center;
4489 height: 2.5em;
4490 }
4491
4492 .search-results-header h2 {
4493 margin: 0;
4494 font-size: 1rem;
4495 font-weight: 600;
4496 color: var(--text-color);
4497 }
4498
4499 .search-results-content {
4500 flex: 1;
4501 overflow-y: auto;
4502 padding: 0;
4503 }
4504
4505 .search-result-item {
4506 border-bottom: 1px solid var(--border-color);
4507 transition: background-color 0.2s;
4508 }
4509
4510 .search-result-item:hover {
4511 background: var(--button-hover-bg);
4512 }
4513
4514 .search-result-item.expanded {
4515 background: var(--button-hover-bg);
4516 }
4517
4518 .search-result-row {
4519 display: flex;
4520 align-items: center;
4521 padding: 0.75rem 1rem;
4522 cursor: pointer;
4523 gap: 0.75rem;
4524 min-height: 3rem;
4525 }
4526
4527 .search-result-avatar {
4528 flex-shrink: 0;
4529 width: 2rem;
4530 height: 2rem;
4531 display: flex;
4532 align-items: center;
4533 justify-content: center;
4534 }
4535
4536 .search-result-info {
4537 flex-shrink: 0;
4538 width: 12rem;
4539 display: flex;
4540 flex-direction: column;
4541 gap: 0.25rem;
4542 }
4543
4544 .search-result-author {
4545 font-family: monospace;
4546 font-size: 0.8rem;
4547 color: var(--text-color);
4548 opacity: 0.8;
4549 }
4550
4551 .search-result-kind {
4552 display: flex;
4553 align-items: center;
4554 gap: 0.5rem;
4555 }
4556
4557 .search-result-content {
4558 flex: 1;
4559 color: var(--text-color);
4560 font-size: 0.9rem;
4561 line-height: 1.3;
4562 word-break: break-word;
4563 }
4564
4565 .search-result-content .event-timestamp {
4566 font-size: 0.75rem;
4567 color: var(--text-color);
4568 opacity: 0.7;
4569 margin-bottom: 0.25rem;
4570 font-weight: 500;
4571 }
4572
4573 .search-result-content .event-content-single-line {
4574 white-space: nowrap;
4575 overflow: hidden;
4576 text-overflow: ellipsis;
4577 line-height: 1.2;
4578 }
4579
4580 .search-result-details {
4581 border-top: 1px solid var(--border-color);
4582 background: var(--header-bg);
4583 padding: 1rem;
4584 }
4585
4586 .no-search-results {
4587 padding: 2rem;
4588 text-align: center;
4589 color: var(--text-color);
4590 opacity: 0.7;
4591 }
4592
4593 .no-search-results p {
4594 margin: 0;
4595 font-size: 1rem;
4596 }
4597
4598 .loading-search-results {
4599 padding: 2rem;
4600 text-align: center;
4601 color: var(--text-color);
4602 opacity: 0.7;
4603 }
4604
4605 .loading-search-results p {
4606 margin: 0;
4607 font-size: 0.9rem;
4608 }
4609
4610 .end-of-search-results {
4611 padding: 1rem;
4612 text-align: center;
4613 color: var(--text-color);
4614 opacity: 0.5;
4615 font-size: 0.8rem;
4616 border-top: 1px solid var(--border-color);
4617 }
4618
4619 .end-of-search-results p {
4620 margin: 0;
4621 }
4622
4623 @media (max-width: 1280px) {
4624 .main-content {
4625 left: 60px;
4626 }
4627 .search-results-view {
4628 left: 60px;
4629 }
4630 }
4631
4632 @media (max-width: 640px) {
4633 .main-content {
4634 left: 0;
4635 }
4636
4637 .search-results-view {
4638 left: 0;
4639 }
4640
4641 .settings-drawer {
4642 width: 100%;
4643 }
4644
4645 .name-row {
4646 left: calc(8px + 56px + 8px);
4647 bottom: 6px;
4648 right: 8px;
4649 gap: 6px;
4650 background: var(--bg-color);
4651 padding: 0.2em 0.5em;
4652 border-radius: 0.5em;
4653 width: fit-content;
4654 }
4655
4656 .profile-username {
4657 font-size: 1rem;
4658 color: var(--text-color);
4659 }
4660 .profile-nip05-inline {
4661 font-size: 0.8rem;
4662 color: var(--text-color);
4663 }
4664
4665 .managed-acl-view {
4666 padding: 1rem;
4667 }
4668
4669 .kind-name {
4670 font-size: 0.7rem;
4671 }
4672
4673 .search-results-view {
4674 left: 160px;
4675 }
4676
4677 .search-result-info {
4678 width: 8rem;
4679 }
4680
4681 .search-result-author {
4682 font-size: 0.7rem;
4683 }
4684
4685 .search-result-content {
4686 font-size: 0.8rem;
4687 }
4688 }
4689
4690 /* Recovery Tab Styles */
4691 .recovery-tab {
4692 padding: 20px;
4693 width: 100%;
4694 max-width: 1200px;
4695 margin: 0;
4696 box-sizing: border-box;
4697 }
4698
4699 .recovery-tab h3 {
4700 margin: 0 0 10px 0;
4701 color: var(--text-color);
4702 }
4703
4704 .recovery-tab p {
4705 margin: 0;
4706 color: var(--text-color);
4707 opacity: 0.7;
4708 padding: 0.5em;
4709 }
4710
4711 .recovery-controls-card {
4712 background-color: transparent;
4713 border: none;
4714 border-radius: 0.5em;
4715 padding: 0;
4716 }
4717
4718 .recovery-controls {
4719 display: flex;
4720 gap: 20px;
4721 align-items: center;
4722 flex-wrap: wrap;
4723 }
4724
4725 .kind-selector {
4726 display: flex;
4727 flex-direction: column;
4728 gap: 5px;
4729 }
4730
4731 .kind-selector label {
4732 font-weight: 500;
4733 color: var(--text-color);
4734 }
4735
4736 .kind-selector select {
4737 padding: 8px 12px;
4738 border: 1px solid var(--border-color);
4739 border-radius: 0.5em;
4740 background: var(--bg-color);
4741 color: var(--text-color);
4742 min-width: 300px;
4743 }
4744
4745 .custom-kind-input {
4746 display: flex;
4747 flex-direction: column;
4748 gap: 5px;
4749 }
4750
4751 .custom-kind-input label {
4752 font-weight: 500;
4753 color: var(--text-color);
4754 }
4755
4756 .custom-kind-input input {
4757 padding: 8px 12px;
4758 border: 1px solid var(--border-color);
4759 border-radius: 0.5em;
4760 background: var(--bg-color);
4761 color: var(--text-color);
4762 min-width: 200px;
4763 }
4764
4765 .custom-kind-input input::placeholder {
4766 color: var(--text-color);
4767 opacity: 0.6;
4768 }
4769
4770 .recovery-results {
4771 margin-top: 20px;
4772 }
4773
4774 .loading,
4775 .no-events {
4776 text-align: left;
4777 padding: 40px 20px;
4778 color: var(--text-color);
4779 opacity: 0.7;
4780 }
4781
4782 .events-list {
4783 display: flex;
4784 flex-direction: column;
4785 gap: 15px;
4786 }
4787
4788 .event-item {
4789 background: var(--surface-bg);
4790 border: 2px solid var(--primary);
4791 border-radius: 0.5em;
4792 padding: 20px;
4793 transition: all 0.2s ease;
4794 background: var(--header-bg);
4795 }
4796
4797 .event-item.old-version {
4798 opacity: 0.85;
4799 border: none;
4800 background: var(--header-bg);
4801 }
4802
4803 .event-header {
4804 display: flex;
4805 justify-content: space-between;
4806 align-items: center;
4807 margin-bottom: 15px;
4808 flex-wrap: wrap;
4809 gap: 10px;
4810 }
4811
4812 .event-header-left {
4813 display: flex;
4814 align-items: center;
4815 gap: 15px;
4816 flex-wrap: wrap;
4817 }
4818
4819 .event-header-actions {
4820 display: flex;
4821 align-items: center;
4822 gap: 8px;
4823 }
4824
4825 .event-kind {
4826 font-weight: 600;
4827 color: var(--primary);
4828 }
4829
4830 .event-timestamp {
4831 color: var(--text-color);
4832 font-size: 0.9em;
4833 opacity: 0.7;
4834 }
4835
4836 .repost-all-button {
4837 background: #059669;
4838 color: var(--text-color);
4839 border: none;
4840 padding: 6px 12px;
4841 border-radius: 0.5em;
4842 cursor: pointer;
4843 font-size: 0.9em;
4844 transition: background 0.2s ease;
4845 margin-right: 8px;
4846 }
4847
4848 .repost-all-button:hover {
4849 background: #047857;
4850 }
4851
4852 .repost-button {
4853 background: var(--primary);
4854 color: var(--text-color);
4855 border: none;
4856 padding: 6px 12px;
4857 border-radius: 0.5em;
4858 cursor: pointer;
4859 font-size: 0.9em;
4860 transition: background 0.2s ease;
4861 }
4862
4863 .repost-button:hover {
4864 background: #00acc1;
4865 }
4866
4867 .event-content {
4868 margin-bottom: 15px;
4869 }
4870
4871 .load-more {
4872 width: 100%;
4873 padding: 12px;
4874 background: var(--primary);
4875 color: var(--text-color);
4876 border: none;
4877 border-radius: 0.5em;
4878 cursor: pointer;
4879 font-size: 1em;
4880 margin-top: 20px;
4881 transition: background 0.2s ease;
4882 }
4883
4884 .load-more:hover:not(:disabled) {
4885 background: #00acc1;
4886 }
4887
4888 .load-more:disabled {
4889 opacity: 0.6;
4890 cursor: not-allowed;
4891 }
4892
4893 /* Dark theme adjustments for recovery tab */
4894 :global(body.dark-theme) .event-item.old-version {
4895 background: var(--header-bg);
4896 border: none;
4897 }
4898
4899 /* Relay connection section in settings drawer */
4900 .relay-section {
4901 margin-top: 20px;
4902 padding-top: 20px;
4903 border-top: 1px solid var(--border-color);
4904 }
4905
4906 .relay-section h3 {
4907 margin: 0 0 12px 0;
4908 color: var(--text-color);
4909 font-size: 1.1rem;
4910 }
4911
4912 .relay-info-card {
4913 background: var(--muted);
4914 padding: 12px;
4915 border-radius: 8px;
4916 margin-bottom: 12px;
4917 }
4918
4919 .relay-name {
4920 font-weight: 600;
4921 color: var(--text-color);
4922 margin-bottom: 4px;
4923 }
4924
4925 .relay-description {
4926 font-size: 0.9rem;
4927 color: var(--muted-foreground);
4928 margin-bottom: 8px;
4929 }
4930
4931 .relay-url {
4932 font-family: monospace;
4933 font-size: 0.85rem;
4934 color: var(--primary);
4935 word-break: break-all;
4936 }
4937
4938 .relay-disconnected {
4939 display: flex;
4940 align-items: center;
4941 gap: 8px;
4942 color: var(--muted-foreground);
4943 margin-bottom: 12px;
4944 }
4945
4946 .status-dot {
4947 width: 8px;
4948 height: 8px;
4949 border-radius: 50%;
4950 }
4951
4952 .status-dot.disconnected {
4953 background: var(--danger);
4954 }
4955
4956 .change-relay-btn {
4957 width: 100%;
4958 padding: 10px 16px;
4959 background: var(--primary);
4960 color: white;
4961 border: none;
4962 border-radius: 6px;
4963 cursor: pointer;
4964 font-size: 0.95rem;
4965 transition: background-color 0.2s;
4966 }
4967
4968 .change-relay-btn:hover {
4969 background: #00acc1;
4970 }
4971
4972 /* Centered permission/login prompts within main-content */
4973 .events-loading-permissions,
4974 .permission-denied,
4975 .access-denied {
4976 margin: auto;
4977 text-align: center;
4978 color: var(--text-color);
4979 }
4980
4981 .main-content :global(.login-prompt),
4982 .main-content :global(.permission-denied) {
4983 margin: auto;
4984 }
4985
4986 .events-loading-permissions .spinner {
4987 width: 24px;
4988 height: 24px;
4989 border: 2px solid var(--border-color);
4990 border-top-color: var(--primary);
4991 border-radius: 50%;
4992 animation: spin 1s linear infinite;
4993 margin: 0 auto 1em;
4994 }
4995 </style>
4996