13 changed files with 1299 additions and 67 deletions
@ -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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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