Browse Source

fix green-text formatting

implement bookmark page
add writing on the wall
master
Silberengel 1 month ago
parent
commit
3a2acd8500
  1. 4
      public/healthz.json
  2. 72
      src/lib/components/content/MarkdownRenderer.svelte
  3. 6
      src/lib/components/content/MediaViewer.svelte
  4. 302
      src/lib/components/content/MentionsAutocomplete.svelte
  5. 82
      src/lib/components/profile/ProfileMenu.svelte
  6. 32
      src/lib/components/write/CreateEventForm.svelte
  7. 53
      src/lib/modules/comments/CommentForm.svelte
  8. 180
      src/lib/modules/profiles/ProfilePage.svelte
  9. 137
      src/lib/services/file-compression.ts
  10. 165
      src/lib/services/mentions.ts
  11. 22
      src/lib/services/nostr/file-upload.ts
  12. 184
      src/lib/services/profile-search.ts
  13. 127
      src/routes/bookmarks/+page.svelte

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.1.1", "version": "0.1.1",
"buildTime": "2026-02-05T18:24:55.668Z", "buildTime": "2026-02-05T22:42:01.031Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770315895668 "timestamp": 1770331321031
} }

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

@ -331,26 +331,42 @@
} }
// Convert greentext (>text with no space) to styled spans // Convert greentext (>text with no space) to styled spans
// Groups consecutive greentext lines into a single block, preserving line breaks
function convertGreentext(text: string): string { function convertGreentext(text: string): string {
// Split by lines and process each line // Split by lines and process
const lines = text.split('\n'); const lines = text.split('\n');
const processedLines = lines.map(line => { const processedLines: string[] = [];
// Check if line starts with > followed immediately by non-whitespace (greentext) let greentextBlock: string[] = [];
// Must match: >text (no space after >)
// Must NOT match: > text (space after >, normal blockquote) const greentextPattern = /^(>|>)([^\s>].*)$/;
// Also handle HTML-escaped > (>)
const greentextPattern = /^(&gt;|>)([^\s>].*)$/; for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const match = line.match(greentextPattern); const match = line.match(greentextPattern);
if (match) { if (match) {
// This is greentext - wrap in span with greentext class // This is greentext - add to current block
// Use > character (not &gt;) since we're inserting HTML
const greentextContent = escapeHtml(match[2]); const greentextContent = escapeHtml(match[2]);
return `<span class="greentext">&gt;${greentextContent}</span>`; greentextBlock.push(greentextContent);
} else {
// Not greentext - flush any accumulated greentext block
if (greentextBlock.length > 0) {
// Join with <br> to preserve line breaks, no extra spacing
const blockContent = greentextBlock.map(content => `&gt;${content}`).join('<br>');
processedLines.push(`<span class="greentext">${blockContent}</span>`);
greentextBlock = [];
}
// Add the non-greentext line as-is
processedLines.push(line);
} }
}
return line;
}); // Flush any remaining greentext block at the end
if (greentextBlock.length > 0) {
// Join with <br> to preserve line breaks, no extra spacing
const blockContent = greentextBlock.map(content => `&gt;${content}`).join('<br>');
processedLines.push(`<span class="greentext">${blockContent}</span>`);
}
return processedLines.join('\n'); return processedLines.join('\n');
} }
@ -507,16 +523,30 @@
}); });
// Post-process HTML to convert blockquotes that are actually greentext // Post-process HTML to convert blockquotes that are actually greentext
// Merges consecutive greentext blockquotes into a single block, preserving line breaks
function postProcessGreentext(html: string): string { function postProcessGreentext(html: string): string {
// Find blockquotes that match greentext pattern (>text with no space) // Pattern to match one or more consecutive greentext blockquotes
// These are blockquotes that markdown created from greentext lines // Matches: <blockquote><p>&gt;text</p></blockquote> (with optional whitespace between)
// Pattern: <blockquote><p>&gt;text</p></blockquote> where there's no space after &gt; // where there's no space after &gt; (greentext pattern)
const greentextBlockquotePattern = /<blockquote[^>]*>\s*<p[^>]*>&gt;([^\s<].*?)<\/p>\s*<\/blockquote>/g; // The (?:...) part matches zero or more additional consecutive blockquotes
const consecutiveGreentextPattern = /(<blockquote[^>]*>\s*<p[^>]*>&gt;([^\s<].*?)<\/p>\s*<\/blockquote>)(\s*<blockquote[^>]*>\s*<p[^>]*>&gt;([^\s<].*?)<\/p>\s*<\/blockquote>)*/g;
return html.replace(greentextBlockquotePattern, (match, content) => { return html.replace(consecutiveGreentextPattern, (match) => {
// Convert to greentext span // Extract all greentext contents from the match
const escapedContent = escapeHtml(content); const contentPattern = /<blockquote[^>]*>\s*<p[^>]*>&gt;([^\s<].*?)<\/p>\s*<\/blockquote>/g;
return `<span class="greentext">&gt;${escapedContent}</span>`; const contents: string[] = [];
let contentMatch;
while ((contentMatch = contentPattern.exec(match)) !== null) {
contents.push(contentMatch[1]);
}
if (contents.length === 0) {
return match; // Shouldn't happen, but safety check
}
// Join all contents with <br> to preserve line breaks, no extra spacing
const combinedContent = contents.map(c => escapeHtml(c)).map(content => `&gt;${content}`).join('<br>');
return `<span class="greentext">${combinedContent}</span>`;
}); });
} }

6
src/lib/components/content/MediaViewer.svelte

@ -45,9 +45,11 @@
{#if mediaType === 'image'} {#if mediaType === 'image'}
<img src={url} alt="Media" class="media-viewer-media" /> <img src={url} alt="Media" class="media-viewer-media" />
{:else if mediaType === 'video'} {:else if mediaType === 'video'}
<video src={url} controls class="media-viewer-media" autoplay={false} /> <video src={url} controls class="media-viewer-media" autoplay={false}>
<track kind="captions" />
</video>
{:else if mediaType === 'audio'} {:else if mediaType === 'audio'}
<audio src={url} controls class="media-viewer-audio" autoplay={false} /> <audio src={url} controls class="media-viewer-audio" autoplay={false}></audio>
{:else} {:else}
<div class="media-viewer-unknown"> <div class="media-viewer-unknown">
<p>Unsupported media type</p> <p>Unsupported media type</p>

302
src/lib/components/content/MentionsAutocomplete.svelte

@ -0,0 +1,302 @@
<script lang="ts">
import { searchProfiles, type ProfileSearchResult } from '../../services/profile-search.js';
import { nip19 } from 'nostr-tools';
interface Props {
textarea: HTMLTextAreaElement;
onMention?: (pubkey: string, handle: string) => void;
}
let { textarea, onMention }: Props = $props();
let suggestions = $state<ProfileSearchResult[]>([]);
let selectedIndex = $state(0);
let showSuggestions = $state(false);
let query = $state('');
let position = $state({ top: 0, left: 0 });
let mentionStart = $state<number | null>(null);
// Listen for @ character in textarea
$effect(() => {
if (!textarea) return;
const handleInput = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
const text = target.value;
const cursorPos = target.selectionStart || 0;
// Find @ mention starting from cursor backwards
// Allow word chars, @, dots, and hyphens to support NIP-05 format (user@domain.com)
let start = cursorPos - 1;
while (start >= 0 && /[\w@.-]/.test(text[start])) {
start--;
}
if (start >= 0 && text[start] === '@') {
// Found @ mention
mentionStart = start;
const mentionText = text.substring(start + 1, cursorPos);
// Check if we're still in a valid mention (word chars, @, dots, hyphens)
// Support both simple handles (@user) and NIP-05 format (@user@domain.com)
if (mentionText.length > 0 && /^[\w.-]+(@[\w.-]+)?$/.test(mentionText)) {
query = mentionText;
updateSuggestions(mentionText);
updatePosition(target, start);
showSuggestions = true;
} else if (mentionText.length === 0) {
// Just @, show all suggestions
query = '';
updateSuggestions('');
updatePosition(target, start);
showSuggestions = true;
} else {
showSuggestions = false;
}
} else {
showSuggestions = false;
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!showSuggestions) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, suggestions.length - 1);
scrollToSelected();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
scrollToSelected();
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
selectMention(selectedIndex);
} else if (e.key === 'Escape') {
e.preventDefault();
showSuggestions = false;
}
};
textarea.addEventListener('input', handleInput);
textarea.addEventListener('keydown', handleKeyDown);
textarea.addEventListener('click', handleInput);
textarea.addEventListener('selectionchange', handleInput);
return () => {
textarea.removeEventListener('input', handleInput);
textarea.removeEventListener('keydown', handleKeyDown);
textarea.removeEventListener('click', handleInput);
textarea.removeEventListener('selectionchange', handleInput);
};
});
async function updateSuggestions(query: string) {
const results = await searchProfiles(query, 10);
suggestions = results;
selectedIndex = 0;
}
function updatePosition(textarea: HTMLTextAreaElement, mentionStartPos: number) {
// Calculate position of @ mention in textarea
const text = textarea.value;
const textBeforeMention = text.substring(0, mentionStartPos);
// Create a temporary div to measure text position
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.visibility = 'hidden';
div.style.whiteSpace = 'pre-wrap';
div.style.font = window.getComputedStyle(textarea).font;
div.style.padding = window.getComputedStyle(textarea).padding;
div.style.border = window.getComputedStyle(textarea).border;
div.style.width = textarea.offsetWidth + 'px';
div.textContent = textBeforeMention;
document.body.appendChild(div);
const rect = textarea.getBoundingClientRect();
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
const lines = textBeforeMention.split('\n').length - 1;
position = {
top: rect.top + (lines * lineHeight) + lineHeight + 5,
left: rect.left + 10
};
document.body.removeChild(div);
}
function selectMention(index: number) {
if (index < 0 || index >= suggestions.length) return;
if (mentionStart === null) return;
const suggestion = suggestions[index];
const handle = suggestion.handle || suggestion.name || suggestion.pubkey.slice(0, 8);
const mentionText = `@${handle} `;
// Replace @mention with selected handle
const text = textarea.value;
const cursorPos = textarea.selectionStart || 0;
const textBefore = text.substring(0, mentionStart);
const textAfter = text.substring(cursorPos);
const newText = textBefore + mentionText + textAfter;
textarea.value = newText;
textarea.focus();
// Set cursor position after mention
const newCursorPos = mentionStart + mentionText.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
// Trigger input event to update content state
textarea.dispatchEvent(new Event('input', { bubbles: true }));
showSuggestions = false;
onMention?.(suggestion.pubkey, handle);
}
function scrollToSelected() {
const selectedElement = document.querySelector(`[data-mention-index="${selectedIndex}"]`);
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
</script>
{#if showSuggestions && suggestions.length > 0}
<div
class="mentions-autocomplete"
style="position: fixed; top: {position.top}px; left: {position.left}px; z-index: 1000;"
role="listbox"
>
<div class="suggestions-list">
{#each suggestions as suggestion, index}
<button
type="button"
class="suggestion-item"
class:selected={index === selectedIndex}
data-mention-index={index}
onclick={() => selectMention(index)}
onmouseenter={() => selectedIndex = index}
role="option"
aria-selected={index === selectedIndex}
>
{#if suggestion.picture}
<img
src={suggestion.picture}
alt=""
class="avatar"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
<div class="avatar-placeholder"></div>
{/if}
<div class="suggestion-info">
<div class="suggestion-name">
{suggestion.name || suggestion.handle || suggestion.pubkey.slice(0, 8)}
</div>
{#if suggestion.handle && suggestion.name}
<div class="suggestion-handle">@{suggestion.handle}</div>
{/if}
</div>
</button>
{/each}
</div>
</div>
{/if}
<style>
.mentions-autocomplete {
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
max-height: 300px;
overflow-y: auto;
min-width: 250px;
max-width: 400px;
}
:global(.dark) .mentions-autocomplete {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
.suggestions-list {
display: flex;
flex-direction: column;
}
.suggestion-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
width: 100%;
transition: background-color 0.15s;
}
.suggestion-item:hover,
.suggestion-item.selected {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .suggestion-item:hover,
:global(.dark) .suggestion-item.selected {
background: var(--fog-dark-highlight, #374151);
}
.avatar,
.avatar-placeholder {
width: 2rem;
height: 2rem;
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
}
.avatar-placeholder {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .avatar-placeholder {
background: var(--fog-dark-border, #475569);
}
.suggestion-info {
flex: 1;
min-width: 0;
}
.suggestion-name {
font-weight: 500;
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark) .suggestion-name {
color: var(--fog-dark-text, #f9fafb);
}
.suggestion-handle {
font-size: 0.75rem;
color: var(--fog-text-light, #6b7280);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark) .suggestion-handle {
color: var(--fog-dark-text-light, #9ca3af);
}
</style>

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

@ -90,6 +90,23 @@
}); });
} }
// Reposition menu on window resize
$effect(() => {
if (!menuOpen) return;
function handleResize() {
positionMenu();
}
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleResize, true);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleResize, true);
};
});
function closeMenu() { function closeMenu() {
menuOpen = false; menuOpen = false;
} }
@ -98,26 +115,63 @@
if (!menuButtonElement || !menuDropdownElement) return; if (!menuButtonElement || !menuDropdownElement) return;
const buttonRect = menuButtonElement.getBoundingClientRect(); const buttonRect = menuButtonElement.getBoundingClientRect();
const top = buttonRect.bottom + 4; const viewportWidth = window.innerWidth;
const right = window.innerWidth - buttonRect.right; const viewportHeight = window.innerHeight;
const padding = 8; // Padding from viewport edges
// Initial position: below button, aligned to right edge
let top = buttonRect.bottom + 4;
let right = viewportWidth - buttonRect.right;
menuPosition = { top, right }; menuPosition = { top, right };
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!menuDropdownElement) return; if (!menuDropdownElement) return;
const dropdownRect = menuDropdownElement.getBoundingClientRect(); const dropdownRect = menuDropdownElement.getBoundingClientRect();
const viewportHeight = window.innerHeight; const dropdownWidth = dropdownRect.width;
const viewportWidth = window.innerWidth; const dropdownHeight = dropdownRect.height;
// Adjust if menu goes off bottom of screen // Calculate left position from right
if (top + dropdownRect.height > viewportHeight) { const left = viewportWidth - right - dropdownWidth;
menuPosition.top = buttonRect.top - dropdownRect.height - 4;
let adjustedTop = top;
let adjustedRight = right;
// Check bottom overflow
if (top + dropdownHeight + padding > viewportHeight) {
// Try positioning above button
const spaceAbove = buttonRect.top;
const spaceBelow = viewportHeight - buttonRect.bottom;
if (spaceAbove >= dropdownHeight + padding || spaceAbove > spaceBelow) {
adjustedTop = buttonRect.top - dropdownHeight - 4;
} else {
// Not enough space above, position at bottom of viewport
adjustedTop = viewportHeight - dropdownHeight - padding;
}
} }
// Adjust if menu goes off right side of screen // Check top overflow
if (right - dropdownRect.width < 0) { if (adjustedTop < padding) {
menuPosition.right = window.innerWidth - buttonRect.left; adjustedTop = padding;
}
// Check right overflow (menu goes off right edge)
if (left < padding) {
adjustedRight = viewportWidth - padding - dropdownWidth;
} }
// Check left overflow (menu goes off left edge)
const adjustedLeft = viewportWidth - adjustedRight - dropdownWidth;
if (adjustedLeft < padding) {
adjustedRight = viewportWidth - padding - dropdownWidth;
}
// Ensure menu doesn't go off right edge
if (adjustedRight < padding) {
adjustedRight = padding;
}
menuPosition = { top: adjustedTop, right: adjustedRight };
}); });
} }
@ -383,7 +437,11 @@
border-radius: 0.375rem; border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 180px; min-width: 180px;
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
padding: 0.25rem; padding: 0.25rem;
overflow-y: auto;
overflow-x: hidden;
} }
:global(.dark) .menu-dropdown { :global(.dark) .menu-dropdown {

32
src/lib/components/write/CreateEventForm.svelte

@ -15,6 +15,8 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { KIND } from '../../types/kind-lookup.js'; import { KIND } from '../../types/kind-lookup.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import MentionsAutocomplete from '../content/MentionsAutocomplete.svelte';
import { extractMentions, getMentionPubkeys } from '../../services/mentions.js';
const SUPPORTED_KINDS = [ const SUPPORTED_KINDS = [
{ value: 1, label: '1 - Short Text Note' }, { value: 1, label: '1 - Short Text Note' },
@ -115,6 +117,7 @@
let fileInputRef: HTMLInputElement | null = $state(null); let fileInputRef: HTMLInputElement | null = $state(null);
let uploading = $state(false); let uploading = $state(false);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
let eventJson = $state('{}');
// Sync selectedKind when initialKind prop changes // Sync selectedKind when initialKind prop changes
$effect(() => { $effect(() => {
@ -490,7 +493,7 @@
tags = newTags; tags = newTags;
} }
function getEventJson(): string { async function getEventJson(): Promise<string> {
const session = sessionManager.getSession(); const session = sessionManager.getSession();
if (!session) return '{}'; if (!session) return '{}';
@ -512,6 +515,13 @@
} }
} }
// Extract mentions and add p tags
const mentions = await extractMentions(contentWithUrls);
const mentionPubkeys = getMentionPubkeys(mentions);
for (const pubkey of mentionPubkeys) {
allTags.push(['p', pubkey]);
}
if (shouldIncludeClientTag()) { if (shouldIncludeClientTag()) {
allTags.push(['client', 'aitherboard']); allTags.push(['client', 'aitherboard']);
} }
@ -653,6 +663,13 @@
} }
} }
// Extract mentions and add p tags
const mentions = await extractMentions(contentWithUrls);
const mentionPubkeys = getMentionPubkeys(mentions);
for (const pubkey of mentionPubkeys) {
allTags.push(['p', pubkey]);
}
if (shouldIncludeClientTag()) { if (shouldIncludeClientTag()) {
allTags.push(['client', 'aitherboard']); allTags.push(['client', 'aitherboard']);
} }
@ -833,6 +850,10 @@
disabled={publishing} disabled={publishing}
></textarea> ></textarea>
{#if textareaRef}
<MentionsAutocomplete textarea={textareaRef} />
{/if}
<div class="textarea-buttons"> <div class="textarea-buttons">
<button <button
type="button" type="button"
@ -863,7 +884,10 @@
<div class="content-buttons"> <div class="content-buttons">
<button <button
type="button" type="button"
onclick={() => showJsonModal = true} onclick={async () => {
eventJson = await getEventJson();
showJsonModal = true;
}}
class="content-button" class="content-button"
disabled={publishing} disabled={publishing}
title="View JSON" title="View JSON"
@ -989,11 +1013,11 @@
<button onclick={() => showJsonModal = false} class="close-button">×</button> <button onclick={() => showJsonModal = false} class="close-button">×</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<pre class="json-preview">{getEventJson()}</pre> <pre class="json-preview">{eventJson}</pre>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button onclick={() => { <button onclick={() => {
navigator.clipboard.writeText(getEventJson()); navigator.clipboard.writeText(eventJson);
alert('JSON copied to clipboard'); alert('JSON copied to clipboard');
}}>Copy</button> }}>Copy</button>
<button onclick={() => showJsonModal = false}>Close</button> <button onclick={() => showJsonModal = false}>Close</button>

53
src/lib/modules/comments/CommentForm.svelte

@ -16,6 +16,8 @@
import { KIND } from '../../types/kind-lookup.js'; import { KIND } from '../../types/kind-lookup.js';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js'; import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
import { cacheEvent } from '../../services/cache/event-cache.js'; import { cacheEvent } from '../../services/cache/event-cache.js';
import MentionsAutocomplete from '../../components/content/MentionsAutocomplete.svelte';
import { extractMentions, getMentionPubkeys } from '../../services/mentions.js';
interface Props { interface Props {
threadId: string; // The root event ID threadId: string; // The root event ID
@ -81,6 +83,7 @@
let fileInputRef: HTMLInputElement | null = $state(null); let fileInputRef: HTMLInputElement | null = $state(null);
let uploading = $state(false); let uploading = $state(false);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
let eventJson = $state('{}');
const isLoggedIn = $derived(sessionManager.isLoggedIn()); const isLoggedIn = $derived(sessionManager.isLoggedIn());
// Only show GIF/emoji buttons for non-kind-11 events (kind 1 replies and kind 1111 comments) // Only show GIF/emoji buttons for non-kind-11 events (kind 1 replies and kind 1111 comments)
@ -165,6 +168,13 @@
} }
} }
// Extract mentions and add p tags
const mentions = await extractMentions(contentWithUrls);
const mentionPubkeys = getMentionPubkeys(mentions);
for (const pubkey of mentionPubkeys) {
tags.push(['p', pubkey]);
}
if (shouldIncludeClientTag()) { if (shouldIncludeClientTag()) {
tags.push(['client', 'aitherboard']); tags.push(['client', 'aitherboard']);
} }
@ -281,7 +291,7 @@
showEmojiPicker = false; showEmojiPicker = false;
} }
function getEventJson(): string { async function getEventJson(): Promise<string> {
const replyKind = getReplyKind(); const replyKind = getReplyKind();
const tags: string[][] = []; const tags: string[][] = [];
@ -310,19 +320,9 @@
} }
} }
if (shouldIncludeClientTag()) { // Extract mentions and add p tags
tags.push(['client', 'aitherboard']);
}
// Add file attachments as imeta tags (same as publish function)
let contentWithUrls = content.trim(); let contentWithUrls = content.trim();
for (const file of uploadedFiles) { for (const file of uploadedFiles) {
// Ensure imetaTag is a plain array (not a Proxy) to avoid cloning issues
const imetaTag = Array.isArray(file.imetaTag) ? [...file.imetaTag] : file.imetaTag;
tags.push(imetaTag);
// Add URL to content field only if it's not already there
// (to avoid duplicates if URL was already inserted into textarea)
if (!contentWithUrls.includes(file.url)) { if (!contentWithUrls.includes(file.url)) {
if (contentWithUrls && !contentWithUrls.endsWith('\n')) { if (contentWithUrls && !contentWithUrls.endsWith('\n')) {
contentWithUrls += '\n'; contentWithUrls += '\n';
@ -330,6 +330,22 @@
contentWithUrls += `${file.url}\n`; contentWithUrls += `${file.url}\n`;
} }
} }
const mentions = await extractMentions(contentWithUrls);
const mentionPubkeys = getMentionPubkeys(mentions);
for (const pubkey of mentionPubkeys) {
tags.push(['p', pubkey]);
}
// Add file attachments as imeta tags (same as publish function)
for (const file of uploadedFiles) {
// Ensure imetaTag is a plain array (not a Proxy) to avoid cloning issues
const imetaTag = Array.isArray(file.imetaTag) ? [...file.imetaTag] : file.imetaTag;
tags.push(imetaTag);
}
if (shouldIncludeClientTag()) {
tags.push(['client', 'aitherboard']);
}
const event: Omit<NostrEvent, 'id' | 'sig'> = { const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: replyKind, kind: replyKind,
@ -409,6 +425,10 @@
disabled={publishing} disabled={publishing}
></textarea> ></textarea>
{#if textareaRef}
<MentionsAutocomplete textarea={textareaRef} />
{/if}
{#if showGifButton} {#if showGifButton}
<div class="textarea-buttons"> <div class="textarea-buttons">
<button <button
@ -442,7 +462,10 @@
<div class="flex gap-2 comment-form-left"> <div class="flex gap-2 comment-form-left">
<button <button
type="button" type="button"
onclick={() => showJsonModal = true} onclick={async () => {
eventJson = await getEventJson();
showJsonModal = true;
}}
class="px-3 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight disabled:opacity-50" class="px-3 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight disabled:opacity-50"
disabled={publishing} disabled={publishing}
title="View JSON" title="View JSON"
@ -537,11 +560,11 @@
<button onclick={() => showJsonModal = false} class="close-button">×</button> <button onclick={() => showJsonModal = false} class="close-button">×</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<pre class="json-preview">{getEventJson()}</pre> <pre class="json-preview">{eventJson}</pre>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button onclick={() => { <button onclick={() => {
navigator.clipboard.writeText(getEventJson()); navigator.clipboard.writeText(eventJson);
alert('JSON copied to clipboard'); alert('JSON copied to clipboard');
}}>Copy</button> }}>Copy</button>
<button onclick={() => showJsonModal = false}>Close</button> <button onclick={() => showJsonModal = false}>Close</button>

180
src/lib/modules/profiles/ProfilePage.svelte

@ -16,14 +16,20 @@
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js'; import { KIND } from '../../types/kind-lookup.js';
import CommentForm from '../comments/CommentForm.svelte';
import Comment from '../comments/Comment.svelte';
import { getProfile } from '../../services/cache/profile-cache.js';
let profile = $state<ProfileData | null>(null); let profile = $state<ProfileData | null>(null);
let profileEvent = $state<NostrEvent | null>(null); // The kind 0 event
let userStatus = $state<string | null>(null); let userStatus = $state<string | null>(null);
let notifications = $state<NostrEvent[]>([]); let notifications = $state<NostrEvent[]>([]);
let interactionsWithMe = $state<NostrEvent[]>([]); let interactionsWithMe = $state<NostrEvent[]>([]);
let wallComments = $state<NostrEvent[]>([]); // Kind 1111 comments on the wall
let loading = $state(true); let loading = $state(true);
let activeTab = $state<'pins' | 'notifications' | 'interactions'>('pins'); let loadingWall = $state(false);
let activeTab = $state<'pins' | 'notifications' | 'interactions' | 'wall'>('pins');
let nip05Validations = $state<Record<string, boolean | null>>({}); // null = checking, true = valid, false = invalid let nip05Validations = $state<Record<string, boolean | null>>({}); // null = checking, true = valid, false = invalid
// Compute pubkey from route params // Compute pubkey from route params
let profilePubkey = $derived.by(() => decodePubkey($page.params.pubkey)); let profilePubkey = $derived.by(() => decodePubkey($page.params.pubkey));
@ -106,6 +112,82 @@
return unsubscribe; return unsubscribe;
}); });
async function loadProfileEvent(pubkey: string) {
if (!isMounted) return;
try {
// Try cache first
const cached = await getProfile(pubkey);
if (cached) {
profileEvent = cached.event;
// Load wall comments if we have the profile event
if (profileEvent) {
await loadWallComments(profileEvent.id);
}
return;
}
// Fetch from relays if not in cache
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0 && isMounted) {
profileEvent = events[0];
// Load wall comments
await loadWallComments(profileEvent.id);
}
} catch (error) {
console.error('Error loading profile event:', error);
}
}
async function loadWallComments(profileEventId: string) {
if (!isMounted || !profileEventId) return;
loadingWall = true;
try {
// Fetch kind 1111 comments that reference this kind 0 event
// NIP-22 format: K="0", E=profileEventId
const relays = relayManager.getCommentReadRelays();
const comments = await nostrClient.fetchEvents(
[
{
kinds: [KIND.COMMENT],
'#K': ['0'], // Root kind is 0
'#E': [profileEventId], // Root event is the profile event
limit: 100
}
],
relays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
if (!isMounted) return;
// Filter to only comments that actually reference this profile event as root
// and sort by created_at descending
wallComments = comments
.filter(comment => {
const kTag = comment.tags.find(t => t[0] === 'K' && t[1] === '0');
const eTag = comment.tags.find(t => t[0] === 'E' && t[1] === profileEventId);
return kTag && eTag;
})
.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error loading wall comments:', error);
if (isMounted) {
wallComments = [];
}
} finally {
if (isMounted) {
loadingWall = false;
}
}
}
async function loadPins(pubkey: string) { async function loadPins(pubkey: string) {
if (!isMounted) return; if (!isMounted) return;
try { try {
@ -510,6 +592,9 @@
userStatus = status; userStatus = status;
loading = false; // Show profile immediately, even if posts are still loading loading = false; // Show profile immediately, even if posts are still loading
// Load the kind 0 profile event for the wall
await loadProfileEvent(pubkey);
// Validate NIP-05 addresses in background (non-blocking) // Validate NIP-05 addresses in background (non-blocking)
if (profileData?.nip05 && profileData.nip05.length > 0) { if (profileData?.nip05 && profileData.nip05.length > 0) {
for (const nip05 of profileData.nip05) { for (const nip05 of profileData.nip05) {
@ -535,7 +620,7 @@
activeTab = 'notifications'; activeTab = 'notifications';
} else if (pins.length > 0) { } else if (pins.length > 0) {
activeTab = 'pins'; activeTab = 'pins';
} }
} else { } else {
notifications = []; notifications = [];
// Load interactions if logged in and viewing another user's profile // Load interactions if logged in and viewing another user's profile
@ -662,6 +747,18 @@
> >
Pins ({pins.length}) Pins ({pins.length})
</button> </button>
<button
onclick={async () => {
activeTab = 'wall';
// Load wall comments if profile event is available and not already loaded
if (profileEvent && wallComments.length === 0 && !loadingWall) {
await loadWallComments(profileEvent.id);
}
}}
class="px-4 py-2 font-semibold {activeTab === 'wall' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
>
Wall ({wallComments.length})
</button>
{#if isOwnProfile} {#if isOwnProfile}
<button <button
onclick={() => activeTab = 'notifications'} onclick={() => activeTab = 'notifications'}
@ -699,6 +796,48 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{:else if activeTab === 'wall'}
{#if profileEvent}
<div class="wall-section">
{#if sessionManager.isLoggedIn()}
<div class="wall-form mb-6">
<h3 class="wall-title mb-4">Write on the Wall</h3>
<CommentForm
threadId={profileEvent.id}
rootEvent={profileEvent}
onPublished={async () => {
// Reload wall comments after publishing
if (profileEvent) {
await loadWallComments(profileEvent.id);
}
}}
/>
</div>
{:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light mb-4">Log in to write on the wall</p>
{/if}
<div class="wall-comments-section">
<h3 class="wall-title mb-4">Wall Posts</h3>
{#if loadingWall}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading wall...</p>
{:else if wallComments.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No wall posts yet. Be the first to write on the wall!</p>
{:else}
<div class="wall-comments">
{#each wallComments as comment (comment.id)}
<Comment
comment={comment}
rootEventKind={KIND.METADATA}
/>
{/each}
</div>
{/if}
</div>
</div>
{:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading profile event...</p>
{/if}
{:else if activeTab === 'interactions'} {:else if activeTab === 'interactions'}
{#if interactionsWithMe.length === 0} {#if interactionsWithMe.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No interactions with you yet.</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">No interactions with you yet.</p>
@ -879,4 +1018,41 @@
background: var(--fog-dark-highlight, #374151); background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569); border-color: var(--fog-dark-border, #475569);
} }
.wall-section {
margin-top: 1rem;
}
.wall-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
margin: 0 0 1rem 0;
}
:global(.dark) .wall-title {
color: var(--fog-dark-text, #f9fafb);
}
.wall-form {
padding: 1rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
}
:global(.dark) .wall-form {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.wall-comments-section {
margin-top: 2rem;
}
.wall-comments {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style> </style>

137
src/lib/services/file-compression.ts

@ -0,0 +1,137 @@
/**
* File compression utilities for images and videos
* Compresses files before upload to reduce size
*/
export interface CompressionOptions {
maxWidth?: number;
maxHeight?: number;
quality?: number; // 0-1 for images
maxSizeMB?: number; // Skip compression if file is smaller than this
}
const DEFAULT_OPTIONS: CompressionOptions = {
maxWidth: 1200,
maxHeight: 1080,
quality: 0.85,
maxSizeMB: 2
};
/**
* Compress an image file
* @param file - The image file to compress
* @param options - Compression options
* @returns Compressed file as Blob
*/
export async function compressImage(
file: File,
options: CompressionOptions = {}
): Promise<Blob> {
const opts = { ...DEFAULT_OPTIONS, ...options };
// Skip compression if file is already small enough
if (opts.maxSizeMB && file.size <= opts.maxSizeMB * 1024 * 1024) {
return file;
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
// Calculate new dimensions
if (opts.maxWidth && width > opts.maxWidth) {
height = (height * opts.maxWidth) / width;
width = opts.maxWidth;
}
if (opts.maxHeight && height > opts.maxHeight) {
width = (width * opts.maxHeight) / height;
height = opts.maxHeight;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Could not get canvas context'));
return;
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to compress image'));
}
},
file.type || 'image/jpeg',
opts.quality
);
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = e.target?.result as string;
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
/**
* Compress a video file (re-encode to reduce size)
* Note: This is a basic implementation. For better compression, consider using a library like ffmpeg.wasm
* @param file - The video file to compress
* @param options - Compression options
* @returns Compressed file as Blob (or original if compression not supported)
*/
export async function compressVideo(
file: File,
options: CompressionOptions = {}
): Promise<Blob> {
const opts = { ...DEFAULT_OPTIONS, ...options };
// Skip compression if file is already small enough
if (opts.maxSizeMB && file.size <= opts.maxSizeMB * 1024 * 1024) {
return file;
}
// For now, return original file as browser video compression is complex
// In the future, could integrate ffmpeg.wasm for client-side video compression
// For now, we'll rely on server-side compression or accept larger files
return file;
}
/**
* Compress a file based on its type
* @param file - The file to compress
* @param options - Compression options
* @returns Compressed file as File object
*/
export async function compressFile(
file: File,
options: CompressionOptions = {}
): Promise<File> {
let compressedBlob: Blob;
if (file.type.startsWith('image/')) {
compressedBlob = await compressImage(file, options);
} else if (file.type.startsWith('video/')) {
compressedBlob = await compressVideo(file, options);
} else {
// No compression for other file types
return file;
}
// Create a new File object from the compressed blob
return new File([compressedBlob], file.name, {
type: file.type,
lastModified: Date.now()
});
}

165
src/lib/services/mentions.ts

@ -0,0 +1,165 @@
/**
* Mentions extraction and processing
* Extracts @mentions from content and converts them to p tags
*/
import { getProfileByPubkey } from './profile-search.js';
import { nip19 } from 'nostr-tools';
import { nostrClient } from './nostr/nostr-client.js';
import { relayManager } from './nostr/relay-manager.js';
import { cacheProfile } from './cache/profile-cache.js';
import { parseProfile } from './user-data.js';
import { KIND } from '../types/kind-lookup.js';
import type { NostrEvent } from '../types/nostr.js';
export interface MentionInfo {
pubkey: string;
handle: string;
start: number;
end: number;
}
/**
* Extract @mentions from content
* Finds @handle patterns and resolves them to pubkeys
* @param content - The content to search for mentions
* @returns Array of mention information
*/
export async function extractMentions(content: string): Promise<MentionInfo[]> {
const mentions: MentionInfo[] = [];
// Match @handle patterns
// Support both simple handles (@user) and NIP-05 format (@user@domain.com)
// Pattern: @ followed by word chars, dots, hyphens, optionally followed by @domain.com
const mentionRegex = /@([\w.-]+(?:@[\w.-]+)?)/g;
let match;
while ((match = mentionRegex.exec(content)) !== null) {
const handle = match[1];
const start = match.index;
const end = start + match[0].length;
// Try to resolve handle to pubkey
// Supports both simple handles and full NIP-05 addresses (user@domain.com)
const profile = await findProfileByHandle(handle);
if (profile) {
mentions.push({
pubkey: profile.pubkey,
handle: profile.handle || handle,
start,
end
});
}
}
// Also extract nostr:npub and nostr:nprofile mentions
const nostrRegex = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g;
while ((match = nostrRegex.exec(content)) !== null) {
try {
const id = match[0].split(':')[1];
const { type, data } = nip19.decode(id);
let pubkey: string | undefined;
if (type === 'npub') {
pubkey = data as string;
} else if (type === 'nprofile') {
pubkey = (data as { pubkey: string }).pubkey;
}
if (pubkey) {
// Fetch from relays if not in cache (use profile relays from config)
const profile = await getProfileByPubkey(pubkey, true);
mentions.push({
pubkey,
handle: profile?.handle || profile?.name || pubkey.slice(0, 8),
start: match.index,
end: match.index + match[0].length
});
}
} catch (e) {
// Invalid nostr ID, skip
console.debug('Invalid nostr ID in mention:', e);
}
}
return mentions;
}
/**
* Find profile by handle (searches cached profiles, then fetches from relays)
* @param handle - The handle to search for
* @returns Profile or null
*/
async function findProfileByHandle(handle: string): Promise<{ pubkey: string; handle?: string } | null> {
// Search through cached profiles for matching handle
try {
const { searchProfiles } = await import('./profile-search.js');
const { fetchProfile } = await import('./user-data.js');
const results = await searchProfiles(handle, 20); // Get more results to find exact matches
const searchHandle = handle.toLowerCase();
// If handle contains @, it's a NIP-05 address - look for exact match
if (searchHandle.includes('@')) {
// Search for exact NIP-05 match
for (const profile of results) {
const profileData = await fetchProfile(profile.pubkey);
if (profileData?.nip05) {
// Check if any NIP-05 matches exactly
for (const nip05 of profileData.nip05) {
if (nip05.toLowerCase() === searchHandle) {
return {
pubkey: profile.pubkey,
handle: searchHandle
};
}
}
}
}
}
// For simple handles or if no exact NIP-05 match found, use best matching result
if (results.length > 0) {
const profile = results[0];
const profileHandle = profile.handle?.toLowerCase();
// Check if handle matches exactly or starts with search term
if (profileHandle === searchHandle || profileHandle?.startsWith(searchHandle)) {
return {
pubkey: profile.pubkey,
handle: profile.handle || handle
};
}
// Return first result as fallback (might be partial match)
return {
pubkey: profile.pubkey,
handle: profile.handle || handle
};
}
// If not found in cache, try to search by nip05 on relays
// This is a best-effort search - we can't efficiently search all profiles on relays
// But we can try to fetch profiles that might match the handle
// Note: This is limited - we'd need to know the domain or have a better search mechanism
// For now, we'll just return null if not found in cache
} catch (error) {
console.debug('Error finding profile by handle:', error);
}
return null;
}
/**
* Get unique pubkeys from mentions
* @param mentions - Array of mention info
* @returns Array of unique pubkeys
*/
export function getMentionPubkeys(mentions: MentionInfo[]): string[] {
const pubkeys = new Set<string>();
for (const mention of mentions) {
pubkeys.add(mention.pubkey);
}
return Array.from(pubkeys);
}

22
src/lib/services/nostr/file-upload.ts

@ -5,6 +5,7 @@
import { sessionManager } from '../auth/session-manager.js'; import { sessionManager } from '../auth/session-manager.js';
import { signHttpAuth } from '../nostr/auth-handler.js'; import { signHttpAuth } from '../nostr/auth-handler.js';
import { compressFile } from '../file-compression.js';
export interface UploadResult { export interface UploadResult {
url: string; url: string;
@ -15,12 +16,29 @@ export interface UploadResult {
* Upload a file to a media server using NIP-96 discovery * Upload a file to a media server using NIP-96 discovery
* @param file - The file to upload * @param file - The file to upload
* @param context - Optional context string for logging (e.g., 'CommentForm', 'CreateEventForm') * @param context - Optional context string for logging (e.g., 'CommentForm', 'CreateEventForm')
* @param compress - Whether to compress the file before upload (default: true)
* @returns Promise with the upload URL and tags from the response * @returns Promise with the upload URL and tags from the response
*/ */
export async function uploadFileToServer( export async function uploadFileToServer(
file: File, file: File,
context: string = 'FileUpload' context: string = 'FileUpload',
compress: boolean = true
): Promise<UploadResult> { ): Promise<UploadResult> {
// Compress file before upload if enabled and file type supports it
let fileToUpload = file;
if (compress && (file.type.startsWith('image/') || file.type.startsWith('video/'))) {
try {
console.log(`[${context}] Compressing ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)...`);
fileToUpload = await compressFile(file);
const originalSize = file.size;
const compressedSize = fileToUpload.size;
const reduction = ((1 - compressedSize / originalSize) * 100).toFixed(1);
console.log(`[${context}] Compressed ${file.name}: ${(compressedSize / 1024 / 1024).toFixed(2)} MB (${reduction}% reduction)`);
} catch (error) {
console.warn(`[${context}] Compression failed, uploading original file:`, error);
// Continue with original file if compression fails
}
}
const mediaServer = typeof window !== 'undefined' const mediaServer = typeof window !== 'undefined'
? localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build' ? localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build'
: 'https://nostr.build'; : 'https://nostr.build';
@ -49,7 +67,7 @@ export async function uploadFileToServer(
]; ];
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', fileToUpload);
for (const endpoint of endpoints) { for (const endpoint of endpoints) {
try { try {

184
src/lib/services/profile-search.ts

@ -0,0 +1,184 @@
/**
* Profile search service for mentions autocomplete
* Searches through cached profiles by name, handle, and nip05
* Can also fetch from relays if not found in cache
*/
import { getDB } from './cache/indexeddb-store.js';
import type { CachedProfile } from './cache/profile-cache.js';
import { parseProfile, fetchProfile } from './user-data.js';
import { nostrClient } from './nostr/nostr-client.js';
import { relayManager } from './nostr/relay-manager.js';
import { cacheProfile } from './cache/profile-cache.js';
import { KIND } from '../types/kind-lookup.js';
import type { NostrEvent } from '../types/nostr.js';
export interface ProfileSearchResult {
pubkey: string;
name?: string;
handle?: string; // nip05 handle
picture?: string;
}
/**
* Search profiles by query string
* Matches against name, nip05 handle, and pubkey
* @param query - Search query (case-insensitive)
* @param limit - Maximum number of results (default: 20)
* @returns Array of matching profiles
*/
export async function searchProfiles(
query: string,
limit: number = 20
): Promise<ProfileSearchResult[]> {
if (!query || query.trim().length === 0) {
return [];
}
const searchTerm = query.trim().toLowerCase();
const results: ProfileSearchResult[] = [];
try {
const db = await getDB();
const tx = db.transaction('profiles', 'readonly');
const store = tx.store;
// Iterate through all profiles
let cursor = await store.openCursor();
while (cursor && results.length < limit) {
const cached = cursor.value as CachedProfile;
const profile = parseProfile(cached.event);
// Check if profile matches query
const name = profile.name?.toLowerCase() || '';
const pubkey = String(cursor.key).toLowerCase();
// Check all NIP-05 addresses (profiles can have multiple)
const allNip05 = (profile.nip05 || []).map(n => n.toLowerCase());
const nip05Matches = allNip05.some(nip05 => nip05.includes(searchTerm));
// Extract handles from all NIP-05 addresses
const allHandles = allNip05
.filter(nip05 => nip05.includes('@'))
.map(nip05 => nip05.split('@')[0]);
const handleMatches = allHandles.some(handle => handle.includes(searchTerm));
// Match against name, handle, nip05, or pubkey
if (
name.includes(searchTerm) ||
handleMatches ||
nip05Matches ||
pubkey.includes(searchTerm)
) {
// Extract handle from first NIP-05 (for display)
const firstNip05 = profile.nip05?.[0] || '';
const handle = firstNip05.includes('@') ? firstNip05.split('@')[0] : '';
results.push({
pubkey: cursor.key as string,
name: profile.name,
handle: handle || undefined,
picture: profile.picture
});
}
cursor = await cursor.continue();
}
// Sort results: exact matches first, then by relevance
results.sort((a, b) => {
const aName = (a.name || '').toLowerCase();
const bName = (b.name || '').toLowerCase();
const aHandle = (a.handle || '').toLowerCase();
const bHandle = (b.handle || '').toLowerCase();
// Exact name match
if (aName === searchTerm && bName !== searchTerm) return -1;
if (bName === searchTerm && aName !== searchTerm) return 1;
// Exact handle match
if (aHandle === searchTerm && bHandle !== searchTerm) return -1;
if (bHandle === searchTerm && aHandle !== searchTerm) return 1;
// Starts with search term
if (aName.startsWith(searchTerm) && !bName.startsWith(searchTerm)) return -1;
if (bName.startsWith(searchTerm) && !aName.startsWith(searchTerm)) return 1;
if (aHandle.startsWith(searchTerm) && !bHandle.startsWith(searchTerm)) return -1;
if (bHandle.startsWith(searchTerm) && !aHandle.startsWith(searchTerm)) return 1;
// Alphabetical
return aName.localeCompare(bName);
});
return results.slice(0, limit);
} catch (error) {
console.error('Error searching profiles:', error);
return [];
}
}
/**
* Get profile by pubkey
* Checks cache first, then fetches from profile relays if not found
* @param pubkey - The pubkey to look up
* @param fetchFromRelays - Whether to fetch from relays if not in cache (default: true)
* @returns Profile data or null
*/
export async function getProfileByPubkey(
pubkey: string,
fetchFromRelays: boolean = true
): Promise<ProfileSearchResult | null> {
try {
const db = await getDB();
const cached = await db.get('profiles', pubkey);
if (cached) {
const profile = parseProfile(cached.event);
const nip05 = profile.nip05?.[0] || '';
const handle = nip05.includes('@') ? nip05.split('@')[0] : '';
return {
pubkey,
name: profile.name,
handle: handle || undefined,
picture: profile.picture
};
}
// Not in cache, fetch from relays if requested
if (fetchFromRelays) {
try {
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }],
relays,
{ useCache: false, cacheResults: true }
);
if (events.length > 0) {
const event = events[0] as NostrEvent;
// Cache the profile for future use
await cacheProfile(event);
const profile = parseProfile(event);
const nip05 = profile.nip05?.[0] || '';
const handle = nip05.includes('@') ? nip05.split('@')[0] : '';
return {
pubkey,
name: profile.name,
handle: handle || undefined,
picture: profile.picture
};
}
} catch (error) {
console.debug('Error fetching profile from relays:', error);
}
}
return null;
} catch (error) {
console.error('Error getting profile:', error);
return null;
}
}

127
src/routes/bookmarks/+page.svelte

@ -8,14 +8,28 @@
import type { NostrEvent } from '../../lib/types/nostr.js'; import type { NostrEvent } from '../../lib/types/nostr.js';
import { KIND } from '../../lib/types/kind-lookup.js'; import { KIND } from '../../lib/types/kind-lookup.js';
let events = $state<NostrEvent[]>([]); let allEvents = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let currentPage = $state(1);
const itemsPerPage = 100;
const maxTotalBookmarks = 500;
// Computed: get events for current page
let paginatedEvents = $derived.by(() => {
const start = (currentPage - 1) * itemsPerPage;
const end = start + itemsPerPage;
return allEvents.slice(start, end);
});
// Computed: total pages
let totalPages = $derived.by(() => Math.ceil(allEvents.length / itemsPerPage));
async function loadBookmarks() { async function loadBookmarks() {
loading = true; loading = true;
error = null; error = null;
events = []; allEvents = [];
currentPage = 1;
try { try {
// Fetch all kind 10003 (bookmark) events from relays // Fetch all kind 10003 (bookmark) events from relays
@ -48,8 +62,13 @@
return; return;
} }
// Limit to maxTotalBookmarks
const eventIds = Array.from(bookmarkedIds).slice(0, maxTotalBookmarks);
if (bookmarkedIds.size > maxTotalBookmarks) {
console.log(`[Bookmarks] Limiting to ${maxTotalBookmarks} bookmarks (found ${bookmarkedIds.size})`);
}
// Fetch the actual events - batch to avoid relay limits // Fetch the actual events - batch to avoid relay limits
const eventIds = Array.from(bookmarkedIds);
const batchSize = config.veryLargeBatchLimit; // Use config batch limit const batchSize = config.veryLargeBatchLimit; // Use config batch limit
const allFetchedEvents: NostrEvent[] = []; const allFetchedEvents: NostrEvent[] = [];
@ -77,8 +96,8 @@
console.log(`[Bookmarks] Total fetched: ${allFetchedEvents.length} events`); console.log(`[Bookmarks] Total fetched: ${allFetchedEvents.length} events`);
// Sort by created_at (newest first) // Sort by created_at (newest first) and limit to maxTotalBookmarks
events = allFetchedEvents.sort((a, b) => b.created_at - a.created_at); allEvents = allFetchedEvents.sort((a, b) => b.created_at - a.created_at).slice(0, maxTotalBookmarks);
} catch (err) { } catch (err) {
console.error('Error loading bookmarks:', err); console.error('Error loading bookmarks:', err);
error = err instanceof Error ? err.message : 'Failed to load bookmarks'; error = err instanceof Error ? err.message : 'Failed to load bookmarks';
@ -107,16 +126,57 @@
<div class="error-state"> <div class="error-state">
<p class="text-fog-text dark:text-fog-dark-text error-message">{error}</p> <p class="text-fog-text dark:text-fog-dark-text error-message">{error}</p>
</div> </div>
{:else if events.length === 0} {:else if allEvents.length === 0}
<div class="empty-state"> <div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No bookmarks found.</p> <p class="text-fog-text dark:text-fog-dark-text">No bookmarks found.</p>
</div> </div>
{:else} {:else}
<div class="bookmarks-info">
<p class="text-fog-text dark:text-fog-dark-text text-sm">
Showing {paginatedEvents.length} of {allEvents.length} bookmarks
{#if allEvents.length >= maxTotalBookmarks}
(limited to {maxTotalBookmarks})
{/if}
</p>
</div>
<div class="bookmarks-posts"> <div class="bookmarks-posts">
{#each events as event (event.id)} {#each paginatedEvents as event (event.id)}
<FeedPost post={event} /> <FeedPost post={event} />
{/each} {/each}
</div> </div>
{#if totalPages > 1}
<div class="pagination">
<button
class="pagination-button"
disabled={currentPage === 1}
onclick={() => {
if (currentPage > 1) currentPage--;
}}
aria-label="Previous page"
>
Previous
</button>
<div class="pagination-info">
<span class="text-fog-text dark:text-fog-dark-text">
Page {currentPage} of {totalPages}
</span>
</div>
<button
class="pagination-button"
disabled={currentPage === totalPages}
onclick={() => {
if (currentPage < totalPages) currentPage++;
}}
aria-label="Next page"
>
Next
</button>
</div>
{/if}
{/if} {/if}
</div> </div>
</main> </main>
@ -142,9 +202,62 @@
color: var(--fog-dark-accent, #94a3b8); color: var(--fog-dark-accent, #94a3b8);
} }
.bookmarks-info {
margin-bottom: 1rem;
padding: 0.5rem 0;
}
.bookmarks-posts { .bookmarks-posts {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
padding: 1rem 0;
}
.pagination-button {
padding: 0.5rem 1rem;
background-color: var(--fog-bg, #ffffff);
color: var(--fog-text, #1e293b);
border: 1px solid var(--fog-border, #e2e8f0);
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.pagination-button:hover:not(:disabled) {
background-color: var(--fog-accent, #64748b);
color: var(--fog-bg, #ffffff);
border-color: var(--fog-accent, #64748b);
}
.pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .pagination-button {
background-color: var(--fog-dark-bg, #0f172a);
color: var(--fog-dark-text, #f1f5f9);
border-color: var(--fog-dark-border, #334155);
}
:global(.dark) .pagination-button:hover:not(:disabled) {
background-color: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-bg, #0f172a);
border-color: var(--fog-dark-accent, #94a3b8);
}
.pagination-info {
min-width: 100px;
text-align: center;
}
</style> </style>

Loading…
Cancel
Save