Browse Source
Nostr-Signature: a37754536125d75a5c55f6af3b5521f89839e797ad1bffb69e3d313939cb7b65 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 6bcca1a025e4478ae330d3664dd2b9cff55f4bec82065ab2afb5bfb92031f7dde3264657dd892fe844396990117048b19247b0ef7423139f89d4cbf46b47f828main
6 changed files with 806 additions and 570 deletions
@ -0,0 +1,49 @@ |
|||||||
|
/** |
||||||
|
* Discussion utilities |
||||||
|
* Handles discussion event processing and formatting |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||||
|
import { KIND } from '$lib/types/nostr.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Format discussion timestamp |
||||||
|
*/ |
||||||
|
export function formatDiscussionTime(timestamp: number): string { |
||||||
|
const date = new Date(timestamp * 1000); |
||||||
|
const now = new Date(); |
||||||
|
const diffMs = now.getTime() - date.getTime(); |
||||||
|
const diffMins = Math.floor(diffMs / 60000); |
||||||
|
const diffHours = Math.floor(diffMs / 3600000); |
||||||
|
const diffDays = Math.floor(diffMs / 86400000); |
||||||
|
|
||||||
|
if (diffMins < 1) return 'just now'; |
||||||
|
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`; |
||||||
|
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; |
||||||
|
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; |
||||||
|
|
||||||
|
return date.toLocaleDateString(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get discussion event by ID |
||||||
|
*/ |
||||||
|
export function getDiscussionEvent(eventId: string, events: Map<string, NostrEvent>): NostrEvent | undefined { |
||||||
|
return events.get(eventId); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get referenced event from discussion |
||||||
|
*/ |
||||||
|
export function getReferencedEventFromDiscussion( |
||||||
|
event: NostrEvent, |
||||||
|
events: Map<string, NostrEvent> |
||||||
|
): NostrEvent | undefined { |
||||||
|
// Check for 'e' tags (event references)
|
||||||
|
const eTags = event.tags.filter(t => t[0] === 'e' && t[1]); |
||||||
|
if (eTags.length > 0) { |
||||||
|
const referencedId = eTags[0][1] as string; |
||||||
|
return events.get(referencedId); |
||||||
|
} |
||||||
|
return undefined; |
||||||
|
} |
||||||
@ -0,0 +1,353 @@ |
|||||||
|
/** |
||||||
|
* File processing utilities |
||||||
|
* Handles syntax highlighting, HTML rendering, and file type detection |
||||||
|
*/ |
||||||
|
|
||||||
|
// Note: highlight.js, marked, and asciidoctor are imported dynamically in functions
|
||||||
|
|
||||||
|
/** |
||||||
|
* Get highlight.js language from file extension |
||||||
|
*/ |
||||||
|
export function getHighlightLanguage(ext: string): string { |
||||||
|
const langMap: Record<string, string> = { |
||||||
|
'js': 'javascript', |
||||||
|
'jsx': 'javascript', |
||||||
|
'ts': 'typescript', |
||||||
|
'tsx': 'typescript', |
||||||
|
'py': 'python', |
||||||
|
'rb': 'ruby', |
||||||
|
'go': 'go', |
||||||
|
'rs': 'rust', |
||||||
|
'java': 'java', |
||||||
|
'c': 'c', |
||||||
|
'cpp': 'cpp', |
||||||
|
'cc': 'cpp', |
||||||
|
'cxx': 'cpp', |
||||||
|
'h': 'c', |
||||||
|
'hpp': 'cpp', |
||||||
|
'hxx': 'cpp', |
||||||
|
'cs': 'csharp', |
||||||
|
'php': 'php', |
||||||
|
'swift': 'swift', |
||||||
|
'kt': 'kotlin', |
||||||
|
'scala': 'scala', |
||||||
|
'clj': 'clojure', |
||||||
|
'sh': 'bash', |
||||||
|
'bash': 'bash', |
||||||
|
'zsh': 'bash', |
||||||
|
'fish': 'bash', |
||||||
|
'ps1': 'powershell', |
||||||
|
'sql': 'sql', |
||||||
|
'html': 'html', |
||||||
|
'htm': 'html', |
||||||
|
'xml': 'xml', |
||||||
|
'css': 'css', |
||||||
|
'scss': 'scss', |
||||||
|
'sass': 'sass', |
||||||
|
'less': 'less', |
||||||
|
'json': 'json', |
||||||
|
'yaml': 'yaml', |
||||||
|
'yml': 'yaml', |
||||||
|
'toml': 'toml', |
||||||
|
'ini': 'ini', |
||||||
|
'conf': 'ini', |
||||||
|
'dockerfile': 'dockerfile', |
||||||
|
'makefile': 'makefile', |
||||||
|
'mk': 'makefile', |
||||||
|
'cmake': 'cmake', |
||||||
|
'r': 'r', |
||||||
|
'R': 'r', |
||||||
|
'm': 'objectivec', |
||||||
|
'mm': 'objectivec', |
||||||
|
'vue': 'xml', |
||||||
|
'svelte': 'xml', |
||||||
|
'graphql': 'graphql', |
||||||
|
'gql': 'graphql', |
||||||
|
'proto': 'protobuf', |
||||||
|
'md': 'markdown', |
||||||
|
'markdown': 'markdown', |
||||||
|
'adoc': 'asciidoc', |
||||||
|
'asciidoc': 'asciidoc', |
||||||
|
'rst': 'restructuredtext', |
||||||
|
'org': 'org', |
||||||
|
'vim': 'vim', |
||||||
|
'lua': 'lua', |
||||||
|
'pl': 'perl', |
||||||
|
'pm': 'perl', |
||||||
|
'tcl': 'tcl', |
||||||
|
'dart': 'dart', |
||||||
|
'elm': 'elm', |
||||||
|
'ex': 'elixir', |
||||||
|
'exs': 'elixir', |
||||||
|
'erl': 'erlang', |
||||||
|
'hrl': 'erlang', |
||||||
|
'fs': 'fsharp', |
||||||
|
'fsx': 'fsharp', |
||||||
|
'fsi': 'fsharp', |
||||||
|
'ml': 'ocaml', |
||||||
|
'mli': 'ocaml', |
||||||
|
'hs': 'haskell', |
||||||
|
'lhs': 'haskell', |
||||||
|
'nim': 'nim', |
||||||
|
'zig': 'zig', |
||||||
|
'cr': 'crystal', |
||||||
|
'jl': 'julia', |
||||||
|
'matlab': 'matlab', |
||||||
|
'tex': 'latex', |
||||||
|
'latex': 'latex', |
||||||
|
'bib': 'bibtex', |
||||||
|
'log': 'plaintext', |
||||||
|
'txt': 'plaintext', |
||||||
|
'diff': 'diff', |
||||||
|
'patch': 'diff' |
||||||
|
}; |
||||||
|
|
||||||
|
return langMap[ext.toLowerCase()] || 'plaintext'; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if file extension supports HTML preview |
||||||
|
*/ |
||||||
|
export function supportsPreview(ext: string): boolean { |
||||||
|
const previewExtensions = ['md', 'markdown', 'adoc', 'asciidoc', 'html', 'htm', 'csv']; |
||||||
|
return previewExtensions.includes(ext.toLowerCase()); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if file extension is an image type |
||||||
|
*/ |
||||||
|
export function isImageFileType(ext: string): boolean { |
||||||
|
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'tif', 'avif', 'heic', 'heif']; |
||||||
|
return imageExtensions.includes(ext.toLowerCase()); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Rewrite image paths in HTML to be relative to file path |
||||||
|
*/ |
||||||
|
export function rewriteImagePaths(html: string, filePath: string | null): string { |
||||||
|
if (!filePath || !html) return html; |
||||||
|
|
||||||
|
// Get directory path (remove filename)
|
||||||
|
const dirPath = filePath.split('/').slice(0, -1).join('/'); |
||||||
|
const basePath = dirPath ? `/${dirPath}/` : '/'; |
||||||
|
|
||||||
|
// Rewrite relative image paths
|
||||||
|
// Match: src="image.png" or src='image.png' or src=image.png
|
||||||
|
html = html.replace(/src=["']([^"']+)["']/g, (match, path) => { |
||||||
|
// Skip absolute URLs and data URLs
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('data:') || path.startsWith('/')) { |
||||||
|
return match; |
||||||
|
} |
||||||
|
// Make path relative to file directory
|
||||||
|
return `src="${basePath}${path}"`; |
||||||
|
}); |
||||||
|
|
||||||
|
return html; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Render CSV content as HTML table |
||||||
|
*/ |
||||||
|
export function renderCsvAsTable(csvContent: string): string { |
||||||
|
const lines = csvContent.split('\n').filter(line => line.trim()); |
||||||
|
if (lines.length === 0) return '<p>Empty CSV file</p>'; |
||||||
|
|
||||||
|
// Parse CSV (simple parser - handles basic cases)
|
||||||
|
const rows: string[][] = []; |
||||||
|
for (const line of lines) { |
||||||
|
const cells: string[] = []; |
||||||
|
let currentCell = ''; |
||||||
|
let inQuotes = false; |
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) { |
||||||
|
const char = line[i]; |
||||||
|
if (char === '"') { |
||||||
|
inQuotes = !inQuotes; |
||||||
|
} else if (char === ',' && !inQuotes) { |
||||||
|
cells.push(currentCell.trim()); |
||||||
|
currentCell = ''; |
||||||
|
} else { |
||||||
|
currentCell += char; |
||||||
|
} |
||||||
|
} |
||||||
|
cells.push(currentCell.trim()); |
||||||
|
rows.push(cells); |
||||||
|
} |
||||||
|
|
||||||
|
if (rows.length === 0) return '<p>No data in CSV file</p>'; |
||||||
|
|
||||||
|
// Generate HTML table
|
||||||
|
let html = '<table class="csv-table"><thead><tr>'; |
||||||
|
const headerRow = rows[0]; |
||||||
|
for (const cell of headerRow) { |
||||||
|
html += `<th>${escapeHtml(cell)}</th>`; |
||||||
|
} |
||||||
|
html += '</tr></thead><tbody>'; |
||||||
|
|
||||||
|
for (let i = 1; i < rows.length; i++) { |
||||||
|
html += '<tr>'; |
||||||
|
for (const cell of rows[i]) { |
||||||
|
html += `<td>${escapeHtml(cell)}</td>`; |
||||||
|
} |
||||||
|
html += '</tr>'; |
||||||
|
} |
||||||
|
|
||||||
|
html += '</tbody></table>'; |
||||||
|
return html; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Escape HTML special characters |
||||||
|
*/ |
||||||
|
export function escapeHtml(text: string): string { |
||||||
|
const map: Record<string, string> = { |
||||||
|
'&': '&', |
||||||
|
'<': '<', |
||||||
|
'>': '>', |
||||||
|
'"': '"', |
||||||
|
"'": ''' |
||||||
|
}; |
||||||
|
return text.replace(/[&<>"']/g, (m) => map[m]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Apply syntax highlighting to content |
||||||
|
*/ |
||||||
|
export async function applySyntaxHighlighting( |
||||||
|
content: string, |
||||||
|
ext: string, |
||||||
|
setHighlightedContent: (html: string) => void |
||||||
|
): Promise<void> { |
||||||
|
try { |
||||||
|
const hljsModule = await import('highlight.js'); |
||||||
|
const hljs = hljsModule.default || hljsModule; |
||||||
|
const lang = getHighlightLanguage(ext); |
||||||
|
|
||||||
|
// Register Markdown language if needed
|
||||||
|
if (lang === 'markdown' && !hljs.getLanguage('markdown')) { |
||||||
|
hljs.registerLanguage('markdown', function(hljs) { |
||||||
|
return { |
||||||
|
name: 'Markdown', |
||||||
|
aliases: ['md', 'mkdown', 'mkd'], |
||||||
|
contains: [ |
||||||
|
{ className: 'section', begin: /^#{1,6}\s+/, relevance: 10 }, |
||||||
|
{ className: 'strong', begin: /\*\*[^*]+\*\*/, relevance: 0 }, |
||||||
|
{ className: 'strong', begin: /__[^_]+__/, relevance: 0 }, |
||||||
|
{ className: 'emphasis', begin: /\*[^*]+\*/, relevance: 0 }, |
||||||
|
{ className: 'emphasis', begin: /_[^_]+_/, relevance: 0 }, |
||||||
|
{ className: 'code', begin: /`[^`]+`/, relevance: 0 }, |
||||||
|
{ className: 'code', begin: /^```[\w]*/, end: /^```$/, contains: [{ begin: /./ }] }, |
||||||
|
{ className: 'link', begin: /\[/, end: /\]/, contains: [{ className: 'string', begin: /\(/, end: /\)/ }] }, |
||||||
|
{ className: 'string', begin: /!\[/, end: /\]/ }, |
||||||
|
{ className: 'bullet', begin: /^(\s*)([*+-]|\d+\.)\s+/, relevance: 0 }, |
||||||
|
{ className: 'quote', begin: /^>\s+/, relevance: 0 }, |
||||||
|
{ className: 'horizontal_rule', begin: /^(\*{3,}|-{3,}|_{3,})$/, relevance: 0 } |
||||||
|
] |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Register AsciiDoc language if needed
|
||||||
|
if (lang === 'asciidoc' && !hljs.getLanguage('asciidoc')) { |
||||||
|
hljs.registerLanguage('asciidoc', function(hljs) { |
||||||
|
return { |
||||||
|
name: 'AsciiDoc', |
||||||
|
aliases: ['adoc', 'asciidoc', 'ad'], |
||||||
|
contains: [ |
||||||
|
{ className: 'section', begin: /^={1,6}\s+/, relevance: 10 }, |
||||||
|
{ className: 'strong', begin: /\*\*[^*]+\*\*/, relevance: 0 }, |
||||||
|
{ className: 'emphasis', begin: /_[^_]+_/, relevance: 0 }, |
||||||
|
{ className: 'code', begin: /`[^`]+`/, relevance: 0 }, |
||||||
|
{ className: 'code', begin: /^----+$/, end: /^----+$/, contains: [{ begin: /./ }] }, |
||||||
|
{ className: 'bullet', begin: /^(\*+|\.+|-+)\s+/, relevance: 0 }, |
||||||
|
{ className: 'link', begin: /link:/, end: /\[/, contains: [{ begin: /\[/, end: /\]/ }] }, |
||||||
|
{ className: 'comment', begin: /^\/\/.*$/, relevance: 0 }, |
||||||
|
{ className: 'attr', begin: /^:.*:$/, relevance: 0 } |
||||||
|
] |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Apply highlighting
|
||||||
|
if (lang === 'plaintext') { |
||||||
|
setHighlightedContent(`<pre><code class="hljs">${hljs.highlight(content, { language: 'plaintext' }).value}</code></pre>`); |
||||||
|
} else if (hljs.getLanguage(lang)) { |
||||||
|
setHighlightedContent(`<pre><code class="hljs language-${lang}">${hljs.highlight(content, { language: lang }).value}</code></pre>`); |
||||||
|
} else { |
||||||
|
setHighlightedContent(`<pre><code class="hljs">${hljs.highlightAuto(content).value}</code></pre>`); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('Error applying syntax highlighting:', err); |
||||||
|
setHighlightedContent(`<pre><code class="hljs">${escapeHtml(content)}</code></pre>`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Render file content as HTML |
||||||
|
*/ |
||||||
|
export async function renderFileAsHtml( |
||||||
|
content: string, |
||||||
|
ext: string, |
||||||
|
filePath: string | null, |
||||||
|
setHtml: (html: string) => void |
||||||
|
): Promise<void> { |
||||||
|
try { |
||||||
|
const lowerExt = ext.toLowerCase(); |
||||||
|
|
||||||
|
if (lowerExt === 'md' || lowerExt === 'markdown') { |
||||||
|
// Render markdown using markdown-it
|
||||||
|
const MarkdownIt = (await import('markdown-it')).default; |
||||||
|
const hljsModule = await import('highlight.js'); |
||||||
|
const hljs = hljsModule.default || hljsModule; |
||||||
|
|
||||||
|
const md = new MarkdownIt({ |
||||||
|
html: true, |
||||||
|
linkify: true, |
||||||
|
typographer: true, |
||||||
|
breaks: true, |
||||||
|
highlight: function (str: string, lang: string): string { |
||||||
|
if (lang && hljs.getLanguage(lang)) { |
||||||
|
try { |
||||||
|
return hljs.highlight(str, { language: lang }).value; |
||||||
|
} catch (__) {} |
||||||
|
} |
||||||
|
try { |
||||||
|
return hljs.highlightAuto(str).value; |
||||||
|
} catch (__) {} |
||||||
|
return ''; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
let rendered = md.render(content); |
||||||
|
rendered = rewriteImagePaths(rendered, filePath); |
||||||
|
setHtml(rendered); |
||||||
|
} else if (lowerExt === 'adoc' || lowerExt === 'asciidoc') { |
||||||
|
// Render asciidoc
|
||||||
|
const Asciidoctor = (await import('@asciidoctor/core')).default; |
||||||
|
const asciidoctor = Asciidoctor(); |
||||||
|
const converted = asciidoctor.convert(content, { |
||||||
|
safe: 'safe', |
||||||
|
attributes: { |
||||||
|
'source-highlighter': 'highlight.js' |
||||||
|
} |
||||||
|
}); |
||||||
|
let rendered = typeof converted === 'string' ? converted : String(converted); |
||||||
|
rendered = rewriteImagePaths(rendered, filePath); |
||||||
|
setHtml(rendered); |
||||||
|
} else if (lowerExt === 'html' || lowerExt === 'htm') { |
||||||
|
// HTML files - rewrite image paths
|
||||||
|
let rendered = content; |
||||||
|
rendered = rewriteImagePaths(rendered, filePath); |
||||||
|
setHtml(rendered); |
||||||
|
} else if (lowerExt === 'csv') { |
||||||
|
// Parse CSV and render as HTML table
|
||||||
|
const html = renderCsvAsTable(content); |
||||||
|
setHtml(html); |
||||||
|
} else { |
||||||
|
setHtml(''); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('Error rendering file as HTML:', err); |
||||||
|
setHtml(''); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,202 @@ |
|||||||
|
/** |
||||||
|
* Nostr link processing utilities |
||||||
|
* Handles parsing and loading of nostr: links |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||||
|
|
||||||
|
export interface ParsedNostrLink { |
||||||
|
type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; |
||||||
|
value: string; |
||||||
|
start: number; |
||||||
|
end: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse nostr: links from content |
||||||
|
*/ |
||||||
|
export function parseNostrLinks(content: string): ParsedNostrLink[] { |
||||||
|
const links: ParsedNostrLink[] = []; |
||||||
|
const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|nprofile1)[a-zA-Z0-9]+/g; |
||||||
|
let match; |
||||||
|
|
||||||
|
while ((match = nostrLinkRegex.exec(content)) !== null) { |
||||||
|
const fullMatch = match[0]; |
||||||
|
const prefix = match[1]; |
||||||
|
let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; |
||||||
|
|
||||||
|
if (prefix === 'nevent1') type = 'nevent'; |
||||||
|
else if (prefix === 'naddr1') type = 'naddr'; |
||||||
|
else if (prefix === 'note1') type = 'note1'; |
||||||
|
else if (prefix === 'npub1') type = 'npub'; |
||||||
|
else if (prefix === 'nprofile1') type = 'profile'; |
||||||
|
else continue; |
||||||
|
|
||||||
|
links.push({ |
||||||
|
type, |
||||||
|
value: fullMatch, |
||||||
|
start: match.index, |
||||||
|
end: match.index + fullMatch.length |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return links; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get event from nostr link |
||||||
|
*/ |
||||||
|
export function getEventFromNostrLink(link: string): NostrEvent | undefined { |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(link.replace('nostr:', '')); |
||||||
|
if (decoded.type === 'nevent' || decoded.type === 'note') { |
||||||
|
return decoded.data as NostrEvent; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Invalid link
|
||||||
|
} |
||||||
|
return undefined; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get pubkey from nostr link |
||||||
|
*/ |
||||||
|
export function getPubkeyFromNostrLink(link: string): string | undefined { |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(link.replace('nostr:', '')); |
||||||
|
if (decoded.type === 'npub' || decoded.type === 'nprofile') { |
||||||
|
return decoded.data as string; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Invalid link
|
||||||
|
} |
||||||
|
return undefined; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process content with nostr links, replacing them with event/profile data |
||||||
|
*/ |
||||||
|
export function processContentWithNostrLinks( |
||||||
|
content: string, |
||||||
|
events: Map<string, NostrEvent>, |
||||||
|
profiles: Map<string, any> |
||||||
|
): Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> { |
||||||
|
const links = parseNostrLinks(content); |
||||||
|
const parts: Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> = []; |
||||||
|
let lastIndex = 0; |
||||||
|
|
||||||
|
for (const link of links) { |
||||||
|
// Add text before link
|
||||||
|
if (link.start > lastIndex) { |
||||||
|
const textPart = content.slice(lastIndex, link.start); |
||||||
|
if (textPart) { |
||||||
|
parts.push({ type: 'text', value: textPart }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Add link
|
||||||
|
const event = getEventFromNostrLink(link.value); |
||||||
|
const pubkey = getPubkeyFromNostrLink(link.value); |
||||||
|
if (event) { |
||||||
|
parts.push({ type: 'event', value: link.value, event }); |
||||||
|
} else if (pubkey) { |
||||||
|
parts.push({ type: 'profile', value: link.value, pubkey }); |
||||||
|
} else { |
||||||
|
parts.push({ type: 'placeholder', value: link.value }); |
||||||
|
} |
||||||
|
|
||||||
|
lastIndex = link.end; |
||||||
|
} |
||||||
|
|
||||||
|
// Add remaining text
|
||||||
|
if (lastIndex < content.length) { |
||||||
|
const textPart = content.slice(lastIndex); |
||||||
|
if (textPart) { |
||||||
|
parts.push({ type: 'text', value: textPart }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return parts.length > 0 ? parts : [{ type: 'text', value: content }]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Load events and profiles from nostr links |
||||||
|
*/ |
||||||
|
export async function loadNostrLinks( |
||||||
|
content: string, |
||||||
|
setEvents: (events: Map<string, NostrEvent>) => void, |
||||||
|
setProfiles: (profiles: Map<string, any>) => void |
||||||
|
): Promise<void> { |
||||||
|
const links = parseNostrLinks(content); |
||||||
|
if (links.length === 0) return; |
||||||
|
|
||||||
|
const eventIds: string[] = []; |
||||||
|
const aTags: string[] = []; |
||||||
|
const npubs: string[] = []; |
||||||
|
|
||||||
|
for (const link of links) { |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(link.value.replace('nostr:', '')); |
||||||
|
if (decoded.type === 'nevent') { |
||||||
|
const data = decoded.data as { id: string; relays?: string[] }; |
||||||
|
if (data.id) eventIds.push(data.id); |
||||||
|
} else if (decoded.type === 'naddr') { |
||||||
|
const data = decoded.data as { identifier: string; pubkey: string; relays?: string[] }; |
||||||
|
if (data.identifier && data.pubkey) { |
||||||
|
aTags.push(`${data.pubkey}:${data.identifier}`); |
||||||
|
} |
||||||
|
} else if (decoded.type === 'note') { |
||||||
|
eventIds.push(decoded.data as string); |
||||||
|
} else if (decoded.type === 'npub' || decoded.type === 'nprofile') { |
||||||
|
npubs.push(decoded.data as string); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Invalid link, skip
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (eventIds.length === 0 && aTags.length === 0 && npubs.length === 0) return; |
||||||
|
|
||||||
|
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
const eventsMap = new Map<string, NostrEvent>(); |
||||||
|
const profilesMap = new Map<string, any>(); |
||||||
|
|
||||||
|
// Load events
|
||||||
|
if (eventIds.length > 0) { |
||||||
|
try { |
||||||
|
const events = await client.fetchEvents([ |
||||||
|
{ ids: eventIds, limit: eventIds.length } |
||||||
|
]); |
||||||
|
for (const event of events) { |
||||||
|
eventsMap.set(event.id, event); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.warn('Failed to load events from nostr links:', err); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Load profiles
|
||||||
|
if (npubs.length > 0) { |
||||||
|
try { |
||||||
|
const profiles = await client.fetchEvents([ |
||||||
|
{ kinds: [0], authors: npubs, limit: npubs.length } |
||||||
|
]); |
||||||
|
for (const profile of profiles) { |
||||||
|
try { |
||||||
|
const data = JSON.parse(profile.content); |
||||||
|
profilesMap.set(profile.pubkey, data); |
||||||
|
} catch { |
||||||
|
// Invalid JSON
|
||||||
|
} |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.warn('Failed to load profiles from nostr links:', err); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
setEvents(eventsMap); |
||||||
|
setProfiles(profilesMap); |
||||||
|
} |
||||||
@ -0,0 +1,164 @@ |
|||||||
|
/** |
||||||
|
* User profile utilities |
||||||
|
* Handles fetching and caching user email/name |
||||||
|
*/ |
||||||
|
|
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { settingsStore } from '$lib/services/settings-store.js'; |
||||||
|
import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||||
|
|
||||||
|
interface CachedUserData { |
||||||
|
email: string | null; |
||||||
|
name: string | null; |
||||||
|
} |
||||||
|
|
||||||
|
interface FetchingFlags { |
||||||
|
email: boolean; |
||||||
|
name: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get user email with caching |
||||||
|
*/ |
||||||
|
export async function getUserEmail( |
||||||
|
userPubkeyHex: string | null, |
||||||
|
userPubkey: string | null, |
||||||
|
cachedData: CachedUserData, |
||||||
|
fetchingFlags: FetchingFlags |
||||||
|
): Promise<string> { |
||||||
|
// Check settings store first
|
||||||
|
try { |
||||||
|
const settings = await settingsStore.getSettings(); |
||||||
|
if (settings.userEmail && settings.userEmail.trim()) { |
||||||
|
cachedData.email = settings.userEmail.trim(); |
||||||
|
return cachedData.email; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.warn('Failed to get userEmail from settings:', err); |
||||||
|
} |
||||||
|
|
||||||
|
// Return cached email if available
|
||||||
|
if (cachedData.email) { |
||||||
|
return cachedData.email; |
||||||
|
} |
||||||
|
|
||||||
|
// If no user pubkey, can't proceed
|
||||||
|
if (!userPubkeyHex) { |
||||||
|
throw new Error('User not authenticated'); |
||||||
|
} |
||||||
|
|
||||||
|
// Prevent concurrent fetches
|
||||||
|
if (fetchingFlags.email) { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 100)); |
||||||
|
if (cachedData.email) { |
||||||
|
return cachedData.email; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fetchingFlags.email = true; |
||||||
|
let prefillEmail: string; |
||||||
|
|
||||||
|
try { |
||||||
|
prefillEmail = await fetchUserEmail(userPubkeyHex, userPubkey || undefined, DEFAULT_NOSTR_RELAYS); |
||||||
|
} catch (err) { |
||||||
|
console.warn('Failed to fetch user profile for email:', err); |
||||||
|
const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown'); |
||||||
|
const shortenedNpub = npubFromPubkey.substring(0, 20); |
||||||
|
prefillEmail = `${shortenedNpub}@gitrepublic.web`; |
||||||
|
} finally { |
||||||
|
fetchingFlags.email = false; |
||||||
|
} |
||||||
|
|
||||||
|
// Prompt user for email address
|
||||||
|
const userEmail = prompt( |
||||||
|
'Please enter your email address for git commits.\n\n' + |
||||||
|
'This will be used as the author email in your commits.\n' + |
||||||
|
'You can use any email address you prefer.', |
||||||
|
prefillEmail |
||||||
|
); |
||||||
|
|
||||||
|
if (userEmail && userEmail.trim()) { |
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; |
||||||
|
if (emailRegex.test(userEmail.trim())) { |
||||||
|
cachedData.email = userEmail.trim(); |
||||||
|
settingsStore.setSetting('userEmail', cachedData.email).catch(console.error); |
||||||
|
return cachedData.email; |
||||||
|
} else { |
||||||
|
alert('Invalid email format. Using fallback email address.'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
cachedData.email = prefillEmail; |
||||||
|
return cachedData.email; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get user name with caching |
||||||
|
*/ |
||||||
|
export async function getUserName( |
||||||
|
userPubkeyHex: string | null, |
||||||
|
userPubkey: string | null, |
||||||
|
cachedData: CachedUserData, |
||||||
|
fetchingFlags: FetchingFlags |
||||||
|
): Promise<string> { |
||||||
|
// Check settings store first
|
||||||
|
try { |
||||||
|
const settings = await settingsStore.getSettings(); |
||||||
|
if (settings.userName && settings.userName.trim()) { |
||||||
|
cachedData.name = settings.userName.trim(); |
||||||
|
return cachedData.name; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.warn('Failed to getUserName from settings:', err); |
||||||
|
} |
||||||
|
|
||||||
|
// Return cached name if available
|
||||||
|
if (cachedData.name) { |
||||||
|
return cachedData.name; |
||||||
|
} |
||||||
|
|
||||||
|
// If no user pubkey, can't proceed
|
||||||
|
if (!userPubkeyHex) { |
||||||
|
throw new Error('User not authenticated'); |
||||||
|
} |
||||||
|
|
||||||
|
// Prevent concurrent fetches
|
||||||
|
if (fetchingFlags.name) { |
||||||
|
await new Promise(resolve => setTimeout(resolve, 100)); |
||||||
|
if (cachedData.name) { |
||||||
|
return cachedData.name; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fetchingFlags.name = true; |
||||||
|
let prefillName: string; |
||||||
|
|
||||||
|
try { |
||||||
|
prefillName = await fetchUserName(userPubkeyHex, userPubkey || undefined, DEFAULT_NOSTR_RELAYS); |
||||||
|
} catch (err) { |
||||||
|
console.warn('Failed to fetch user profile for name:', err); |
||||||
|
const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown'); |
||||||
|
const shortenedNpub = npubFromPubkey.substring(0, 20); |
||||||
|
prefillName = shortenedNpub; |
||||||
|
} finally { |
||||||
|
fetchingFlags.name = false; |
||||||
|
} |
||||||
|
|
||||||
|
// Prompt user for name
|
||||||
|
const userName = prompt( |
||||||
|
'Please enter your name for git commits.\n\n' + |
||||||
|
'This will be used as the author name in your commits.\n' + |
||||||
|
'You can use any name you prefer.', |
||||||
|
prefillName |
||||||
|
); |
||||||
|
|
||||||
|
if (userName && userName.trim()) { |
||||||
|
cachedData.name = userName.trim(); |
||||||
|
settingsStore.setSetting('userName', cachedData.name).catch(console.error); |
||||||
|
return cachedData.name; |
||||||
|
} |
||||||
|
|
||||||
|
cachedData.name = prefillName; |
||||||
|
return cachedData.name; |
||||||
|
} |
||||||
Loading…
Reference in new issue