13 changed files with 1299 additions and 67 deletions
@ -0,0 +1,302 @@
@@ -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> |
||||
@ -0,0 +1,137 @@
@@ -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() |
||||
}); |
||||
} |
||||
@ -0,0 +1,165 @@
@@ -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); |
||||
} |
||||
@ -0,0 +1,184 @@
@@ -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; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue