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