fixes + readme update

This commit is contained in:
n 2025-04-08 21:00:57 +01:00
parent 07ffd05fe2
commit 2d70707062
11 changed files with 1926 additions and 1137 deletions

@ -66,7 +66,44 @@ sequenceDiagram
The remote server should periodically scan for expired RESPONSE events (and associated blossom blobs) and delete them.
## Event Structure
## Server Advertisement Event (Kind 11120)
To facilitate discovery of HTTP-over-Nostr servers, a dedicated event kind is used to advertise server availability.
```jsonc
{
"kind": 11120,
"pubkey": "<pubkey of server operator>",
"content": "HTTP-over-Nostr server", // Optional description
"tags": [
["name", "My HTTP Server"], // Optional server name
["server", "<pubkey of server>"], // Server pubkey that will be listening for requests
["relay", "wss://relay.one"], // Relay where server is listening (can have multiple)
["relay", "wss://relay.two"],
["expiry", "<unix timestamp>"], // How long this server will be online
["p", "<allowed client pubkey>"], // Clients allowed to use this server (can have multiple)
["p", "<allowed client pubkey>"]
],
// other fields...
}
```
Explanations:
* `kind:1120` - BIP39 word #1120 ([message](https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt#L1120)) plus 10000 to make it replaceable.
* `"content"` - Optional description of the server
* `"server"` - The pubkey of the HTTP server that will be processing requests
* `"relay"` - Relays where this server is listening for kind 21120 events (can have multiple)
* `"expiry"` - Timestamp after which this server advertisement should be considered expired
* `"p"` - Pubkeys allowed to send requests to this server (if none specified, server is public)
Clients looking to use HTTP over Nostr can query for these kind 1120 events to discover available servers and determine if they have permission to use them.
## HTTP Requests - Kind 21120
Example **request** with a small payload. Payload is in `content` and `P` tag is the npub of the remote HTTP server.
@ -85,11 +122,22 @@ Example **request** with a small payload. Payload is in `content` and `P` tag i
}
```
Explanations:
* `kind:21120` - BIP39 word #1120 ([message](https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt#L1120)), plus 20,000 to be treated as ephemeral (not stored by relays).
* `"content"` - encrypted JSON with location of blob **OR** the content itself (if under a threshold). NIP-44 is NOT used as the payload may be large, affecting bunker signing stability.
* `"p"` - the pubkey of the remote HTTP server.
* `"key"` - the decryption key for the `content` field, also the key for the blossom blob (if used).
* `"expiration"` - remote servers should not process requests after this time. Relays SHOULD delete events after this time.
* `"r"` - (optional) relay on which the response should be sent.
### HTTP Response - Kind 21121
Example **response** with a large payload. Valid JSON is in `content` and `E` tag is populated. For privacy, the requestor npub is NOT shown - the requestor instead should be fetching the response using the `E` tag.
```jsonc
{
"kind": 21120,
"kind": 21121,
"pubkey": "<pubkey>",
"content": "encrypt({'url':'blossom.one','hash':'xx'},$decryptkey)",
"tags": [
@ -103,13 +151,13 @@ Example **response** with a large payload. Valid JSON is in `content` and `E` t
Explanations:
* `kind:21120` - BIP39 word #1120 ([message](https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt#L1120)), plus 20,000 to be treated as ephemeral (not stored by relays).
* `kind:21121` - A different kind is used for responses, to help with filtering and other use cases.
* `"content"` - encrypted JSON with location of blob **OR** the content itself (if under a threshold). NIP-44 is NOT used as the payload may be large, affecting bunker signing stability.
* `"p"` - the pubkey of the remote HTTP server. Indicates that this is a REQUEST.
* `"key"` - the decryption key for the `content` field, also the key for the blossom blob (if used).
* `"E"` - ID of the request event. Enables a response to be identified, and fetched.
* `"expiration"` - remote servers should not process requests after this time. Relays SHOULD delete events after this time.
* `"r"` - (optional) relay on which the response should be sent. For Requests only.
There is no "p" tag as the "E" tag already identifies the request.
## Considerations
@ -145,4 +193,3 @@ So why would you use it? Several reasons:

@ -1,105 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Implement strict Content Security Policy -->
<title>HTTP to Kind 21120 Converter</title>
<!-- Include the Nostr libraries -->
<script src='https://www.unpkg.com/nostr-login@latest/dist/unpkg.js'></script>
<script src="https://cdn.jsdelivr.net/npm/nostr-tools@latest"></script>
<!-- Load our external TypeScript file (compiled to JS) -->
<script src="./src/client.js" type="module"></script>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
textarea {
width: 100%;
height: 200px;
font-family: monospace;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 15px;
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background-color: #45a049;
}
#output {
margin-top: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
}
pre {
background-color: #f5f5f5;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.info-box {
background-color: #e7f3fe;
border-left: 6px solid #2196F3;
padding: 10px;
margin-bottom: 20px;
}
h1 {
color: #333;
}
h2 {
color: #555;
}
</style>
</head>
<body>
<h1>HTTP Request to Kind 21120 Converter</h1>
<div class="info-box">
<p>This tool converts HTTP requests into a single Nostr event of kind 21120. The HTTP request is encrypted using NIP-44 in the content field with appropriate tags following the specification.</p>
<p>The server's public key (<strong>npub1r6knexka25dn9w9jnf5kf8xh6gfq7n3p38zfl7nn7cjjjsp4umcqnk0aun</strong>) is pre-populated for your convenience.</p>
</div>
<h2>Server Information:</h2>
<div style="margin-bottom: 15px;">
<div style="margin-bottom: 10px;">
<label for="serverPubkey">Server Pubkey:</label><br>
<input type="text" id="serverPubkey" value="npub1r6knexka25dn9w9jnf5kf8xh6gfq7n3p38zfl7nn7cjjjsp4umcqnk0aun" style="width: 100%; padding: 8px;">
</div>
<div>
<label for="relay">Response Relay (optional):</label><br>
<input type="text" id="relay" value="wss://relay.damus.io" style="width: 100%; padding: 8px;">
</div>
</div>
<h2>Enter HTTP Request:</h2>
<textarea id="httpRequest" placeholder="GET /index.html HTTP/1.1
Host: example.com
User-Agent: Browser/1.0
"></textarea>
<button id="convertButton">Convert to Event</button>
<div id="output" hidden>
<h2>Converted Event:</h2>
<pre id="eventOutput"></pre>
</div>
</body>
</html>

@ -41,6 +41,17 @@
<div id="relay-connection-section" class="active">
<h2>Relay Connection</h2>
<div class="relay-connection">
<!-- Server npub display -->
<div class="server-info-container">
<div class="server-npub-container">
<label>Server NPUB:</label>
<input type="text" id="serverNpub" readonly class="server-npub-input">
<button id="copyServerNpubBtn" class="copy-btn" title="Copy NPUB">
<span id="copyBtnText">Copy</span>
</button>
</div>
</div>
<div class="relay-input-container">
<label for="relayUrl">Relay URL:</label>
<input type="text" id="relayUrl" value="wss://relay.degmods.com" placeholder="wss://relay.example.com">
@ -49,7 +60,7 @@
<div class="filter-options">
<label class="filter-checkbox">
<input type="checkbox" id="showAllEvents" checked>
Show all kind 21120 events (not just addressed to me)
Show all kind 21120 events
</label>
</div>
<div id="relayStatus" class="relay-status">Not connected</div>

@ -4,8 +4,6 @@
// Default server information
export const defaultServerConfig = {
// Server's npub (hard-coded to match the server's config)
serverNpub: "npub1r6knexka25dn9w9jnf5kf8xh6gfq7n3p38zfl7nn7cjjjsp4umcqnk0aun",
// Default relay for responses
defaultRelay: "wss://relay.degmods.com"
};

@ -299,7 +299,7 @@ export async function displayConvertedEvent(): Promise<void> {
if (httpRequestBox && eventOutputPre && outputDiv) {
// Get server pubkey and relay values from inputs
const serverPubkey = serverPubkeyInput && serverPubkeyInput.value ?
serverPubkeyInput.value : defaultServerConfig.serverNpub;
serverPubkeyInput.value : "";
const relay = relayInput && relayInput.value ?
relayInput.value : defaultServerConfig.defaultRelay;
// Get or create a keypair

File diff suppressed because it is too large Load Diff

@ -0,0 +1,226 @@
/**
* HttpService.ts
* Handles HTTP-related operations and cryptography functions for the HTTP-to-Nostr application
*/
// Interface for HTTP request options
export interface HttpRequestOptions {
method: string;
headers: Record<string, string>;
body?: string;
}
/**
* Class for handling HTTP requests and responses
*/
export class HttpService {
/**
* Parse a raw HTTP request text into its components
* @param httpRequestText The raw HTTP request text
* @returns Parsed HTTP request details or null if invalid
*/
public parseHttpRequest(httpRequestText: string): { url: string; options: HttpRequestOptions } | null {
try {
// Parse the HTTP request
const lines = httpRequestText.split('\n');
// Extract the first line (request line)
const requestLine = lines[0].trim().split(' ');
if (requestLine.length < 2) {
throw new Error('Invalid HTTP request: Missing request line');
}
const method = requestLine[0];
let url = requestLine[1];
// If it's a relative URL, make it absolute
if (url.startsWith('/')) {
url = `${window.location.origin}${url}`;
} else if (!url.startsWith('http')) {
url = `http://${url}`; // Default to http if no protocol specified
}
// Parse headers
const headers: Record<string, string> = {};
let i = 1;
let bodyStartIndex = lines.length;
while (i < lines.length) {
const line = lines[i].trim();
if (line === '') {
bodyStartIndex = i + 1;
break;
}
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
headers[key] = value;
}
i++;
}
// Extract body if present
let body = '';
if (bodyStartIndex < lines.length) {
body = lines.slice(bodyStartIndex).join('\n');
}
// Return the parsed request
return {
url,
options: {
method,
headers,
...(body && method !== 'GET' && method !== 'HEAD' ? { body } : {})
}
};
} catch {
return null;
}
}
/**
* Execute an HTTP request
* @param httpRequestText The raw HTTP request text
* @returns A promise that resolves to the HTTP response as text
*/
public async executeHttpRequest(httpRequestText: string): Promise<string> {
try {
const parsedRequest = this.parseHttpRequest(httpRequestText);
if (!parsedRequest) {
return 'Error: Could not parse HTTP request';
}
const { url, options } = parsedRequest;
// Use the fetch API to execute the request - explicitly using window.fetch
const response = await window.fetch(url, options);
// Prepare the response text
let responseText = `HTTP/1.1 ${response.status} ${response.statusText}\n`;
// Add response headers
response.headers.forEach((value: string, key: string) => {
responseText += `${key}: ${value}\n`;
});
// Add empty line to separate headers from body
responseText += '\n';
// Add response body
const responseBody = await response.text();
responseText += responseBody;
return responseText;
} catch (error) {
if (error instanceof Error) {
return `Error executing HTTP request: ${error.message}`;
} else {
return `Error executing HTTP request: ${String(error)}`;
}
}
}
/**
* Encrypt data using Web Crypto API
* @param plaintext The plaintext to encrypt
* @param key The encryption key
* @returns A promise that resolves to a base64-encoded encrypted string
*/
public async encryptWithWebCrypto(plaintext: string, key: string): Promise<string> {
try {
// Generate a random IV
const iv = crypto.getRandomValues(new Uint8Array(12));
// Create key material from the encryption key
const keyMaterial = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(key)
);
// Import the key for AES-GCM encryption
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'AES-GCM' },
false,
['encrypt']
);
// Encrypt the data
const encryptedData = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
cryptoKey,
new TextEncoder().encode(plaintext)
);
// Combine IV and ciphertext
const encryptedBytes = new Uint8Array(iv.length + new Uint8Array(encryptedData).length);
encryptedBytes.set(iv, 0);
encryptedBytes.set(new Uint8Array(encryptedData), iv.length);
// Convert to base64
return btoa(String.fromCharCode(...encryptedBytes));
} catch (error) {
throw new Error(`WebCrypto encryption failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Decrypt data using Web Crypto API
* @param encryptedBase64 The base64-encoded encrypted data
* @param key The decryption key
* @returns A promise that resolves to the decrypted plaintext
*/
public async decryptWithWebCrypto(encryptedBase64: string, key: string): Promise<string> {
try {
// Convert base64 to byte array
const encryptedBytes = new Uint8Array(
atob(encryptedBase64)
.split('')
.map(char => char.charCodeAt(0))
);
// Extract IV (first 12 bytes)
const iv = encryptedBytes.slice(0, 12);
// Extract ciphertext (remaining bytes)
const ciphertext = encryptedBytes.slice(12);
// Create key material from the decryption key
const keyMaterial = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(key)
);
// Import the key for AES-GCM decryption
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'AES-GCM' },
false,
['decrypt']
);
// Decrypt the data
const decryptedData = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv
},
cryptoKey,
ciphertext
);
// Convert decrypted data to string
return new TextDecoder().decode(decryptedData);
} catch (error) {
throw new Error(`WebCrypto decryption failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
}

@ -0,0 +1,351 @@
/**
* NostrService.ts
* Handles Nostr-specific functionality like events, subscriptions, and filtering
*/
// External imports
import * as nostrTools from 'nostr-tools';
// Project imports
import type { NostrEvent } from '../relay';
import { convertNpubToHex } from '../relay';
import { WebSocketManager } from './WebSocketManager';
// Interface for profile data
export interface ProfileData {
name?: string;
about?: string;
picture?: string;
nip05?: string;
[key: string]: unknown;
}
// Interface for a Nostr subscription
export interface NostrSubscription {
unsub: () => void;
}
// Interface for a received event
export interface ReceivedEvent {
id: string;
event: NostrEvent;
receivedAt: number;
decrypted: boolean;
decryptedContent?: string;
}
// Interface for Nostr filter
export interface NostrFilter {
kinds: number[];
'#p'?: string[];
authors?: string[];
since?: number;
until?: number;
limit?: number;
[key: string]: unknown;
}
/**
* Class for managing Nostr functionality
*/
export class NostrService {
private profileCache = new Map<string, ProfileData>();
private relayPool: nostrTools.SimplePool | null = null;
private wsManager = new WebSocketManager();
private activeRelayUrl: string | null = null;
// eslint-disable-next-line no-unused-vars
private eventHandler: ((event: NostrEvent) => void) | null = null;
// eslint-disable-next-line no-unused-vars
private statusCallback: ((message: string, className: string) => void) | null = null;
/**
* Constructor
* @param statusCallback Callback for status updates
*/
// eslint-disable-next-line no-unused-vars
constructor(statusCallback?: ((message: string, className: string) => void)) {
this.statusCallback = statusCallback || null;
}
/**
* Set the event handler for received events
* @param handler The handler function for received events
*/
// eslint-disable-next-line no-unused-vars
public setEventHandler(handler: ((event: NostrEvent) => void)): void {
this.eventHandler = handler;
}
/**
* Connect to a relay
* @param relayUrl The relay URL to connect to
* @returns A promise that resolves to true if connected successfully
*/
public async connectToRelay(relayUrl: string): Promise<boolean> {
try {
this.updateStatus('Connecting to relay...', 'connecting');
// Test the connection first
const connectionSuccess = await this.wsManager.testConnection(relayUrl);
if (!connectionSuccess) {
this.updateStatus('Connection failed', 'error');
return false;
}
// Close existing relay pool if any
if (this.relayPool && this.activeRelayUrl) {
try {
await this.relayPool.close([this.activeRelayUrl]);
} catch {
// Ignore errors when closing
}
this.relayPool = null;
}
// Create new relay pool
this.relayPool = new nostrTools.SimplePool();
this.activeRelayUrl = relayUrl;
this.updateStatus('Connected', 'connected');
return true;
} catch (error) {
this.updateStatus(
`Error: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
return false;
}
}
/**
* Subscribe to events with the given filter
* @param filter The filter to use for the subscription
* @returns A promise that resolves to a NostrSubscription
*/
public async subscribeToEvents(filter: NostrFilter): Promise<NostrSubscription> {
if (!this.activeRelayUrl) {
throw new Error('No active relay URL');
}
this.updateStatus('Creating subscription...', 'connecting');
try {
await this.wsManager.connect(this.activeRelayUrl, {
timeout: 5000,
onOpen: (ws) => {
// Send a REQ message to subscribe
const reqId = `req-${Date.now()}`;
const reqMsg = JSON.stringify(["REQ", reqId, filter]);
ws.send(reqMsg);
this.updateStatus('Subscription active ✓', 'connected');
},
onMessage: (data) => {
// Type assertion for the received data
const nostrData = data as unknown[];
// Handle different message types
if (Array.isArray(nostrData) && nostrData[0] === "EVENT" && nostrData.length >= 3) {
const event = nostrData[2] as NostrEvent;
// Process the event if we have a handler
if (this.eventHandler && event.id) {
const callback = this.eventHandler;
callback(event);
}
}
},
onError: () => {
this.updateStatus('WebSocket error', 'error');
},
onClose: () => {
this.updateStatus('Connection closed', 'error');
}
});
// Return a subscription object
return {
unsub: () => {
this.wsManager.close();
}
};
} catch (error) {
this.updateStatus(
`Subscription error: ${error instanceof Error ? error.message : String(error)}`,
'error'
);
throw error;
}
}
/**
* Check if the service is connected to a relay
* @returns true if connected, false otherwise
*/
public isConnected(): boolean {
return this.relayPool !== null && this.activeRelayUrl !== null && this.wsManager.isConnected();
}
/**
* Create a filter for kind 21120 events, optionally filtered for a specific user
* @param showAllEvents Whether to show all events or only those for the logged-in user
* @returns A NostrFilter for the subscription
*/
public createKind21120Filter(showAllEvents: boolean): NostrFilter {
// Create filter for kind 21120 events
const filter: NostrFilter = {
kinds: [21120], // HTTP Messages event kind
};
// If "Show all events" is not checked and the user is logged in, filter for events addressed to them
if (!showAllEvents) {
const loggedInPubkey = this.getLoggedInPubkey();
if (loggedInPubkey) {
let pubkeyHex = loggedInPubkey;
// Convert npub to hex if needed
if (loggedInPubkey.startsWith('npub')) {
try {
const hexPubkey = convertNpubToHex(loggedInPubkey);
if (hexPubkey) {
pubkeyHex = hexPubkey;
}
} catch {
// Ignore conversion errors
}
}
// Add p-tag filter for events addressed to the logged-in user
filter['#p'] = [pubkeyHex];
}
}
return filter;
}
/**
* Get the logged-in user's public key from localStorage
* @returns The public key or null if not found
*/
public getLoggedInPubkey(): string | null {
const pubkey = localStorage.getItem('userPublicKey');
// If no pubkey in localStorage, try to get it from window.nostr directly
if (!pubkey && window.nostr && typeof window.nostr.getPublicKey === 'function') {
// Note: This returns a promise, so we can't use it synchronously
// But we'll trigger the fetch so it might be available next time
window.nostr.getPublicKey()
.then(directPubkey => {
localStorage.setItem('userPublicKey', directPubkey);
return directPubkey;
})
.catch(() => {
return null;
});
}
return pubkey;
}
/**
* Fetch profile data for a pubkey
* @param pubkey The public key to fetch profile data for
* @returns A promise that resolves to ProfileData or null
*/
public async fetchProfileData(pubkey: string): Promise<ProfileData | null> {
// Return from cache if available
if (this.profileCache.has(pubkey)) {
return this.profileCache.get(pubkey) || null;
}
try {
// List of relays to try, in order of preference
const relays = ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol'];
// Prepare the filter for kind-0 events (profile metadata)
const filter = {
kinds: [0],
authors: [pubkey],
limit: 1
};
// Create unique request ID
const requestId = `profile-${Math.random().toString(36).substring(2, 10)}`;
const subRequest = ["REQ", requestId, filter];
// Store events received
const events: NostrEvent[] = [];
let connected = false;
// Try each relay until we get a response
for (const relayUrl of relays) {
if (connected) {
break;
}
try {
const wsManager = new WebSocketManager();
await wsManager.connect(relayUrl, {
timeout: 3000,
onOpen: (ws) => {
// Send subscription request for profile
ws.send(JSON.stringify(subRequest));
},
onMessage: (data) => {
// Type assertion for the received data
const nostrData = data as unknown[];
// If we receive an EVENT message with our request ID
if (Array.isArray(nostrData) && nostrData[0] === 'EVENT' && nostrData[1] === requestId) {
events.push(nostrData[2] as NostrEvent);
connected = true;
wsManager.close();
}
// If we receive EOSE (End of Stored Events)
else if (Array.isArray(nostrData) && nostrData[0] === 'EOSE' && nostrData[1] === requestId) {
wsManager.close();
}
}
});
} catch {
// If connection fails, just continue to next relay
}
}
// Process the events if we found any
if (events.length > 0) {
const profileEvent = events[0];
try {
// Parse the content as JSON to get profile data
const profileData = JSON.parse(profileEvent.content) as ProfileData;
// Store in cache
this.profileCache.set(pubkey, profileData);
return profileData;
} catch {
// If parsing fails, return null
return null;
}
}
} catch {
// Any error, return null
}
return null;
}
/**
* Update the status via callback if set
* @param message The status message
* @param className The CSS class name for styling
*/
private updateStatus(message: string, className: string): void {
if (this.statusCallback) {
const callback = this.statusCallback;
callback(message, className);
}
}
}

@ -0,0 +1,686 @@
/**
* UiService.ts
* Handles UI-related operations and DOM manipulation
*/
import jsQR from 'jsqr';
import type { NostrEvent } from '../relay';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { HttpService } from './HttpService';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { NostrService } from './NostrService';
import type { ProfileData, ReceivedEvent } from './NostrService';
/**
* Class for managing UI operations
*/
export class UiService {
private nostrService: NostrService;
private httpService: HttpService;
private receivedEvents = new Map<string, ReceivedEvent>();
// DOM Elements
private relayStatus: HTMLElement | null = null;
private eventsList: HTMLElement | null = null;
private eventDetails: HTMLElement | null = null;
/**
* Constructor
* @param nostrService An instance of NostrService
* @param httpService An instance of HttpService
*/
constructor(nostrService: NostrService, httpService: HttpService) {
this.nostrService = nostrService;
this.httpService = httpService;
}
/**
* Initialize the UI elements and event listeners
*/
public initialize(): void {
// Get DOM elements
this.relayStatus = document.getElementById('relayStatus');
this.eventsList = document.getElementById('eventsList');
this.eventDetails = document.getElementById('eventDetails');
// Initialize event handlers
this.setupTabNavigation();
this.setupQRScanner();
this.setupRawTextInput();
this.setupEventHandlers();
// Set event handler for Nostr events
this.nostrService.setEventHandler((event) => this.processEvent(event));
}
/**
* Set up tab navigation for the UI
*/
private setupTabNavigation(): void {
const tabs = document.querySelectorAll('.tab-content');
const tabButtons = document.querySelectorAll('.tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
// Remove active class from all buttons and tabs
tabButtons.forEach(b => b.classList.remove('active'));
tabs.forEach(tab => tab.classList.remove('active'));
// Add active class to clicked button and corresponding tab
button.classList.add('active');
const tabId = button.getAttribute('data-tab');
if (tabId) {
const activeTab = document.getElementById(tabId);
if (activeTab) {
activeTab.classList.add('active');
}
}
});
});
}
/**
* Set up QR scanner functionality
*/
private setupQRScanner(): void {
const scannerContainer = document.getElementById('qrScannerContainer');
const startScanBtn = document.getElementById('startScanBtn');
const stopScanBtn = document.getElementById('stopScanBtn');
const videoElement = document.getElementById('qrVideo') as HTMLVideoElement;
const qrResultInput = document.getElementById('qrResultInput') as HTMLTextAreaElement;
if (!scannerContainer || !startScanBtn || !stopScanBtn || !videoElement || !qrResultInput) {
return;
}
// Start QR scanner
async function startQRScanner(): Promise<void> {
try {
if (scannerContainer && startScanBtn && stopScanBtn) {
scannerContainer.classList.remove('hidden');
startScanBtn.classList.add('hidden');
stopScanBtn.classList.remove('hidden');
}
// Get user media
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
});
videoElement.srcObject = stream;
videoElement.play();
// Start scanning for QR codes
scanQRCode();
} catch (error) {
alert(`Error accessing camera: ${error instanceof Error ? error.message : String(error)}`);
stopQRScanner();
}
}
// Stop QR scanner
function stopQRScanner(): void {
if (scannerContainer && startScanBtn && stopScanBtn) {
scannerContainer.classList.add('hidden');
startScanBtn.classList.remove('hidden');
stopScanBtn.classList.add('hidden');
}
// Stop the video stream
if (videoElement.srcObject) {
const stream = videoElement.srcObject as MediaStream;
const tracks = stream.getTracks();
tracks.forEach(track => {
track.stop();
});
videoElement.srcObject = null;
}
}
// Add event listeners
startScanBtn.addEventListener('click', () => {
startQRScanner().catch(error => {
alert(`Error starting QR scanner: ${error instanceof Error ? error.message : String(error)}`);
});
});
stopScanBtn.addEventListener('click', stopQRScanner);
// Function to scan QR code from video feed
function scanQRCode(): void {
if (videoElement.readyState !== videoElement.HAVE_ENOUGH_DATA) {
// Not enough data yet, retry after a short delay
setTimeout(scanQRCode, 100);
return;
}
const canvas = document.createElement('canvas');
const width = videoElement.videoWidth;
const height = videoElement.videoHeight;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
setTimeout(scanQRCode, 100);
return;
}
ctx.drawImage(videoElement, 0, 0, width, height);
const imageData = ctx.getImageData(0, 0, width, height);
// Use jsQR to detect QR code
const code = jsQR(imageData.data, width, height, {
inversionAttempts: 'dontInvert'
});
if (code) {
// QR code detected
qrResultInput.value = code.data;
stopQRScanner();
} else {
// No QR code found, retry after a short delay
setTimeout(scanQRCode, 100);
}
}
}
/**
* Set up raw text input functionality
*/
private setupRawTextInput(): void {
const rawEventInput = document.getElementById('rawEventInput') as HTMLTextAreaElement;
const parseRawEventBtn = document.getElementById('parseRawEventBtn');
if (!rawEventInput || !parseRawEventBtn) {
return;
}
parseRawEventBtn.addEventListener('click', () => {
try {
const rawText = rawEventInput.value.trim();
if (!rawText) {
alert('Please enter a raw Nostr event.');
return;
}
// Try to parse as JSON
const eventData = JSON.parse(rawText);
// Validate as a Nostr event
if (!eventData.id || !eventData.pubkey || !eventData.created_at ||
!eventData.kind || !Array.isArray(eventData.tags) ||
typeof eventData.content !== 'string' || !eventData.sig) {
alert('Invalid Nostr event format.');
return;
}
// Process the event
this.processEvent(eventData as NostrEvent);
// Clear the input
rawEventInput.value = '';
// Show notification
alert('Event processed successfully!');
} catch (error) {
alert(`Error parsing event: ${error instanceof Error ? error.message : String(error)}`);
}
});
}
/**
* Set up event handlers for the UI
*/
private setupEventHandlers(): void {
// Connect to relay button
const connectRelayBtn = document.getElementById('connectRelayBtn');
const relayUrlInput = document.getElementById('relayUrlInput') as HTMLInputElement;
if (connectRelayBtn && relayUrlInput) {
connectRelayBtn.addEventListener('click', async () => {
const relayUrl = relayUrlInput.value.trim();
if (!relayUrl) {
alert('Please enter a relay URL');
return;
}
try {
// Connect to the relay
const success = await this.nostrService.connectToRelay(relayUrl);
if (success) {
// Subscribe to events
const showAllEventsCheckbox = document.getElementById('showAllEvents') as HTMLInputElement;
const showAllEvents = showAllEventsCheckbox ? showAllEventsCheckbox.checked : false;
const filter = this.nostrService.createKind21120Filter(showAllEvents);
await this.nostrService.subscribeToEvents(filter);
}
} catch (error) {
alert(`Error connecting to relay: ${error instanceof Error ? error.message : String(error)}`);
}
});
}
// Show all events checkbox
const showAllEventsCheckbox = document.getElementById('showAllEvents') as HTMLInputElement;
if (showAllEventsCheckbox) {
showAllEventsCheckbox.addEventListener('change', async () => {
try {
// Resubscribe with new filter
const filter = this.nostrService.createKind21120Filter(showAllEventsCheckbox.checked);
await this.nostrService.subscribeToEvents(filter);
} catch (error) {
alert(`Error updating subscription: ${error instanceof Error ? error.message : String(error)}`);
}
});
}
// Connect to default relay on page load
setTimeout(async () => {
if (relayUrlInput && relayUrlInput.value && connectRelayBtn) {
// Trigger click on the connect button
connectRelayBtn.click();
}
}, 500);
}
/**
* Process a received Nostr event
* @param event The Nostr event to process
*/
private processEvent(event: NostrEvent): void {
// Ensure event has an ID
if (!event.id) {
return;
}
// Check if this is a new event
if (this.receivedEvents.has(event.id)) {
return;
}
// Store the event
const receivedEvent: ReceivedEvent = {
id: event.id,
event: event,
receivedAt: Date.now(),
decrypted: false
};
this.receivedEvents.set(event.id, receivedEvent);
// Add event to UI
this.addEventToUI(receivedEvent);
}
/**
* Add an event to the UI
* @param receivedEvent The received event to add to the UI
*/
private addEventToUI(receivedEvent: ReceivedEvent): void {
if (!this.eventsList) {
return;
}
const event = receivedEvent.event;
// Create event item with improved styling
const eventItem = document.createElement('div');
eventItem.className = 'event-item';
eventItem.dataset.id = event.id;
// Get event ID for display
const eventIdForDisplay = event.id ? event.id.substring(0, 8) : 'unknown';
// Determine if it's a request or response
const hasP = event.tags.some(tag => tag[0] === 'p');
const hasE = event.tags.some(tag => tag[0] === 'e');
const eventType = hasP ? 'HTTP Request' : (hasE ? 'HTTP Response' : 'Unknown');
// Find recipient if available
let recipient = '';
const pTag = event.tags.find(tag => tag[0] === 'p');
if (pTag && pTag.length > 1) {
recipient = `| To: ${pTag[1].substring(0, 8)}...`;
}
// Format the event item
eventItem.innerHTML = `
<div class="event-item-container">
<div class="event-avatar" data-pubkey="${event.pubkey}">
<div class="avatar-placeholder">👤</div>
</div>
<div class="event-content-wrapper">
<div class="event-header">
<div class="event-type ${eventType === 'HTTP Request' ? 'request' : 'response'}">${eventType}</div>
<div class="event-time">${new Date(event.created_at * 1000).toLocaleTimeString()}</div>
</div>
<div class="event-id">ID: ${eventIdForDisplay}... ${recipient}</div>
<div class="event-pubkey">From: ${event.pubkey.substring(0, 8)}...</div>
</div>
</div>
`;
// Add to list at the top
if (this.eventsList.firstChild) {
this.eventsList.insertBefore(eventItem, this.eventsList.firstChild);
} else {
this.eventsList.appendChild(eventItem);
}
// Fetch profile for avatar after a short delay
const avatarElement = eventItem.querySelector('.event-avatar') as HTMLElement;
if (avatarElement) {
setTimeout(() => {
this.nostrService.fetchProfileData(event.pubkey)
.then(profile => this.updateAvatarElement(avatarElement, profile))
.catch(() => this.updateAvatarElement(avatarElement, null));
}, 10);
}
// Add click event to show details
eventItem.addEventListener('click', () => {
if (event.id) {
this.showEventDetails(event.id);
}
});
}
/**
* Update an avatar element with profile data
* @param element The element to update
* @param profile The profile data to use
*/
private updateAvatarElement(element: HTMLElement, profile: ProfileData | null): void {
if (!element) {
return;
}
if (profile && profile.picture) {
// Use the profile picture URL
element.innerHTML = `<img src="${profile.picture}" alt="Avatar" class="avatar-img" />`;
} else {
// Use default avatar
element.innerHTML = `<div class="avatar-placeholder">👤</div>`;
}
// Make sure it's visible
element.style.opacity = '1';
}
/**
* Show event details in the UI
* @param eventId The ID of the event to show details for
*/
public showEventDetails(eventId: string): void {
if (!this.eventDetails) {
console.error('Event details element not found!');
return;
}
const receivedEvent = this.receivedEvents.get(eventId);
if (!receivedEvent) {
console.error(`Event with ID ${eventId} not found in receivedEvents Map!`);
return;
}
const event = receivedEvent.event;
// Ensure event has an ID (should already be verified)
const eventIdForDisplay = event.id ? event.id.substring(0, 8) : 'unknown';
// Determine if this is a request or response
const isRequest = event.tags.some(tag => tag[0] === 'p');
const isResponse = event.tags.some(tag => tag[0] === 'e');
const eventTypeLabel = isRequest ? 'HTTP Request' : (isResponse ? 'HTTP Response' : 'Unknown Type');
// Get the decrypted or original content
const httpContent = receivedEvent.decrypted ?
(receivedEvent.decryptedContent || event.content) :
event.content;
// Create raw JSON representation of the event
const rawJson = JSON.stringify(event, null, 2);
// Display the event details with a tabbed interface
this.eventDetails.innerHTML = `
<div class="event-detail-header">
<h3>${eventTypeLabel} (ID: ${eventIdForDisplay}...)</h3>
<div class="event-timestamp">${new Date(event.created_at * 1000).toLocaleString()}</div>
</div>
<div class="event-detail-tabs">
<button class="tab-btn" data-tab="raw-json">Raw JSON</button>
<button class="tab-btn active" data-tab="http-content">HTTP Content</button>
</div>
<div class="event-detail-content">
<div class="tab-content" id="raw-json">
<pre class="json-content">${rawJson}</pre>
</div>
<div class="tab-content active" id="http-content">
${isRequest ?
`<div class="http-content-header">
<button class="execute-http-request-btn">Execute HTTP Request</button>
</div>` :
''
}
<pre class="http-content">${httpContent}</pre>
${!receivedEvent.decrypted ? '<div class="decryption-status">Attempting decryption...</div>' : ''}
</div>
</div>
`;
// Add event listeners for tab buttons
const tabButtons = this.eventDetails.querySelectorAll('.tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
// Get the target tab
const targetTabId = (button as HTMLElement).dataset.tab;
if (!targetTabId) {
return;
}
// Remove active class from all buttons and content sections
tabButtons.forEach(btn => btn.classList.remove('active'));
// Use non-null assertion since we already checked eventDetails isn't null at the beginning
this.eventDetails!.querySelectorAll('.tab-content').forEach(section => section.classList.remove('active'));
// Add active class to clicked button and corresponding content section
button.classList.add('active');
const targetSection = document.getElementById(targetTabId);
if (targetSection) {
targetSection.classList.add('active');
}
});
});
// Add event listener for the execute HTTP request button if it exists
const executeButton = this.eventDetails.querySelector('.execute-http-request-btn');
if (executeButton && isRequest) {
executeButton.addEventListener('click', async () => {
// Change button state to indicate processing
executeButton.textContent = 'Executing...';
executeButton.setAttribute('disabled', 'true');
try {
// Execute the HTTP request
const response = await this.httpService.executeHttpRequest(httpContent);
// Display the response
this.displayHttpResponse(response);
} catch (error) {
// Display error in a modal
this.displayHttpResponse(`Error executing request: ${error instanceof Error ? error.message : String(error)}`);
} finally {
// Restore button state
executeButton.textContent = 'Execute HTTP Request';
executeButton.removeAttribute('disabled');
}
});
}
}
/**
* Display an HTTP response in a modal
* @param responseText The response text to display
*/
private displayHttpResponse(responseText: string): void {
// Create a modal dialog element
const modal = document.createElement('div');
modal.className = 'http-response-modal';
// Add the response content
modal.innerHTML = `
<div class="http-response-container">
<div class="http-response-header">
<h3>HTTP Response</h3>
<button class="close-modal-btn">&times;</button>
</div>
<div class="http-response-content">
<pre>${responseText}</pre>
</div>
</div>
`;
// Add the modal to the DOM
document.body.appendChild(modal);
// Add event listener to close the modal
const closeBtn = modal.querySelector('.close-modal-btn');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
document.body.removeChild(modal);
});
}
// Also close the modal when clicking outside the content
modal.addEventListener('click', (event) => {
if (event.target === modal) {
document.body.removeChild(modal);
}
});
}
/**
* Attempt to decrypt an event using NIP-44
* @param eventId The ID of the event to decrypt
*/
public async decryptEvent(eventId: string): Promise<void> {
const receivedEvent = this.receivedEvents.get(eventId);
if (!receivedEvent || receivedEvent.decrypted) {
return;
}
try {
const event = receivedEvent.event;
let decryptedContent: string;
// Look for a "key" tag in the event
const keyTag = event.tags.find(tag => tag[0] === 'key');
if (!keyTag || keyTag.length < 2) {
decryptedContent = `[No key tag found for decryption]\n${event.content}`;
} else {
try {
// Extract the encrypted key from the tag
const encryptedKey = keyTag[1];
// Check if window.nostr and nip44.decrypt are available
if (!window.nostr) {
throw new Error("window.nostr is not available - ensure a NIP-07 extension is installed");
}
if (!window.nostr.nip44 || !window.nostr.nip44.decrypt) {
console.warn("NIP-44 decryption not available - trying to connect to a compatible extension");
// Check if we can trigger a connection via NostrLogin
if (typeof window.nostr.getPublicKey === 'function') {
try {
await window.nostr.getPublicKey();
// Check again after getting public key
if (typeof window.nostr.nip44 === 'object' &&
typeof window.nostr.nip44.decrypt === 'function') {
console.log("NIP-44 is now available after connecting");
} else {
throw new Error("NIP-44 decryption is still not available after connecting");
}
} catch (e) {
throw new Error(`Failed to connect to extension: ${e}`);
}
} else {
throw new Error("window.nostr.nip44.decrypt is not available");
}
}
// Declare decryptedKey outside the try block so it's available in the outer scope
let decryptedKey: string;
try {
// Use window.nostr to decrypt the key tag with NIP-44
decryptedKey = await window.nostr.nip44.decrypt(
event.pubkey, // The pubkey that encrypted the key
encryptedKey // The encrypted key from the "key" tag
);
} catch (decryptKeyError) {
console.error("Error in direct decryption call:", decryptKeyError);
throw decryptKeyError; // Re-throw to be caught by the outer catch block
}
// Now use the decrypted key to decrypt the content using Web Crypto API
try {
// Decrypt the content using Web Crypto API and the decrypted key
const decryptedEventContent = await this.httpService.decryptWithWebCrypto(
event.content,
decryptedKey
);
// The decrypted content is the direct HTTP request/response
decryptedContent = decryptedEventContent;
} catch (contentDecryptError) {
decryptedContent = `Key decryption successful, but content decryption failed: ${contentDecryptError instanceof Error ? contentDecryptError.message : String(contentDecryptError)}
Encrypted content: ${event.content.substring(0, 50)}...
Decrypted key: ${decryptedKey}`;
}
} catch (decryptError) {
console.error("Failed to decrypt with window.nostr.nip44:", decryptError instanceof Error ? decryptError.message : String(decryptError));
decryptedContent = `[NIP-44 decryption failed: ${decryptError instanceof Error ? decryptError.message : String(decryptError)}]\n${event.content}`;
}
}
// Update the event
receivedEvent.decrypted = true;
receivedEvent.decryptedContent = decryptedContent;
this.receivedEvents.set(eventId, receivedEvent);
// Update UI if this event is currently being viewed
const selectedEventId = this.eventDetails?.querySelector('h3')?.textContent?.match(/(.+)\.\.\./)?.[1];
if (selectedEventId && `${selectedEventId}...` === `${eventId.substring(0, 8)}...`) {
this.showEventDetails(eventId);
}
} catch (error) {
console.error("Failed to process event:", error);
}
}
/**
* Update relay status in UI
* @param message The status message
* @param className The CSS class name
*/
public updateRelayStatus(message: string, className: string): void {
if (this.relayStatus) {
this.relayStatus.textContent = message;
this.relayStatus.className = `relay-status ${className}`;
}
}
}

@ -0,0 +1,179 @@
/**
* WebSocketManager.ts
* Handles WebSocket connections and abstracts the WebSocket API
*/
export interface WebSocketOptions {
timeout?: number;
// eslint-disable-next-line no-unused-vars
onOpen?: (socket: WebSocket) => void;
// eslint-disable-next-line no-unused-vars
onMessage?: (data: unknown) => void;
// eslint-disable-next-line no-unused-vars
onError?: (error: Event) => void;
onClose?: () => void;
}
export class WebSocketManager {
private ws: WebSocket | null = null;
private connected = false;
private url: string | null = null;
/**
* Create a WebSocket connection
* @param url The WebSocket URL to connect to
* @param options Options for the WebSocket connection
* @returns A promise that resolves when the connection is established
*/
public async connect(url: string, options: WebSocketOptions = {}): Promise<WebSocket> {
return new Promise<WebSocket>((resolve, reject) => {
// Close existing connection if any
this.close();
this.url = url;
this.ws = new WebSocket(url);
this.connected = false;
// Set a timeout to avoid hanging
const timeout = setTimeout(() => {
if (!this.connected) {
this.close();
reject(new Error(`Connection timeout after ${options.timeout || 5000}ms`));
}
}, options.timeout || 5000);
// Set up event handlers
this.ws.onopen = () => {
clearTimeout(timeout);
this.connected = true;
if (options.onOpen) {
const callback = options.onOpen;
callback(this.ws as WebSocket);
}
resolve(this.ws as WebSocket);
};
this.ws.onmessage = (msg) => {
if (options.onMessage && typeof msg.data === 'string') {
try {
const parsedData = JSON.parse(msg.data);
const callback = options.onMessage;
callback(parsedData);
} catch {
// Ignore parsing errors
}
}
};
this.ws.onerror = (errorEvt) => {
clearTimeout(timeout);
if (options.onError) {
const callback = options.onError;
callback(errorEvt);
}
if (!this.connected) {
reject(new Error(`WebSocket error: ${errorEvt.toString()}`));
}
};
this.ws.onclose = () => {
clearTimeout(timeout);
this.connected = false;
if (options.onClose) {
options.onClose();
}
};
});
}
/**
* Test a WebSocket connection without creating a persistent connection
* @param url The WebSocket URL to test
* @param timeout Timeout in milliseconds
* @returns A promise that resolves when the connection test is complete
*/
public async testConnection(url: string, timeout = 5000): Promise<boolean> {
try {
const ws = new WebSocket(url);
let connected = false;
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
if (!connected) {
ws.close();
reject(new Error('Connection timeout'));
}
}, timeout);
ws.onopen = () => {
clearTimeout(timeoutId);
connected = true;
resolve();
};
ws.onerror = (err) => {
clearTimeout(timeoutId);
reject(new Error(`WebSocket error: ${err.toString()}`));
};
});
ws.close();
return true;
} catch {
return false;
}
}
/**
* Send data through the WebSocket
* @param data The data to send
* @returns true if sent successfully, false otherwise
*/
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): boolean {
if (!this.ws || !this.connected) {
return false;
}
try {
this.ws.send(data);
return true;
} catch {
return false;
}
}
/**
* Close the WebSocket connection
*/
public close(): void {
if (this.ws) {
try {
this.ws.close();
} catch {
// Ignore errors when closing WebSocket
}
this.ws = null;
this.connected = false;
this.url = null;
}
}
/**
* Check if the WebSocket is connected
*/
public isConnected(): boolean {
return this.connected && this.ws !== null;
}
/**
* Get the current WebSocket URL
*/
public getUrl(): string | null {
return this.url;
}
}

@ -454,6 +454,59 @@ footer {
margin-bottom: 20px;
padding: 15px;
background-color: var(--bg-secondary);
display: flex;
flex-direction: column;
gap: 15px;
}
/* Server info styles */
.server-info-container {
margin-bottom: 10px;
padding-bottom: 15px;
border-bottom: 1px solid var(--border-color);
}
.server-npub-container {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.server-npub-container label {
font-weight: 600;
min-width: 100px;
}
.server-npub-input {
flex: 1;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-tertiary);
color: var(--text-secondary);
font-family: 'Courier New', monospace;
font-size: 14px;
min-width: 250px;
}
.copy-btn {
padding: 8px 12px;
background-color: var(--button-primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.copy-btn:hover {
background-color: var(--button-hover);
}
.copy-btn.copied {
background-color: var(--button-success);
border-radius: 8px;
border: 1px solid var(--border-color);
}
@ -1238,6 +1291,133 @@ footer {
background-color: #c82333;
}
/* HTTP Content Header for the Execute button */
.http-content-header {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.execute-http-request-btn {
padding: 8px 15px;
background-color: var(--button-primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.execute-http-request-btn:hover {
background-color: var(--button-hover);
}
.execute-http-request-btn:disabled {
background-color: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
}
/* HTTP Response Modal */
.http-response-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.http-response-container {
width: 80%;
max-width: 800px;
max-height: 80vh;
background-color: var(--bg-secondary);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
overflow: hidden;
}
.http-response-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background-color: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.http-response-header h3 {
margin: 0;
color: var(--accent-color);
}
.close-modal-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-secondary);
transition: color 0.2s;
}
.close-modal-btn:hover {
color: var(--accent-color);
}
.http-response-content {
padding: 20px;
overflow-y: auto;
max-height: calc(80vh - 60px);
}
.http-response-content pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
/* Event item with avatar layout */
.event-item-container {
display: flex;
align-items: flex-start;
gap: 12px;
}
.event-avatar {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
background-color: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.3s ease;
}
.event-content-wrapper {
flex: 1;
min-width: 0; /* Prevent flex item from overflowing */
}
.avatar-placeholder {
font-size: 20px;
color: var(--text-tertiary);
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Tab Interface Styles */
.tabs {
display: flex;