Browse Source

bug-fixing

make updates more complete
handle login persistence
master
Silberengel 1 month ago
parent
commit
6c1a9904fd
  1. 4
      public/healthz.json
  2. 10
      src/lib/components/EventMenu.svelte
  3. 38
      src/lib/components/content/FileExplorer.svelte
  4. 357
      src/lib/components/content/MarkdownRenderer.svelte
  5. 8
      src/lib/components/content/MediaAttachments.svelte
  6. 108
      src/lib/components/modals/UpdateModal.svelte
  7. 16
      src/lib/components/preferences/UserPreferences.svelte
  8. 30
      src/lib/components/profile/ProfileMenu.svelte
  9. 65
      src/lib/modules/feed/FeedPost.svelte
  10. 213
      src/lib/services/auth/session-manager.ts
  11. 365
      src/lib/services/cache/cache-prewarmer.ts
  12. 106
      src/lib/services/cache/indexeddb-store.ts
  13. 55
      src/lib/services/preferences.ts
  14. 100
      src/routes/+layout.svelte
  15. 1
      src/routes/about/+page.svelte
  16. 8
      src/routes/discussions/+page.svelte
  17. 32
      src/routes/feed/+page.svelte
  18. 30
      src/routes/lists/+page.svelte
  19. 2
      src/routes/relay/+page.svelte
  20. 2
      src/routes/write/+page.svelte

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.3.1",
"buildTime": "2026-02-12T11:34:23.480Z",
"buildTime": "2026-02-12T12:55:08.903Z",
"gitCommit": "unknown",
"timestamp": 1770896063480
"timestamp": 1770900908903
}

10
src/lib/components/EventMenu.svelte

@ -418,7 +418,7 @@ @@ -418,7 +418,7 @@
</button>
{#if isReplaceable}
<button class="menu-item" onclick={openVersionHistory}>
<span class="menu-item-icon">🕐</span>
<span class="menu-item-icon"><Icon name="database" size={16} /></span>
<span>See version history</span>
</button>
{/if}
@ -508,7 +508,7 @@ @@ -508,7 +508,7 @@
{#if isLoggedIn && !isOwnEvent}
<div class="menu-divider"></div>
<button class="menu-item menu-item-danger" onclick={openReportModal}>
<span class="menu-item-icon">🚩</span>
<span class="menu-item-icon"><Icon name="x" size={16} /></span>
<span>Report this event</span>
</button>
{/if}
@ -646,6 +646,12 @@ @@ -646,6 +646,12 @@
flex-shrink: 0;
font-size: 1rem;
line-height: 1;
display: inline-flex;
align-items: center;
}
.menu-item-icon :global(.icon-wrapper) {
display: inline-block;
}
.menu-item span {

38
src/lib/components/content/FileExplorer.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
// @ts-ignore - highlight.js default export works at runtime
import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css';
import Icon from '../ui/Icon.svelte';
interface Props {
files: GitFile[];
@ -137,20 +138,25 @@ @@ -137,20 +138,25 @@
}
}
function getFileIcon(file: GitFile): string {
function getFileIconName(file: GitFile): string {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const icons: Record<string, string> = {
'js': '📜', 'ts': '📘', 'jsx': '⚛', 'tsx': '⚛',
'py': '🐍', 'java': '☕', 'cpp': '⚙', 'c': '⚙',
'html': '🌐', 'css': '🎨', 'scss': '🎨', 'sass': '🎨',
'json': '📋', 'yaml': '📋', 'yml': '📋', 'toml': '📋',
'md': '📝', 'txt': '📄', 'adoc': '📝',
'png': '🖼', 'jpg': '🖼', 'jpeg': '🖼', 'gif': '🖼', 'svg': '🖼',
'pdf': '📕', 'zip': '📦', 'tar': '📦', 'gz': '📦',
'sh': '💻', 'bash': '💻', 'zsh': '💻',
'rs': '🦀', 'go': '🐹', 'php': '🐘', 'rb': '💎'
const iconMap: Record<string, string> = {
// Code files
'js': 'code', 'ts': 'code', 'jsx': 'code', 'tsx': 'code',
'py': 'code', 'java': 'code', 'cpp': 'code', 'c': 'code',
'html': 'code', 'css': 'code', 'scss': 'code', 'sass': 'code',
'rs': 'code', 'go': 'code', 'php': 'code', 'rb': 'code',
'sh': 'code', 'bash': 'code', 'zsh': 'code',
// Data/config files
'json': 'file-text', 'yaml': 'file-text', 'yml': 'file-text', 'toml': 'file-text',
// Text files
'md': 'file-text', 'txt': 'file-text', 'adoc': 'file-text',
// Images
'png': 'image', 'jpg': 'image', 'jpeg': 'image', 'gif': 'image', 'svg': 'image',
// Other files
'pdf': 'file-text', 'zip': 'file-text', 'tar': 'file-text', 'gz': 'file-text'
};
return icons[ext] || '📄';
return iconMap[ext] || 'file-text';
}
function formatFileSize(bytes?: number): string {
@ -326,7 +332,7 @@ @@ -326,7 +332,7 @@
onclick={() => fetchFileContent(file)}
class="tree-file-btn"
>
<span class="tree-icon">{getFileIcon(file)}</span>
<span class="tree-icon"><Icon name={getFileIconName(file)} size={16} /></span>
<span class="tree-name">{subName}</span>
{#if file.size}
<span class="tree-size">{formatFileSize(file.size)}</span>
@ -364,7 +370,7 @@ @@ -364,7 +370,7 @@
onclick={() => fetchFileContent(nestedFile)}
class="tree-file-btn"
>
<span class="tree-icon">{getFileIcon(nestedFile)}</span>
<span class="tree-icon"><Icon name={getFileIconName(nestedFile)} size={16} /></span>
<span class="tree-name">{nestedName}</span>
{#if nestedFile.size}
<span class="tree-size">{formatFileSize(nestedFile.size)}</span>
@ -401,7 +407,7 @@ @@ -401,7 +407,7 @@
onclick={() => fetchFileContent(deepFile)}
class="tree-file-btn"
>
<span class="tree-icon">{getFileIcon(deepFile)}</span>
<span class="tree-icon"><Icon name={getFileIconName(deepFile)} size={16} /></span>
<span class="tree-name">{deepName}</span>
{#if deepFile.size}
<span class="tree-size">{formatFileSize(deepFile.size)}</span>
@ -436,7 +442,7 @@ @@ -436,7 +442,7 @@
onclick={() => fetchFileContent(file)}
class="tree-file-btn"
>
<span class="tree-icon">{getFileIcon(file)}</span>
<span class="tree-icon"><Icon name={getFileIconName(file)} size={16} /></span>
<span class="tree-name">{name}</span>
{#if file.size}
<span class="tree-size">{formatFileSize(file.size)}</span>

357
src/lib/components/content/MarkdownRenderer.svelte

@ -124,68 +124,6 @@ @@ -124,68 +124,6 @@
}
}
// Convert plain media URLs (images, videos, audio) to HTML tags
function convertMediaUrls(text: string): string {
// Match media URLs (http/https URLs ending in media extensions)
const imageExtensions = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i;
const videoExtensions = /\.(mp4|webm|ogg|mov|avi|mkv)(\?[^\s<>"']*)?$/i;
const audioExtensions = /\.(mp3|wav|ogg|flac|aac|m4a)(\?[^\s<>"']*)?$/i;
const urlPattern = /https?:\/\/[^\s<>"']+/g;
// Normalize exclude URLs for comparison
const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url)));
let result = text;
const matches: Array<{ url: string; index: number; endIndex: number; type: 'image' | 'video' | 'audio' }> = [];
// Find all URLs
let match;
while ((match = urlPattern.exec(text)) !== null) {
const url = match[0];
const index = match.index;
const endIndex = index + url.length;
// Skip if this URL is already displayed by MediaAttachments
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
continue;
}
// Check if this URL is already in markdown or HTML
const before = text.substring(Math.max(0, index - 10), index);
const after = text.substring(endIndex, Math.min(text.length, endIndex + 10));
// Skip if it's already in markdown or HTML tags
if (before.includes('![') || before.includes('<img') || before.includes('<video') || before.includes('<audio') ||
after.startsWith('](') || after.startsWith('</img>') || after.startsWith('</video>') || after.startsWith('</audio>')) {
continue;
}
// Determine media type
if (imageExtensions.test(url)) {
matches.push({ url, index, endIndex, type: 'image' });
} else if (videoExtensions.test(url)) {
matches.push({ url, index, endIndex, type: 'video' });
} else if (audioExtensions.test(url)) {
matches.push({ url, index, endIndex, type: 'audio' });
}
}
// Replace from end to start to preserve indices
for (let i = matches.length - 1; i >= 0; i--) {
const { url, index, endIndex, type } = matches[i];
const escapedUrl = escapeHtml(url);
if (type === 'image') {
result = result.substring(0, index) + `<img src="${escapedUrl}" alt="" style="max-width: 600px; width: 100%; height: auto;" />` + result.substring(endIndex);
} else if (type === 'video') {
result = result.substring(0, index) + `<video src="${escapedUrl}" controls preload="none" style="max-width: 600px; width: 100%; height: auto; max-height: 500px;"></video>` + result.substring(endIndex);
} else if (type === 'audio') {
result = result.substring(0, index) + `<audio src="${escapedUrl}" controls preload="none" style="max-width: 600px; width: 100%;"></audio>` + result.substring(endIndex);
}
}
return result;
}
// Resolve custom emojis in content
async function resolveContentEmojis(text: string): Promise<void> {
@ -502,10 +440,144 @@ @@ -502,10 +440,144 @@
return result;
}
// Process and convert media URLs: handles markdown, AsciiDoc, and plain URLs
// Removes excluded URLs (displayed by MediaAttachments) and converts others to HTML tags
function processMediaUrls(text: string): string {
// Normalize exclude URLs for comparison
const normalizedExcludeUrls = excludeMediaUrls.length > 0
? new Set(excludeMediaUrls.map(url => normalizeUrl(url)))
: new Set<string>();
// Debug: Log excluded URLs and check if they're being found in text
if (normalizedExcludeUrls.size > 0 && typeof console !== 'undefined') {
console.debug('MarkdownRenderer: Excluding URLs:', Array.from(normalizedExcludeUrls));
console.debug('MarkdownRenderer: Original text:', text.substring(0, 200));
// Check if any excluded URLs appear in the text
for (const excludedUrl of normalizedExcludeUrls) {
// Try to find the URL in the text (check both normalized and original)
const urlInText = text.includes(excludedUrl) ||
excludeMediaUrls.some(orig => text.includes(orig));
if (urlInText) {
console.debug('MarkdownRenderer: Found excluded URL in text:', excludedUrl);
// Find where it appears
const index = text.indexOf(excludeMediaUrls.find(orig => text.includes(orig)) || '');
if (index >= 0) {
console.debug('MarkdownRenderer: URL found at index:', index, 'context:', text.substring(Math.max(0, index - 20), Math.min(text.length, index + 100)));
}
}
}
}
let result = text;
const replacements: Array<{ fullMatch: string; replacement: string; index: number }> = [];
const processedIndices = new Set<number>(); // Track processed indices to avoid duplicates
// 1. Match markdown image syntax: ![alt](url)
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let match;
while ((match = markdownImageRegex.exec(text)) !== null) {
const fullMatch = match[0];
const alt = match[1];
const url = match[2];
const index = match.index;
if (processedIndices.has(index)) continue;
processedIndices.add(index);
const normalizedUrl = normalizeUrl(url);
if (normalizedExcludeUrls.has(normalizedUrl)) {
// Remove excluded markdown images, leaving just alt text
replacements.push({ fullMatch, replacement: alt || '', index });
} else {
// Convert non-excluded markdown images to HTML (marked will handle this, but we ensure it's preserved)
// Marked will convert this, so we don't need to do anything here
}
}
// 2. Match AsciiDoc image syntax: image::url[] or image:url[]
const asciidocImageRegex = /image::?([^\s\[\]]+)(\[[^\]]*\])?/g;
asciidocImageRegex.lastIndex = 0;
let asciidocMatch;
while ((asciidocMatch = asciidocImageRegex.exec(text)) !== null) {
const fullMatch = asciidocMatch[0];
const url = asciidocMatch[1];
const index = asciidocMatch.index;
if (processedIndices.has(index)) continue;
// Only process if it's a URL (starts with http:// or https://)
if (url.startsWith('http://') || url.startsWith('https://')) {
processedIndices.add(index);
const normalizedUrl = normalizeUrl(url);
if (normalizedExcludeUrls.has(normalizedUrl)) {
// Remove excluded AsciiDoc images
replacements.push({ fullMatch, replacement: '', index });
}
// Non-excluded AsciiDoc images will be handled by AsciiDoc renderer
}
}
// 3. Match plain media URLs (using the SAME pattern as getMediaAttachmentUrls)
const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|flac|aac|m4a)(\?[^\s<>"{}|\\^`\[\]]*)?)/gi;
urlRegex.lastIndex = 0;
while ((match = urlRegex.exec(text)) !== null) {
const url = match[1]; // The URL part (group 1)
const fullMatch = match[0]; // Full match
const index = match.index;
if (processedIndices.has(index)) continue;
processedIndices.add(index);
// Check if it's already in markdown or HTML
const before = text.substring(Math.max(0, index - 10), index);
const after = text.substring(index + fullMatch.length, Math.min(text.length, index + fullMatch.length + 10));
if (before.includes('![') || before.includes('<img') || before.includes('<video') || before.includes('<audio') ||
after.startsWith('](') || after.startsWith('</img>') || after.startsWith('</video>') || after.startsWith('</audio>')) {
continue; // Already handled by markdown/HTML
}
const normalizedUrl = normalizeUrl(url);
if (normalizedExcludeUrls.has(normalizedUrl)) {
// Remove excluded plain URLs
if (typeof console !== 'undefined') {
console.debug('MarkdownRenderer: Found excluded URL, removing:', url, 'normalized:', normalizedUrl);
}
replacements.push({ fullMatch, replacement: '', index });
} else {
if (typeof console !== 'undefined' && normalizedExcludeUrls.size > 0) {
console.debug('MarkdownRenderer: URL not excluded, will convert:', url, 'normalized:', normalizedUrl, 'excluded set:', Array.from(normalizedExcludeUrls));
}
// Convert non-excluded plain URLs to HTML tags
const ext = match[2].toLowerCase();
let htmlTag: string;
if (['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(ext)) {
htmlTag = `<video src="${escapeHtml(url)}" controls preload="none" style="max-width: 600px; width: 100%; height: auto; max-height: 500px;"></video>`;
} else if (['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(ext)) {
htmlTag = `<audio src="${escapeHtml(url)}" controls preload="none" style="max-width: 600px; width: 100%;"></audio>`;
} else {
htmlTag = `<img src="${escapeHtml(url)}" alt="" style="max-width: 600px; width: 100%; height: auto;" />`;
}
replacements.push({ fullMatch, replacement: htmlTag, index });
}
}
// Apply all replacements from end to start to preserve indices
replacements.sort((a, b) => b.index - a.index);
for (const { fullMatch, replacement, index } of replacements) {
result = result.substring(0, index) + replacement + result.substring(index + fullMatch.length);
}
return result;
}
// Process content: replace nostr URIs with HTML span elements and convert media URLs
function processContent(text: string): string {
// First, convert greentext (must be before markdown processing)
let processed = convertGreentext(text);
// Process media URLs (removes excluded ones, converts others to HTML)
let processed = processMediaUrls(text);
// Then, convert greentext (must be before markdown processing)
processed = convertGreentext(processed);
// Convert wikilinks to markdown links (before other processing)
processed = convertWikilinks(processed);
@ -516,9 +588,6 @@ @@ -516,9 +588,6 @@
// Convert hashtags to links
processed = convertHashtags(processed);
// Then, convert plain media URLs (images, videos, audio) to HTML tags
processed = convertMediaUrls(processed);
// Find all NIP-21 links (nostr:npub, nostr:nprofile, nostr:nevent, etc.)
const links = findNIP21Links(processed);
@ -688,6 +757,48 @@ @@ -688,6 +757,48 @@
});
}
// Helper function to apply exclusion filtering to HTML (used for both fresh and cached content)
function applyExclusionFiltering(htmlContent: string): string {
if (excludeMediaUrls.length === 0) return htmlContent;
const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url)));
let filtered = htmlContent;
// Remove ALL img tags with excluded URLs
filtered = filtered.replace(/<img[^>]*>/gi, (match) => {
const srcMatch = match.match(/src=["']([^"']+)["']/i);
if (srcMatch) {
const src = srcMatch[1];
const normalizedSrc = normalizeUrl(src);
if (normalizedExcludeUrls.has(normalizedSrc)) {
if (typeof console !== 'undefined') {
console.debug('MarkdownRenderer: Removing excluded img tag from content:', src);
}
return '';
}
}
return match;
});
// Remove links pointing to excluded image URLs
filtered = filtered.replace(/<a\s+([^>]*?)href=["']([^"']+)["']([^>]*?)>([^<]*?)<\/a>/gi, (match, beforeAttrs, url, afterAttrs, linkText) => {
if (normalizedExcludeUrls.has(normalizeUrl(url)) && /\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?|$)/i.test(url)) {
return linkText;
}
return match;
});
// Remove excluded image URLs from text nodes
filtered = filtered.replace(/>([^<]*?)(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?[^\s<>"{}|\\^`\[\]]*)?)([^<]*?)</gi, (match, before, url, ext, query, after) => {
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
return `>${before}${after}<`;
}
return match;
});
return filtered;
}
// Render markdown or AsciiDoc to HTML
async function renderMarkdown(text: string): Promise<string> {
if (!content) return '';
@ -710,13 +821,15 @@ @@ -710,13 +821,15 @@
}
}
markdownCache.set(cacheKey, cachedFromDB);
return cachedFromDB;
// Apply exclusion filtering to cached content (exclusion list may have changed)
return applyExclusionFiltering(cachedFromDB);
}
// Check in-memory cache (faster for same session)
const cached = markdownCache.get(cacheKey);
if (cached !== undefined) {
return cached;
// Apply exclusion filtering to cached content (exclusion list may have changed)
return applyExclusionFiltering(cached);
}
const processed = processContent(contentToRender);
@ -744,6 +857,9 @@ @@ -744,6 +857,9 @@
// Post-process to fix any greentext that markdown converted to blockquotes
html = postProcessGreentext(html);
// Apply exclusion filtering to the rendered HTML
html = applyExclusionFiltering(html);
// Remove emoji images that are inside code blocks (they should be plain text)
// This handles cases where emojis were replaced before markdown/AsciiDoc parsing
// Handle <code> tags (inline code)
@ -773,9 +889,34 @@ @@ -773,9 +889,34 @@
// Normalize exclude URLs for comparison
const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url)));
// AGGRESSIVE CLEANUP: Remove ALL img tags with excluded URLs first (before any other processing)
// This is the most important step - it catches images regardless of how they were created
if (normalizedExcludeUrls.size > 0) {
const beforeCleanup = html;
html = html.replace(/<img[^>]*>/gi, (match) => {
// Extract src attribute
const srcMatch = match.match(/src=["']([^"']+)["']/i);
if (srcMatch) {
const src = srcMatch[1];
const normalizedSrc = normalizeUrl(src);
// Remove if this image is already displayed by MediaAttachments
if (normalizedExcludeUrls.has(normalizedSrc)) {
if (typeof console !== 'undefined') {
console.debug('MarkdownRenderer: Removing excluded img tag:', src, 'normalized:', normalizedSrc);
}
return ''; // Remove the img tag completely
}
}
return match;
});
if (typeof console !== 'undefined' && beforeCleanup !== html) {
console.debug('MarkdownRenderer: Cleaned up img tags. Before:', beforeCleanup.match(/<img[^>]*>/gi)?.length || 0, 'After:', html.match(/<img[^>]*>/gi)?.length || 0);
}
}
// Fix malformed image tags - ensure all img src attributes are absolute URLs
// This prevents the browser from trying to fetch markdown syntax or malformed tags as relative URLs
// Also filter out images that are already displayed by MediaAttachments
// Also filter out images that are already displayed by MediaAttachments (defensive check)
html = html.replace(/<img\s+([^>]*?)>/gi, (match, attributes) => {
// Extract src attribute
const srcMatch = attributes.match(/src=["']([^"']+)["']/i);
@ -846,6 +987,11 @@ @@ -846,6 +987,11 @@
const urlMatch = pattern.match(/(https?:\/\/[^\s<>"')]+)/i);
if (urlMatch) {
const url = urlMatch[1];
// Skip if this URL is already displayed by MediaAttachments
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
// Remove the URL pattern completely
return `>${before}${after}<`;
}
// If it's an image URL, convert to proper img tag
if (/\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i.test(url)) {
const escapedUrl = escapeHtml(url);
@ -856,6 +1002,32 @@ @@ -856,6 +1002,32 @@
return `>${before}${after}<`;
});
// Final aggressive cleanup: Remove ANY reference to excluded URLs from the HTML
// This catches URLs that might have been converted to links by the markdown renderer
// or appeared in any other form
if (normalizedExcludeUrls.size > 0) {
// Remove links that point to excluded image URLs (marked might auto-link them)
html = html.replace(/<a\s+([^>]*?)href=["']([^"']+)["']([^>]*?)>([^<]*?)<\/a>/gi, (match, beforeAttrs, url, afterAttrs, linkText) => {
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
// Remove the link but keep just the text content
return linkText;
}
return match;
});
// Remove excluded image URLs from text nodes (final safety net)
// Match URLs in text content (between > and <)
html = html.replace(/>([^<]*?)(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?[^\s<>"{}|\\^`\[\]]*)?)([^<]*?)</gi, (match, before, url, ext, query, after) => {
// Check if this URL is excluded (already displayed by MediaAttachments)
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
// Remove the URL from the text completely
return `>${before}${after}<`;
}
// Otherwise, leave it as-is
return match;
});
}
// Filter out invalid relative links (like /a, /b, etc.) that cause 404s
// These are likely malformed markdown links
html = html.replace(/<a\s+href="\/([a-z]|\.)"[^>]*>.*?<\/a>/gi, (match, char) => {
@ -869,8 +1041,51 @@ @@ -869,8 +1041,51 @@
return textMatch ? textMatch[1] : '';
});
// FINAL PASS: Remove any remaining references to excluded URLs
// This is the absolute last step before sanitization - catches anything we might have missed
if (normalizedExcludeUrls.size > 0) {
// Remove any img tags with excluded URLs (one more time, just to be sure)
html = html.replace(/<img[^>]*src=["']([^"']+)["'][^>]*>/gi, (match, src) => {
if (normalizedExcludeUrls.has(normalizeUrl(src))) {
return '';
}
return match;
});
// Remove any links pointing to excluded image URLs
html = html.replace(/<a\s+[^>]*href=["']([^"']+)["'][^>]*>([^<]*?)<\/a>/gi, (match, href, linkText) => {
if (normalizedExcludeUrls.has(normalizeUrl(href))) {
// Check if it's an image URL (ends with image extension)
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?|$)/i.test(href)) {
return linkText; // Remove link, keep text
}
}
return match;
});
// Remove excluded image URLs from any remaining text content
html = html.replace(/(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?[^\s<>"{}|\\^`\[\]]*)?)/gi, (match, url) => {
if (normalizedExcludeUrls.has(normalizeUrl(url))) {
return '';
}
return match;
});
}
// Sanitize HTML (but preserve our data attributes and image src)
const sanitized = sanitizeMarkdown(html);
let sanitized = sanitizeMarkdown(html);
// ONE MORE FINAL PASS after sanitization - remove any excluded URLs that might have survived
// This is the absolute last chance to catch excluded images
if (normalizedExcludeUrls.size > 0) {
sanitized = sanitized.replace(/<img[^>]*>/gi, (match) => {
const srcMatch = match.match(/src=["']([^"']+)["']/i);
if (srcMatch && normalizedExcludeUrls.has(normalizeUrl(srcMatch[1]))) {
return '';
}
return match;
});
}
// Cache the result (with size limit to prevent memory bloat)
if (markdownCache.size >= MAX_CACHE_SIZE) {

8
src/lib/components/content/MediaAttachments.svelte

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
import type { NostrEvent } from '../../types/nostr.js';
import { decode } from 'blurhash';
import { onMount } from 'svelte';
import Icon from '../ui/Icon.svelte';
interface Props {
event: NostrEvent;
@ -685,7 +686,8 @@ @@ -685,7 +686,8 @@
rel="noopener noreferrer"
class="file-link"
>
📎 {item.mimeType || 'File'} {item.size ? `(${(item.size / 1024).toFixed(1)} KB)` : ''}
<Icon name="file-text" size={16} />
<span>{item.mimeType || 'File'} {item.size ? `(${(item.size / 1024).toFixed(1)} KB)` : ''}</span>
</a>
</div>
{/if}
@ -773,6 +775,10 @@ @@ -773,6 +775,10 @@
gap: 0.5rem;
}
.file-link :global(.icon-wrapper) {
flex-shrink: 0;
}
.file-link:hover {
text-decoration: underline;
}

108
src/lib/components/modals/UpdateModal.svelte

@ -45,38 +45,64 @@ @@ -45,38 +45,64 @@
};
try {
// Step 1: Ensure database is up to date
// Step 1: Ensure database is up to date (with timeout)
progress = {
step: 'Updating database structure...',
progress: 5,
total: 7,
total: 8,
current: 1
};
await getDB(); // This will trigger IndexedDB upgrade if needed
// Step 2: Prewarm caches
// Add timeout to prevent hanging
await Promise.race([
getDB(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Database update timed out after 10 seconds')), 10000)
)
]).catch(async (error) => {
// If timeout, try to continue anyway (database might still work)
console.warn('Database update timeout, continuing:', error);
try {
// Try to get DB one more time without timeout
await getDB();
} catch (e) {
console.warn('Failed to get database after timeout:', e);
// Continue anyway - the app might still work
}
});
// Step 2: Clean up service workers and caches
progress = {
step: 'Cleaning up service workers...',
progress: 10,
total: 8,
current: 2
};
await cleanupServiceWorkers();
// Step 3: Prewarm caches
await prewarmCaches((prog) => {
progress = {
...prog,
progress: 10 + Math.round((prog.progress * 80) / 100) // Map 0-100 to 10-90
progress: 15 + Math.round((prog.progress * 75) / 100) // Map 0-100 to 15-90
};
});
// Step 3: Mark version as updated
// Step 4: Mark version as updated
progress = {
step: 'Finalizing update...',
progress: 95,
total: 7,
current: 7
total: 8,
current: 8
};
await markVersionUpdated();
// Step 4: Complete
// Step 5: Complete
progress = {
step: 'Update complete!',
progress: 100,
total: 7,
current: 7
total: 8,
current: 8
};
// Small delay to show completion
@ -103,6 +129,61 @@ @@ -103,6 +129,61 @@
}
}
/**
* Clean up service workers and caches to prevent corrupted content errors
*/
async function cleanupServiceWorkers(): Promise<void> {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return;
}
try {
// Get all service worker registrations
const registrations = await navigator.serviceWorker.getRegistrations();
// Unregister all service workers
await Promise.all(
registrations.map(async (registration) => {
try {
// Clear all caches for this registration
if (registration.active) {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName))
);
}
// Unregister the service worker
const unregistered = await registration.unregister();
if (unregistered) {
console.log('Service worker unregistered successfully');
}
} catch (error) {
console.warn('Failed to unregister service worker:', error);
// Continue with other registrations even if one fails
}
})
);
// Also try to clear all caches (in case some weren't associated with registrations)
try {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName))
);
if (cacheNames.length > 0) {
console.log(`Cleared ${cacheNames.length} cache(s)`);
}
} catch (error) {
console.warn('Failed to clear some caches:', error);
// Non-critical, continue
}
} catch (error) {
console.warn('Service worker cleanup encountered errors (non-critical):', error);
// Non-critical - continue with update even if cleanup fails
}
}
async function handlePWAUpdate() {
try {
// Check for service worker update
@ -181,7 +262,10 @@ @@ -181,7 +262,10 @@
<span>Update Now</span>
</button>
<button
onclick={onComplete}
onclick={() => {
onComplete();
goto('/about');
}}
class="update-button-secondary"
aria-label="Update later"
>

16
src/lib/components/preferences/UserPreferences.svelte

@ -1,22 +1,12 @@ @@ -1,22 +1,12 @@
<script lang="ts">
// Simple button component that links to settings page
import Icon from '../ui/Icon.svelte';
</script>
<a
href="/settings"
class="px-3 py-1 rounded border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors inline-flex items-center"
class="px-3 py-1 rounded border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors inline-flex items-center gap-2"
aria-label="Open settings"
title="Settings"
>
<span class="emoji emoji-grayscale"></span>
<Icon name="settings" size={16} />
</a>
<style>
.emoji-grayscale {
filter: grayscale(100%);
}
a:hover .emoji-grayscale {
filter: grayscale(80%);
}
</style>

30
src/lib/components/profile/ProfileMenu.svelte

@ -398,7 +398,7 @@ @@ -398,7 +398,7 @@
role="menu"
>
<button class="menu-item" onclick={copyUserId} role="menuitem">
<span class="menu-item-icon">📋</span>
<span class="menu-item-icon"><Icon name="copy" size={16} /></span>
<span class="menu-item-text">Copy user ID (npub)</span>
{#if copied === 'userId'}
<span class="menu-item-check"></span>
@ -406,7 +406,7 @@ @@ -406,7 +406,7 @@
</button>
<button class="menu-item" onclick={copyEventId} role="menuitem" disabled={!profileEvent}>
<span class="menu-item-icon">📋</span>
<span class="menu-item-icon"><Icon name="copy" size={16} /></span>
<span class="menu-item-text">Copy event ID (nevent)</span>
{#if copied === 'eventId'}
<span class="menu-item-check"></span>
@ -414,31 +414,31 @@ @@ -414,31 +414,31 @@
</button>
<button class="menu-item" onclick={viewJson} role="menuitem" disabled={!profileEvent}>
<span class="menu-item-icon">📄</span>
<span class="menu-item-icon"><Icon name="file-text" size={16} /></span>
<span class="menu-item-text">View Json</span>
</button>
{#if isLoggedIn && profileEvent}
<button class="menu-item" onclick={cloneEvent} role="menuitem">
<span class="menu-item-icon"></span>
<span class="menu-item-icon"><Icon name="edit" size={16} /></span>
<span class="menu-item-text">Edit/Clone this event</span>
</button>
{/if}
{#if isOwnProfile}
<button class="menu-item" onclick={openEditProfileEventsPanel} role="menuitem">
<span class="menu-item-icon"></span>
<span class="menu-item-icon"><Icon name="settings" size={16} /></span>
<span class="menu-item-text">Edit profile events</span>
</button>
<button class="menu-item menu-item-danger" onclick={openDeleteAllEventsModal} role="menuitem">
<span class="menu-item-icon">🗑</span>
<span class="menu-item-icon"><Icon name="trash" size={16} /></span>
<span class="menu-item-text">Delete all events from this npub</span>
</button>
{/if}
{#if isLoggedIn && !isOwnProfile}
<button class="menu-item" onclick={openReportModal} role="menuitem">
<span class="menu-item-icon">🚩</span>
<span class="menu-item-icon"><Icon name="x" size={16} /></span>
<span class="menu-item-text">Report this user</span>
</button>
{/if}
@ -446,7 +446,7 @@ @@ -446,7 +446,7 @@
<div class="menu-divider"></div>
<button class="menu-item" onclick={shareWithAitherboard} role="menuitem">
<span class="menu-item-icon">🔗</span>
<span class="menu-item-icon"><Icon name="link" size={16} /></span>
<span class="menu-item-text">Share with aitherboard</span>
{#if copied === 'share'}
<span class="menu-item-check"></span>
@ -462,7 +462,13 @@ @@ -462,7 +462,13 @@
role="menuitem"
disabled={muting}
>
<span class="menu-item-icon">{muted ? '🔇' : '🔊'}</span>
<span class="menu-item-icon">
{#if muted}
<Icon name="x" size={16} />
{:else}
<Icon name="message-square" size={16} />
{/if}
</span>
<span class="menu-item-text">{muted ? 'Unmute this user' : 'Mute this user'}</span>
{#if muted}
<span class="menu-item-check"></span>
@ -649,9 +655,15 @@ @@ -649,9 +655,15 @@
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
display: inline-flex;
align-items: center;
filter: grayscale(100%);
}
.menu-item-icon :global(.icon-wrapper) {
display: inline-block;
}
.menu-item-text {
flex: 1;
}

65
src/lib/modules/feed/FeedPost.svelte

@ -23,6 +23,7 @@ @@ -23,6 +23,7 @@
import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte';
import Icon from '../../components/ui/Icon.svelte';
import { page } from '$app/stores';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
@ -724,7 +725,8 @@ @@ -724,7 +725,8 @@
// Get media URLs that MediaAttachments will display (for fullView)
// This matches the logic in MediaAttachments.extractMedia()
function getMediaAttachmentUrls(): string[] {
// Use $derived to ensure it updates reactively
const mediaAttachmentUrls = $derived.by(() => {
const urls: string[] = [];
const seen = new Set<string>();
const forceRender = isMediaKind; // Same as what we pass to MediaAttachments
@ -767,7 +769,19 @@ @@ -767,7 +769,19 @@
}
}
// 3. file tags (NIP-94)
// 3. NIP-94 tags (kind 1063) - separate tag arrays
if (post.kind === 1063) {
const urlTag = post.tags.find(t => t[0] === 'url' && t[1]);
if (urlTag && urlTag[1]) {
const normalized = normalizeUrl(urlTag[1]);
if (!seen.has(normalized)) {
urls.push(urlTag[1]);
seen.add(normalized);
}
}
}
// 4. file tags (legacy)
for (const tag of post.tags) {
if (tag[0] === 'file' && tag[1]) {
const normalized = normalizeUrl(tag[1]);
@ -778,7 +792,7 @@ @@ -778,7 +792,7 @@
}
}
// 4. Extract from markdown content (images in markdown syntax)
// 5. Extract from markdown content (images in markdown syntax)
const imageRegex = /!\[.*?\]\((.*?)\)/g;
let match;
while ((match = imageRegex.exec(post.content)) !== null) {
@ -790,8 +804,35 @@ @@ -790,8 +804,35 @@
}
}
// 6. Extract from AsciiDoc content (images in AsciiDoc syntax)
const asciidocImageRegex = /image::?([^\s\[\]]+)(?:\[[^\]]*\])?/g;
asciidocImageRegex.lastIndex = 0;
let asciidocMatch;
while ((asciidocMatch = asciidocImageRegex.exec(post.content)) !== null) {
const url = asciidocMatch[1];
if (url.startsWith('http://') || url.startsWith('https://')) {
const normalized = normalizeUrl(url);
if (!seen.has(normalized)) {
urls.push(url);
seen.add(normalized);
}
}
}
// 7. Extract plain image URLs from content (matching MediaAttachments logic)
const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|flac|aac|m4a)(\?[^\s<>"{}|\\^`\[\]]*)?)/gi;
urlRegex.lastIndex = 0;
while ((match = urlRegex.exec(post.content)) !== null) {
const url = match[1];
const normalized = normalizeUrl(url);
if (!seen.has(normalized)) {
urls.push(url);
seen.add(normalized);
}
}
return urls;
}
});
// Extract media URLs from event tags (image, imeta, file) - for feed view only
// Excludes URLs that are already in the content
@ -895,7 +936,9 @@ @@ -895,7 +936,9 @@
>
{#snippet actions()}
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
<span class="bookmark-indicator bookmarked" title="Bookmarked">
<Icon name="bookmark" size={16} />
</span>
{/if}
<IconButton
icon="eye"
@ -953,7 +996,9 @@ @@ -953,7 +996,9 @@
{#if post.kind === KIND.POLL && fullView}
<PollCard pollEvent={post} />
{:else if post.content && post.content.trim()}
{@const mediaAttachmentUrls = getMediaAttachmentUrls()}
{#if typeof console !== 'undefined' && fullView}
{console.debug('FeedPost fullView: Media attachment URLs to exclude:', mediaAttachmentUrls, 'for post:', post.id)}
{/if}
<MarkdownRenderer content={post.content} event={post} excludeMediaUrls={mediaAttachmentUrls} />
{:else if !isMediaKind && post.kind !== KIND.POLL}
<!-- Show empty content message for non-media kinds without content -->
@ -1386,8 +1431,8 @@ @@ -1386,8 +1431,8 @@
}
.bookmark-indicator {
display: inline-block;
font-size: 1rem;
display: inline-flex;
align-items: center;
line-height: 1;
filter: grayscale(100%);
transition: filter 0.2s;
@ -1397,6 +1442,10 @@ @@ -1397,6 +1442,10 @@
filter: grayscale(0%);
}
.bookmark-indicator :global(.icon-wrapper) {
display: inline-block;
}
.highlighted-text {
background-color: rgba(255, 255, 0, 0.3);
padding: 0.125rem 0.25rem;

213
src/lib/services/auth/session-manager.ts

@ -3,6 +3,10 @@ @@ -3,6 +3,10 @@
*/
import type { NostrEvent } from '../../types/nostr.js';
import { getAnonymousKey } from '../cache/anonymous-key-store.js';
import { signEventWithAnonymous } from './anonymous-signer.js';
import { hasNsecKey, getNcryptsec } from '../cache/nsec-key-store.js';
import { signEventWithNsec } from './nsec-signer.js';
export type AuthMethod = 'nip07' | 'nsec' | 'anonymous';
@ -41,6 +45,43 @@ class SessionManager { @@ -41,6 +45,43 @@ class SessionManager {
private currentSession: UserSession | null = null;
public session = createStore<UserSession | null>(null);
/**
* Encrypt password for sessionStorage (simple obfuscation, not cryptographically secure)
* This is just to prevent casual inspection, not against determined attackers
*/
private encryptPasswordForStorage(password: string, pubkey: string): string {
// Simple XOR encryption with pubkey-derived key (not cryptographically secure, just obfuscation)
// For better security, we could use Web Crypto API, but this is sufficient for sessionStorage
const key = pubkey.slice(0, 16); // Use first 16 chars of pubkey as key
let encrypted = '';
for (let i = 0; i < password.length; i++) {
const keyChar = key.charCodeAt(i % key.length);
const passChar = password.charCodeAt(i);
encrypted += String.fromCharCode(passChar ^ keyChar);
}
// Base64 encode to make it safe for storage
return btoa(encrypted);
}
/**
* Decrypt password from sessionStorage
*/
private decryptPasswordFromStorage(encrypted: string, pubkey: string): string {
try {
const encryptedBytes = atob(encrypted);
const key = pubkey.slice(0, 16);
let decrypted = '';
for (let i = 0; i < encryptedBytes.length; i++) {
const keyChar = key.charCodeAt(i % key.length);
const encChar = encryptedBytes.charCodeAt(i);
decrypted += String.fromCharCode(encChar ^ keyChar);
}
return decrypted;
} catch {
throw new Error('Failed to decrypt stored password');
}
}
/**
* Set current session
*/
@ -48,7 +89,7 @@ class SessionManager { @@ -48,7 +89,7 @@ class SessionManager {
this.currentSession = session;
this.session.set(session);
// Store in localStorage for persistence
// NEVER store password in localStorage - it's only kept in memory
// Store password encrypted in sessionStorage for nsec/anonymous sessions (persists across reloads)
if (typeof window !== 'undefined') {
const sessionData: any = {
pubkey: session.pubkey,
@ -59,8 +100,20 @@ class SessionManager { @@ -59,8 +100,20 @@ class SessionManager {
if (metadata) {
sessionData.metadata = metadata;
}
// Password is never persisted - only kept in memory
localStorage.setItem('aitherboard_session', JSON.stringify(sessionData));
// Store password encrypted in sessionStorage for nsec/anonymous (persists across page reloads)
// This allows signing to work after page reload without re-entering password
// Password is cleared when browser/tab closes (sessionStorage behavior)
if (session.password && (session.method === 'nsec' || session.method === 'anonymous')) {
try {
const encryptedPassword = this.encryptPasswordForStorage(session.password, session.pubkey);
sessionStorage.setItem(`aitherboard_password_${session.pubkey}`, encryptedPassword);
} catch (error) {
console.warn('Failed to store password in sessionStorage:', error);
// Non-critical - password will just need to be re-entered
}
}
}
}
@ -98,7 +151,7 @@ class SessionManager { @@ -98,7 +151,7 @@ class SessionManager {
/**
* Clear session
* Also clears password from memory for security
* Also clears password from memory and sessionStorage for security
* Stores current route for redirect after logout
*/
clearSession(currentRoute?: string): void {
@ -107,6 +160,15 @@ class SessionManager { @@ -107,6 +160,15 @@ class SessionManager {
localStorage.setItem('aitherboard_logout_redirect', currentRoute);
}
// Clear password from sessionStorage if it exists
if (this.currentSession?.pubkey && typeof window !== 'undefined') {
try {
sessionStorage.removeItem(`aitherboard_password_${this.currentSession.pubkey}`);
} catch {
// Ignore errors
}
}
// Clear password from memory if it exists
if (this.currentSession?.password) {
// Overwrite password in memory (though JS doesn't guarantee this)
@ -158,23 +220,27 @@ class SessionManager { @@ -158,23 +220,27 @@ class SessionManager {
* Restore session from localStorage
* This will attempt to restore the session based on the auth method
* Only restores if there's no active session (to avoid overwriting sessions with passwords)
* @returns Object with restored status and whether password was available
*/
async restoreSession(): Promise<boolean> {
if (typeof window === 'undefined') return false;
async restoreSession(): Promise<{ restored: boolean; passwordAvailable: boolean }> {
if (typeof window === 'undefined') return { restored: false, passwordAvailable: false };
// Don't restore if there's already an active session (especially one with a password)
if (this.currentSession) {
return true; // Session already exists, consider it restored
return {
restored: true,
passwordAvailable: !!this.currentSession.password
}; // Session already exists, consider it restored
}
const stored = localStorage.getItem('aitherboard_session');
if (!stored) return false;
if (!stored) return { restored: false, passwordAvailable: false };
try {
const data = JSON.parse(stored);
const { pubkey, method, metadata } = data;
if (!pubkey || !method) return false;
if (!pubkey || !method) return { restored: false, passwordAvailable: false };
// Import auth handlers dynamically to avoid circular dependencies
switch (method) {
@ -192,23 +258,63 @@ class SessionManager { @@ -192,23 +258,63 @@ class SessionManager {
signer: signEventWithNIP07,
createdAt: data.createdAt || Date.now()
});
return true;
return { restored: true, passwordAvailable: true }; // NIP-07 doesn't need password
}
} catch {
// Extension error, can't restore
return false;
return { restored: false, passwordAvailable: false };
}
}
return false;
return { restored: false, passwordAvailable: false };
}
case 'anonymous': {
// For anonymous, we can restore if the encrypted key is stored
// The key is stored in IndexedDB, we just need to verify it exists
// We can't restore without password, but we can check if key exists
// For anonymous, try to restore session with password from sessionStorage
if (pubkey) {
try {
// Check if key exists by trying to list keys (we can't decrypt without password)
// For now, restore session but signer will require password
// Try to get password from sessionStorage
let password: string | undefined;
try {
const encryptedPassword = sessionStorage.getItem(`aitherboard_password_${pubkey}`);
if (encryptedPassword) {
password = this.decryptPasswordFromStorage(encryptedPassword, pubkey);
}
} catch {
// Password not found or decryption failed - user will need to re-enter
password = undefined;
}
if (password) {
// Try to verify the key exists and password is correct
try {
const nsec = await getAnonymousKey(pubkey, password);
if (nsec) {
// Restore session with password - signing will work
this.setSession({
pubkey,
method: 'anonymous',
password,
signer: async (event) => {
const session = this.getSession();
if (!session || !session.password) {
throw new Error('Session password not available');
}
const storedNsec = await getAnonymousKey(pubkey, session.password);
if (!storedNsec) {
throw new Error('Stored anonymous key not found');
}
return await signEventWithAnonymous(event, pubkey, session.password);
},
createdAt: data.createdAt || Date.now()
});
return { restored: true, passwordAvailable: true };
}
} catch {
// Key doesn't exist or password is wrong
password = undefined;
}
}
// Password not available - restore session but signer will require password
this.setSession({
pubkey,
method: 'anonymous',
@ -217,50 +323,81 @@ class SessionManager { @@ -217,50 +323,81 @@ class SessionManager {
},
createdAt: data.createdAt || Date.now()
});
// Note: This session won't work until user re-authenticates with password
return true;
return { restored: true, passwordAvailable: false };
} catch {
return false;
return { restored: false, passwordAvailable: false };
}
}
return false;
return { restored: false, passwordAvailable: false };
}
case 'nsec': {
// For nsec, we can restore the session but signing will require password
// The encrypted key is stored in IndexedDB, but we need password to decrypt
// For now, restore session but signer will fail until user re-enters password
// For nsec, try to restore session with password from sessionStorage
// The encrypted key is stored in IndexedDB, and password is in sessionStorage
if (pubkey) {
try {
const { hasNsecKey } = await import('../cache/nsec-key-store.js');
const keyExists = await hasNsecKey(pubkey);
if (keyExists) {
// Restore session but signer will require password
this.setSession({
pubkey,
method: 'nsec',
signer: async () => {
throw new Error('Nsec session requires password. Please log in again.');
},
createdAt: data.createdAt || Date.now()
});
return true;
// Try to get password from sessionStorage
let password: string | undefined;
try {
const encryptedPassword = sessionStorage.getItem(`aitherboard_password_${pubkey}`);
if (encryptedPassword) {
password = this.decryptPasswordFromStorage(encryptedPassword, pubkey);
}
} catch {
// Password not found or decryption failed - user will need to re-enter
password = undefined;
}
if (password) {
// Restore session with password - signing will work
this.setSession({
pubkey,
method: 'nsec',
password,
signer: async (event) => {
const session = this.getSession();
if (!session || !session.password) {
throw new Error('Session password not available');
}
const ncryptsec = await getNcryptsec(pubkey);
if (!ncryptsec) {
throw new Error('Stored nsec key not found');
}
return signEventWithNsec(event, ncryptsec, session.password);
},
createdAt: data.createdAt || Date.now()
});
return { restored: true, passwordAvailable: true };
} else {
// Password not available - restore session but signer will require password
this.setSession({
pubkey,
method: 'nsec',
signer: async () => {
throw new Error('Nsec session requires password. Please log in again.');
},
createdAt: data.createdAt || Date.now()
});
return { restored: true, passwordAvailable: false };
}
}
} catch {
return false;
return { restored: false, passwordAvailable: false };
}
}
// Clear the stored session if key doesn't exist
localStorage.removeItem('aitherboard_session');
return false;
return { restored: false, passwordAvailable: false };
}
default:
return false;
return { restored: false, passwordAvailable: false };
}
} catch (error) {
console.error('Error restoring session:', error);
// Clear corrupted session data
localStorage.removeItem('aitherboard_session');
return false;
return { restored: false, passwordAvailable: false };
}
}
}

365
src/lib/services/cache/cache-prewarmer.ts vendored

@ -26,13 +26,22 @@ export async function prewarmCaches( @@ -26,13 +26,22 @@ export async function prewarmCaches(
onProgress?: ProgressCallback
): Promise<void> {
const steps = [
{ name: 'Initializing database...', weight: 5 },
{ name: 'Loading user profile...', weight: 10 },
{ name: 'Loading user lists...', weight: 15 },
{ name: 'Loading recent events...', weight: 20 },
{ name: 'Loading profiles...', weight: 15 },
{ name: 'Loading RSS feeds...', weight: 10 },
{ name: 'Finalizing...', weight: 5 }
{ name: 'Initializing database...', weight: 2 },
{ name: 'Archiving old events...', weight: 8 },
{ name: 'Loading user profile...', weight: 5 },
{ name: 'Loading user lists...', weight: 8 },
{ name: 'Loading recent feed events...', weight: 15 },
{ name: 'Loading more feed events...', weight: 15 },
{ name: 'Loading discussions and threads...', weight: 10 },
{ name: 'Loading comments...', weight: 8 },
{ name: 'Loading reactions...', weight: 8 },
{ name: 'Loading profiles from events...', weight: 12 },
{ name: 'Loading more profiles...', weight: 10 },
{ name: 'Loading GIF events...', weight: 8 },
{ name: 'Loading emoji events...', weight: 6 },
{ name: 'Loading RSS feeds...', weight: 8 },
{ name: 'Loading user bookmarks...', weight: 5 },
{ name: 'Finalizing cache...', weight: 5 }
];
let totalProgress = 0;
@ -68,15 +77,34 @@ export async function prewarmCaches( @@ -68,15 +77,34 @@ export async function prewarmCaches(
await getDB();
updateProgress(0, 100);
// Step 2: Load user profile if logged in
// Step 2: Archive old events (free up space before prewarming)
updateProgress(1, 0);
try {
const { archiveOldEvents } = await import('./event-archive.js');
// Archive events older than 30 days
const ARCHIVE_THRESHOLD = 30 * 24 * 60 * 60 * 1000; // 30 days
// Archive in background with progress updates
const archived = await archiveOldEvents(ARCHIVE_THRESHOLD);
if (archived > 0) {
// Archive completed - events have been compressed and moved
}
} catch (error) {
// Archive failed (non-critical, continue)
console.warn('Archive step failed (non-critical):', error);
}
updateProgress(1, 100);
// Step 3: Load user profile if logged in
updateProgress(2, 0);
try {
if (sessionManager.isLoggedIn()) {
const pubkey = sessionManager.getSession()?.pubkey;
if (pubkey) {
const profileRelays = relayManager.getProfileReadRelays();
// Use a shorter timeout for prewarming to avoid hanging
const prewarmTimeout = Math.min(config.standardTimeout, 5000); // Max 5 seconds
// Increased timeout for thorough prewarming
const prewarmTimeout = config.standardTimeout; // Use full timeout
await Promise.race([
nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }],
@ -84,7 +112,7 @@ export async function prewarmCaches( @@ -84,7 +112,7 @@ export async function prewarmCaches(
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Profile fetch timeout')), prewarmTimeout + 1000)
setTimeout(() => reject(new Error('Profile fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
@ -108,7 +136,7 @@ export async function prewarmCaches( @@ -108,7 +136,7 @@ export async function prewarmCaches(
...relayManager.getFeedReadRelays()
];
const uniqueRelays = [...new Set(relays)];
const prewarmTimeout = Math.min(config.standardTimeout, 5000); // Max 5 seconds
const prewarmTimeout = config.standardTimeout; // Use full timeout
await Promise.race([
Promise.all([
@ -121,10 +149,30 @@ export async function prewarmCaches( @@ -121,10 +149,30 @@ export async function prewarmCaches(
[{ kinds: [KIND.FOLLOW_SET], authors: [pubkey] }],
uniqueRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
nostrClient.fetchEvents(
[{ kinds: [KIND.MUTE_LIST], authors: [pubkey], limit: 1 }],
uniqueRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
nostrClient.fetchEvents(
[{ kinds: [KIND.PIN_LIST], authors: [pubkey], limit: 1 }],
uniqueRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
nostrClient.fetchEvents(
[{ kinds: [KIND.RELAY_LIST], authors: [pubkey], limit: 1 }],
uniqueRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
nostrClient.fetchEvents(
[{ kinds: [KIND.INTEREST_LIST], authors: [pubkey], limit: 1 }],
uniqueRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
)
]),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Lists fetch timeout')), prewarmTimeout + 1000)
setTimeout(() => reject(new Error('Lists fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
@ -134,22 +182,23 @@ export async function prewarmCaches( @@ -134,22 +182,23 @@ export async function prewarmCaches(
} catch (error) {
// Ignore errors - prewarming is non-critical
}
updateProgress(2, 100);
updateProgress(3, 100);
// Step 4: Load recent feed events
updateProgress(3, 0);
// Step 5: Load recent feed events (all kinds, first batch)
updateProgress(4, 0);
try {
const feedRelays = relayManager.getFeedReadRelays();
const prewarmTimeout = Math.min(config.standardTimeout, 5000); // Max 5 seconds
const prewarmTimeout = config.standardTimeout; // Use full timeout
// Load all feed kinds with more events
await Promise.race([
nostrClient.fetchEvents(
[{ kinds: feedKinds.slice(0, 10), limit: 50 }], // Load first 10 feed kinds, limit 50 events
[{ kinds: feedKinds, limit: 100 }], // Load all feed kinds, limit 100 events
feedRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Feed events fetch timeout')), prewarmTimeout + 1000)
setTimeout(() => reject(new Error('Feed events fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
@ -157,42 +206,174 @@ export async function prewarmCaches( @@ -157,42 +206,174 @@ export async function prewarmCaches(
} catch (error) {
// Ignore errors - prewarming is non-critical
}
updateProgress(3, 100);
updateProgress(4, 100);
// Step 5: Load profiles for recent events (if logged in)
updateProgress(4, 0);
// Step 6: Load more feed events (second batch for better coverage)
updateProgress(5, 0);
try {
if (sessionManager.isLoggedIn()) {
// Get some recent events to extract pubkeys
const { getRecentCachedEvents } = await import('./event-cache.js');
const recentEvents = await getRecentCachedEvents(feedKinds, 24 * 60 * 60 * 1000, 20);
const pubkeys = [...new Set(recentEvents.map(e => e.pubkey))].slice(0, 20);
const feedRelays = relayManager.getFeedReadRelays();
const prewarmTimeout = config.standardTimeout;
if (pubkeys.length > 0) {
const profileRelays = relayManager.getProfileReadRelays();
const prewarmTimeout = Math.min(config.standardTimeout, 5000); // Max 5 seconds
// Load another batch with different time range
await Promise.race([
nostrClient.fetchEvents(
[{ kinds: feedKinds, limit: 100 }], // Another 100 events
feedRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('More feed events fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
});
} catch (error) {
// Ignore errors - prewarming is non-critical
}
updateProgress(5, 100);
// Step 7: Load discussions and threads
updateProgress(6, 0);
try {
const feedRelays = relayManager.getFeedReadRelays();
const prewarmTimeout = config.standardTimeout;
// Load discussion/thread events
await Promise.race([
nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE, KIND.LONG_FORM_NOTE], limit: 50 }], // Load discussions
feedRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Discussions fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
});
} catch (error) {
// Ignore errors - prewarming is non-critical
}
updateProgress(6, 100);
// Step 8: Load comments on discussions and threads
updateProgress(7, 0);
try {
const feedRelays = relayManager.getFeedReadRelays();
const prewarmTimeout = config.standardTimeout;
// Load comments (kind 1111) - these are replies to discussions/threads
await Promise.race([
nostrClient.fetchEvents(
[{ kinds: [KIND.COMMENT], limit: 100 }],
feedRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Comments fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
});
} catch (error) {
// Ignore errors - prewarming is non-critical
}
updateProgress(7, 100);
// Step 9: Load reactions on events
updateProgress(8, 0);
try {
const feedRelays = relayManager.getFeedReadRelays();
const prewarmTimeout = config.standardTimeout;
// Load reactions (kind 7) - these are reactions to posts/events
await Promise.race([
nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], limit: 100 }],
feedRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Reactions fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
});
} catch (error) {
// Ignore errors - prewarming is non-critical
}
updateProgress(8, 100);
// Step 10: Load profiles for recent events
updateProgress(9, 0);
try {
// Get recent events to extract pubkeys
const { getRecentCachedEvents } = await import('./event-cache.js');
const recentEvents = await getRecentCachedEvents(feedKinds, 24 * 60 * 60 * 1000, 100); // Get more events
const pubkeys = [...new Set(recentEvents.map(e => e.pubkey))].slice(0, 50); // Get up to 50 unique pubkeys
if (pubkeys.length > 0) {
const profileRelays = relayManager.getProfileReadRelays();
const prewarmTimeout = config.standardTimeout;
// Load profiles in batches to avoid overwhelming relays
const batchSize = 20;
for (let i = 0; i < pubkeys.length; i += batchSize) {
const batch = pubkeys.slice(i, i + batchSize);
await Promise.race([
nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: pubkeys, limit: 1 }],
[{ kinds: [KIND.METADATA], authors: batch, limit: 1 }],
profileRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Profiles fetch timeout')), prewarmTimeout + 1000)
setTimeout(() => reject(new Error('Profiles fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
});
// Update progress within this step
const stepProgress = Math.min(100, ((i + batchSize) / pubkeys.length) * 100);
updateProgress(9, stepProgress);
}
}
} catch (error) {
// Ignore errors - prewarming is non-critical
}
updateProgress(4, 100);
updateProgress(9, 100);
// Step 6: Load RSS feeds (if logged in)
updateProgress(5, 0);
// Step 11: Load more profiles (from discussions and threads)
updateProgress(10, 0);
try {
const { getRecentCachedEvents } = await import('./event-cache.js');
const discussionEvents = await getRecentCachedEvents([KIND.SHORT_TEXT_NOTE, KIND.LONG_FORM_NOTE], 7 * 24 * 60 * 60 * 1000, 50);
const pubkeys = [...new Set(discussionEvents.map(e => e.pubkey))].slice(0, 30);
if (pubkeys.length > 0) {
const profileRelays = relayManager.getProfileReadRelays();
const prewarmTimeout = config.standardTimeout;
await Promise.race([
nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: pubkeys, limit: 1 }],
profileRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('More profiles fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
});
}
} catch (error) {
// Ignore errors - prewarming is non-critical
}
updateProgress(8, 100);
// Step 12: Load RSS feeds (if logged in)
updateProgress(11, 0);
try {
if (sessionManager.isLoggedIn()) {
const pubkey = sessionManager.getSession()?.pubkey;
@ -203,16 +384,16 @@ export async function prewarmCaches( @@ -203,16 +384,16 @@ export async function prewarmCaches(
...relayManager.getFeedReadRelays()
];
const uniqueRelays = [...new Set(relays)];
const prewarmTimeout = Math.min(config.standardTimeout, 5000); // Max 5 seconds
const prewarmTimeout = config.standardTimeout;
await Promise.race([
nostrClient.fetchEvents(
[{ kinds: [KIND.RSS_FEED], authors: [pubkey], limit: 10 }],
[{ kinds: [KIND.RSS_FEED], authors: [pubkey], limit: 50 }], // Load more RSS feeds
uniqueRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('RSS feeds fetch timeout')), prewarmTimeout + 1000)
setTimeout(() => reject(new Error('RSS feeds fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
@ -222,13 +403,111 @@ export async function prewarmCaches( @@ -222,13 +403,111 @@ export async function prewarmCaches(
} catch (error) {
// Ignore errors - prewarming is non-critical
}
updateProgress(5, 100);
updateProgress(9, 100);
// Step 7: Finalize
updateProgress(6, 0);
// Step 11: Load GIF events
updateProgress(10, 0);
try {
const { fetchGifs } = await import('../nostr/gif-service.js');
const prewarmTimeout = config.standardTimeout;
// Fetch GIFs to prewarm the cache (limit 100 for thorough prewarming)
await Promise.race([
fetchGifs(undefined, 100, true), // Force refresh to get latest GIFs
new Promise((_, reject) =>
setTimeout(() => reject(new Error('GIF fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
});
} catch (error) {
// Ignore errors - prewarming is non-critical
}
updateProgress(11, 100);
// Step 13: Load emoji events (emoji sets and packs)
updateProgress(12, 0);
try {
const feedRelays = relayManager.getFeedReadRelays();
const prewarmTimeout = config.standardTimeout;
// Load emoji sets (kind 10030) and emoji packs (kind 30030)
// If logged in, load user's emojis; otherwise load popular emojis
if (sessionManager.isLoggedIn()) {
const pubkey = sessionManager.getSession()?.pubkey;
if (pubkey) {
// Load user's emoji sets and packs
await Promise.race([
nostrClient.fetchEvents(
[{ kinds: [KIND.EMOJI_SET, KIND.EMOJI_PACK], authors: [pubkey], limit: 50 }],
feedRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Emoji events fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
});
}
}
// Also load some popular emoji sets/packs (from well-known users or recent)
await Promise.race([
nostrClient.fetchEvents(
[{ kinds: [KIND.EMOJI_SET, KIND.EMOJI_PACK], limit: 50 }],
feedRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Popular emoji events fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
});
} catch (error) {
// Ignore errors - prewarming is non-critical
}
updateProgress(13, 100);
// Step 15: Load user bookmarks (if logged in)
updateProgress(14, 0);
try {
if (sessionManager.isLoggedIn()) {
const pubkey = sessionManager.getSession()?.pubkey;
if (pubkey) {
const relays = [
...config.defaultRelays,
...config.profileRelays,
...relayManager.getFeedReadRelays()
];
const uniqueRelays = [...new Set(relays)];
const prewarmTimeout = config.standardTimeout;
await Promise.race([
nostrClient.fetchEvents(
[{ kinds: [KIND.BOOKMARKS], authors: [pubkey], limit: 20 }],
uniqueRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: prewarmTimeout }
),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Bookmarks fetch timeout')), prewarmTimeout + 2000)
)
]).catch(() => {
// Ignore errors - prewarming is non-critical
});
}
}
} catch (error) {
// Ignore errors - prewarming is non-critical
}
updateProgress(14, 100);
// Step 16: Finalize
updateProgress(15, 0);
// Small delay to ensure all operations complete
await new Promise(resolve => setTimeout(resolve, 100));
updateProgress(6, 100);
await new Promise(resolve => setTimeout(resolve, 200));
updateProgress(15, 100);
} catch (error) {
// Prewarming errors are non-critical - app can still work

106
src/lib/services/cache/indexeddb-store.ts vendored

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import { openDB, type IDBPDatabase } from 'idb';
const DB_NAME = 'aitherboard';
const DB_VERSION = 11; // Version 7: Added RSS cache store. Version 8: Added markdown cache store. Version 9: Added event archive store. Version 10: Added GIF cache store. Version 11: Added event version history store
const DB_VERSION = 12; // Version 7: Added RSS cache store. Version 8: Added markdown cache store. Version 9: Added event archive store. Version 10: Added GIF cache store. Version 11: Added event version history store. Version 12: Migrate preferences from localStorage to IndexedDB
export interface DatabaseSchema {
events: {
@ -69,8 +69,14 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -69,8 +69,14 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
if (dbInstance) return dbInstance;
try {
let needsMigration = false;
let oldVersion = 0;
dbInstance = await openDB<DatabaseSchema>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
upgrade(db, oldVer, newVer) {
oldVersion = oldVer;
needsMigration = oldVer < 12;
// Migration: Remove opengraph store (was added in version 4, removed in version 6)
if (db.objectStoreNames.contains('opengraph')) {
db.deleteObjectStore('opengraph');
@ -135,6 +141,15 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -135,6 +141,15 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
gifStore.createIndex('cached_at', 'cached_at', { unique: false });
gifStore.createIndex('createdAt', 'createdAt', { unique: false });
}
// Event versions store (version history)
if (!db.objectStoreNames.contains('eventVersions')) {
const eventVersionsStore = db.createObjectStore('eventVersions', { keyPath: 'key' });
eventVersionsStore.createIndex('eventKey', 'eventKey', { unique: false });
eventVersionsStore.createIndex('pubkey', 'pubkey', { unique: false });
eventVersionsStore.createIndex('kind', 'kind', { unique: false });
eventVersionsStore.createIndex('savedAt', 'savedAt', { unique: false });
}
},
blocked() {
// IndexedDB blocked (another tab may have it open)
@ -148,32 +163,72 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -148,32 +163,72 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
}
});
// Verify all stores exist after opening - if not, database is corrupted
if (!dbInstance.objectStoreNames.contains('events') ||
!dbInstance.objectStoreNames.contains('profiles') ||
!dbInstance.objectStoreNames.contains('keys') ||
!dbInstance.objectStoreNames.contains('search') ||
!dbInstance.objectStoreNames.contains('preferences') ||
!dbInstance.objectStoreNames.contains('drafts') ||
!dbInstance.objectStoreNames.contains('rss') ||
!dbInstance.objectStoreNames.contains('markdown') ||
!dbInstance.objectStoreNames.contains('eventArchive') ||
!dbInstance.objectStoreNames.contains('gifs') ||
!dbInstance.objectStoreNames.contains('eventVersions')) {
// Migration: Migrate preferences from localStorage to IndexedDB (version 12)
// Run after database is opened to avoid transaction timeout issues
if (needsMigration && typeof window !== 'undefined') {
try {
const keysToMigrate = ['showOnlyOPs'];
for (const key of keysToMigrate) {
try {
const localStorageKey = `pref_${key}`;
const stored = localStorage.getItem(localStorageKey);
if (stored !== null) {
// Check if already exists in IndexedDB
const existing = await dbInstance.get('preferences', key);
// Only migrate if not already in IndexedDB (IndexedDB takes precedence)
if (!existing || existing.value === undefined) {
const value = JSON.parse(stored);
await dbInstance.put('preferences', { key, value });
}
// Delete from localStorage after migration
localStorage.removeItem(localStorageKey);
}
} catch (e) {
console.warn(`Failed to migrate preference ${key} from localStorage:`, e);
}
}
} catch (error) {
console.warn('Failed to migrate preferences from localStorage:', error);
}
}
// Verify critical stores exist after opening - if not, database is corrupted
// Only check a few critical stores to avoid delays
if (!dbInstance) {
throw new Error('Failed to open database');
}
const db = dbInstance; // TypeScript narrowing helper
const criticalStores = ['events', 'profiles', 'preferences', 'eventVersions'];
const missingStores = criticalStores.filter(store => !db.objectStoreNames.contains(store));
if (missingStores.length > 0) {
// Database is corrupted - close and delete it, then recreate
// Database schema outdated, recreating
dbInstance.close();
db.close();
dbInstance = null;
// Delete the corrupted database
// Delete the corrupted database (with timeout)
const deleteReq = indexedDB.deleteDatabase(DB_NAME);
await new Promise<void>((resolve, reject) => {
deleteReq.onsuccess = () => resolve();
deleteReq.onerror = () => reject(deleteReq.error);
deleteReq.onblocked = () => {
// Database deletion blocked (another tab may have it open)
resolve(); // Continue anyway
};
await Promise.race([
new Promise<void>((resolve, reject) => {
deleteReq.onsuccess = () => resolve();
deleteReq.onerror = () => reject(deleteReq.error);
deleteReq.onblocked = () => {
// Database deletion blocked (another tab may have it open)
// Wait a bit and then continue anyway
setTimeout(() => resolve(), 500);
};
}),
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error('Database deletion timed out')), 5000)
)
]).catch((error) => {
// If deletion times out or fails, log and continue
console.warn('Database deletion issue (continuing anyway):', error);
});
// Wait a bit for deletion to complete
@ -202,6 +257,11 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -202,6 +257,11 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
const gifStore = db.createObjectStore('gifs', { keyPath: 'url' });
gifStore.createIndex('cached_at', 'cached_at', { unique: false });
gifStore.createIndex('createdAt', 'createdAt', { unique: false });
const eventVersionsStore = db.createObjectStore('eventVersions', { keyPath: 'key' });
eventVersionsStore.createIndex('eventKey', 'eventKey', { unique: false });
eventVersionsStore.createIndex('pubkey', 'pubkey', { unique: false });
eventVersionsStore.createIndex('kind', 'kind', { unique: false });
eventVersionsStore.createIndex('savedAt', 'savedAt', { unique: false });
},
blocked() {
// IndexedDB blocked (another tab may have it open)

55
src/lib/services/preferences.ts

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
/**
* User preferences management
* Stores preferences in IndexedDB for persistence across sessions
*/
import { getDB } from './cache/indexeddb-store.js';
const PREFERENCE_KEYS = {
SHOW_ONLY_OPS: 'showOnlyOPs'
} as const;
/**
* Save a preference to IndexedDB
*/
export async function savePreference(key: string, value: unknown): Promise<void> {
try {
const db = await getDB();
await db.put('preferences', { key, value });
} catch (error) {
console.warn('Failed to save preference to IndexedDB:', error);
throw error;
}
}
/**
* Load a preference from IndexedDB
*/
export async function loadPreference<T>(key: string, defaultValue: T): Promise<T> {
try {
const db = await getDB();
const stored = await db.get('preferences', key);
if (stored && stored.value !== undefined) {
return stored.value as T;
}
} catch (error) {
console.warn('Failed to load preference from IndexedDB:', error);
}
return defaultValue;
}
/**
* Save the "Show only OPs" preference
*/
export async function saveShowOnlyOPs(value: boolean): Promise<void> {
return savePreference(PREFERENCE_KEYS.SHOW_ONLY_OPS, value);
}
/**
* Load the "Show only OPs" preference
*/
export async function loadShowOnlyOPs(): Promise<boolean> {
return loadPreference(PREFERENCE_KEYS.SHOW_ONLY_OPS, false);
}

100
src/routes/+layout.svelte

@ -14,6 +14,8 @@ @@ -14,6 +14,8 @@
let { children }: Props = $props();
let showUpdateModal = $state(false);
let showSessionWarning = $state(false);
let sessionWarningPubkey = $state<string | null>(null);
// Initialize theme and preferences from localStorage immediately (before any components render)
if (browser) {
@ -58,7 +60,15 @@ @@ -58,7 +60,15 @@
(async () => {
try {
if (!sessionManager.isLoggedIn()) {
await sessionManager.restoreSession();
const result = await sessionManager.restoreSession();
// If session was restored but password is not available, show warning
if (result.restored && !result.passwordAvailable) {
const session = sessionManager.getSession();
if (session && (session.method === 'nsec' || session.method === 'anonymous')) {
sessionWarningPubkey = session.pubkey;
showSessionWarning = true;
}
}
}
} catch (error) {
console.error('Failed to restore session:', error);
@ -121,10 +131,50 @@ @@ -121,10 +131,50 @@
// Async operations
(async () => {
try {
// In development, clear corrupted service workers on load
if (browser && import.meta.env.DEV) {
try {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
// Only clear if there are registrations (to avoid unnecessary work)
if (registrations.length > 0) {
// Check for corrupted content errors by trying to access caches
try {
await caches.keys();
} catch (error) {
// If cache access fails, clear everything
console.warn('[Dev] Detected corrupted cache, clearing service workers...');
await Promise.all(
registrations.map(reg => reg.unregister().catch(() => {}))
);
const cacheNames = await caches.keys().catch(() => []);
await Promise.all(
cacheNames.map(name => caches.delete(name).catch(() => {}))
);
// Reload to clear corrupted state
window.location.reload();
return;
}
}
}
} catch (error) {
// Non-critical, just log
console.debug('[Dev] Service worker check:', error);
}
}
// Only restore if there's no active session
// This prevents overwriting sessions that were just created during login
if (!sessionManager.isLoggedIn()) {
await sessionManager.restoreSession();
const result = await sessionManager.restoreSession();
// If session was restored but password is not available, show warning
if (result.restored && !result.passwordAvailable) {
const session = sessionManager.getSession();
if (session && (session.method === 'nsec' || session.method === 'anonymous')) {
sessionWarningPubkey = session.pubkey;
showSessionWarning = true;
}
}
}
if (browser) {
@ -179,6 +229,20 @@ @@ -179,6 +229,20 @@
showUpdateModal = false;
}
async function handleSessionWarningLogin() {
showSessionWarning = false;
const { goto } = await import('$app/navigation');
goto('/login');
}
async function handleSessionWarningLogout() {
showSessionWarning = false;
// Clear the session - this will update the session store and trigger UI updates
sessionManager.clearSession();
// Small delay to ensure UI updates before any potential navigation
await new Promise(resolve => setTimeout(resolve, 50));
}
// Track current route when user is logged in (for redirect after logout)
$effect(() => {
if (sessionManager.isLoggedIn() && browser) {
@ -196,3 +260,35 @@ @@ -196,3 +260,35 @@
{#if showUpdateModal}
<UpdateModal onComplete={handleUpdateComplete} />
{/if}
{#if showSessionWarning}
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="session-warning-title"
>
<div class="bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border rounded-lg p-6 max-w-md w-full shadow-lg">
<h2 id="session-warning-title" class="text-xl font-semibold mb-4 text-fog-text dark:text-fog-dark-text">
Session Password Required
</h2>
<p class="text-fog-text-light dark:text-fog-dark-text-light mb-6">
Your session was restored, but your password is not available. You'll need to log in again to sign events.
</p>
<div class="flex gap-3 justify-end">
<button
onclick={handleSessionWarningLogout}
class="px-4 py-2 bg-fog-highlight dark:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text rounded hover:opacity-80 transition-opacity"
>
Stay Logged Out
</button>
<button
onclick={handleSessionWarningLogin}
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 transition-opacity"
>
Log In Again
</button>
</div>
</div>
</div>
{/if}

1
src/routes/about/+page.svelte

@ -25,6 +25,7 @@ @@ -25,6 +25,7 @@
'Media attachments rendering in all feeds and views',
'NIP-92/NIP-94 image tags support',
'Blossom support for media attachments',
'OP-only view added to feed',
],
'0.3.0': [
'Version history modal added to event menu',

8
src/routes/discussions/+page.svelte

@ -11,6 +11,7 @@ @@ -11,6 +11,7 @@
import { page } from '$app/stores';
import Pagination from '../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js';
import Icon from '../../lib/components/ui/Icon.svelte';
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null });
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] });
@ -74,7 +75,8 @@ @@ -74,7 +75,8 @@
{#if discussionListComponent && !searchResults.events.length && !searchResults.profiles.length}
<div class="discussions-controls-row">
<a href="/write?kind=11" class="see-more-events-btn-header" title="Write a new thread">
Write
<Icon name="edit" size={16} />
<span>Write</span>
</a>
<select
bind:value={discussionListComponent.sortBy}
@ -217,7 +219,9 @@ @@ -217,7 +219,9 @@
transition: all 0.2s;
white-space: nowrap;
text-decoration: none;
display: inline-block;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.see-more-events-btn-header:hover:not(:disabled) {

32
src/routes/feed/+page.svelte

@ -4,6 +4,8 @@ @@ -4,6 +4,8 @@
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { loadShowOnlyOPs, saveShowOnlyOPs } from '../../lib/services/preferences.js';
import Icon from '../../lib/components/ui/Icon.svelte';
let feedPageComponent: {
loadOlderEvents: () => Promise<void>;
@ -13,7 +15,30 @@ @@ -13,7 +15,30 @@
loadWaitingRoomEvents: () => void;
} | null = $state(null);
let showOnlyOPs = $state(false);
let showOnlyOPs = $state<boolean>(false);
let preferenceLoaded = $state(false);
// Load preference from IndexedDB immediately when component initializes
$effect(() => {
if (!preferenceLoaded && typeof window !== 'undefined') {
loadShowOnlyOPs().then(value => {
showOnlyOPs = value;
preferenceLoaded = true;
}).catch(err => {
console.warn('Failed to load showOnlyOPs preference:', err);
preferenceLoaded = true;
});
}
});
// Save preference when it changes (but only after initial load)
$effect(() => {
if (preferenceLoaded && typeof window !== 'undefined') {
saveShowOnlyOPs(showOnlyOPs).catch(err => {
console.warn('Failed to save showOnlyOPs preference:', err);
});
}
});
onMount(async () => {
await nostrClient.initialize();
@ -41,7 +66,8 @@ @@ -41,7 +66,8 @@
</div>
<div class="feed-header-buttons">
<a href="/write?kind=1" class="see-more-events-btn-header" title="Write a new post">
Write
<Icon name="edit" size={16} />
<span>Write</span>
</a>
{#if feedPageComponent && feedPageComponent.waitingRoomEvents.length > 0}
<button
@ -106,6 +132,8 @@ @@ -106,6 +132,8 @@
display: flex;
align-items: center;
margin-right: 1rem;
flex-shrink: 0;
min-width: fit-content;
}
.feed-filter-label {

30
src/routes/lists/+page.svelte

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
import Pagination from '../../lib/components/ui/Pagination.svelte';
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../lib/utils/pagination.js';
import { isReply } from '../../lib/utils/event-utils.js';
import { loadShowOnlyOPs, saveShowOnlyOPs } from '../../lib/services/preferences.js';
interface ListInfo {
kind: number;
@ -28,7 +29,8 @@ @@ -28,7 +29,8 @@
let loading = $state(true);
let loadingEvents = $state(false);
let hasLists = $derived(lists.length > 0);
let showOnlyOPs = $state(false);
let showOnlyOPs = $state<boolean>(false);
let preferenceLoaded = $state(false);
// Filter events based on showOnlyOPs
let filteredEvents = $derived(
@ -45,6 +47,19 @@ @@ -45,6 +47,19 @@
: filteredEvents
);
// Load preference from IndexedDB immediately when component initializes
$effect(() => {
if (!preferenceLoaded && typeof window !== 'undefined') {
loadShowOnlyOPs().then(value => {
showOnlyOPs = value;
preferenceLoaded = true;
}).catch(err => {
console.warn('Failed to load showOnlyOPs preference:', err);
preferenceLoaded = true;
});
}
});
// Subscribe to session changes to reactively update login status
let currentSession = $state(sessionManager.session.value);
const isLoggedIn = $derived(currentSession !== null);
@ -334,8 +349,8 @@ @@ -334,8 +349,8 @@
// Restore session if not already logged in (in case of page refresh)
if (!sessionManager.isLoggedIn()) {
const restored = await sessionManager.restoreSession();
if (!restored || !sessionManager.isLoggedIn()) {
const result = await sessionManager.restoreSession();
if (!result.restored || !sessionManager.isLoggedIn()) {
goto('/login');
return;
}
@ -343,6 +358,15 @@ @@ -343,6 +358,15 @@
await loadLists();
});
// Save preference when it changes (but only after initial load)
$effect(() => {
if (preferenceLoaded && typeof window !== 'undefined') {
saveShowOnlyOPs(showOnlyOPs).catch(err => {
console.warn('Failed to save showOnlyOPs preference:', err);
});
}
});
</script>
<Header />

2
src/routes/relay/+page.svelte

@ -268,7 +268,7 @@ @@ -268,7 +268,7 @@
// Ensure session is restored before loading favorite relays
if (!sessionManager.isLoggedIn()) {
try {
await sessionManager.restoreSession();
await sessionManager.restoreSession(); // Ignore return value - just try to restore
} catch (error) {
// Session restore failed
}

2
src/routes/write/+page.svelte

@ -41,7 +41,7 @@ @@ -41,7 +41,7 @@
// Ensure session is restored (fallback in case layout restoration didn't complete)
if (!sessionManager.isLoggedIn()) {
try {
await sessionManager.restoreSession();
await sessionManager.restoreSession(); // Ignore return value - just try to restore
} catch (error) {
console.error('Failed to restore session in write page:', error);
}

Loading…
Cancel
Save