Browse Source

bug-fixes

implement polling
master
Silberengel 1 month ago
parent
commit
07ee8f33d2
  1. 65
      src/lib/components/content/MarkdownRenderer.svelte
  2. 519
      src/lib/components/content/PollCard.svelte
  3. 2
      src/lib/modules/feed/FeedPage.svelte
  4. 12
      src/lib/modules/feed/FeedPost.svelte
  5. 125
      src/lib/modules/feed/HighlightCard.svelte
  6. 28
      src/lib/services/cache/event-cache.ts

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

@ -7,8 +7,6 @@
import EmbeddedEvent from './EmbeddedEvent.svelte'; import EmbeddedEvent from './EmbeddedEvent.svelte';
import { fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js'; import { fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js';
import { renderAsciiDoc } from '../../services/content/asciidoctor-renderer.js'; import { renderAsciiDoc } from '../../services/content/asciidoctor-renderer.js';
import { fetchOpenGraph, type OpenGraphData } from '../../services/content/opengraph-fetcher.js';
import OpenGraphCard from './OpenGraphCard.svelte';
import HighlightOverlay from './HighlightOverlay.svelte'; import HighlightOverlay from './HighlightOverlay.svelte';
import { getHighlightsForEvent, findHighlightMatches, type Highlight } from '../../services/nostr/highlight-service.js'; import { getHighlightsForEvent, findHighlightMatches, type Highlight } from '../../services/nostr/highlight-service.js';
import { mountComponent } from './mount-component-action.js'; import { mountComponent } from './mount-component-action.js';
@ -22,7 +20,6 @@
let { content, event }: Props = $props(); let { content, event }: Props = $props();
let containerRef = $state<HTMLElement | null>(null); let containerRef = $state<HTMLElement | null>(null);
let emojiUrls = $state<Map<string, string>>(new Map()); let emojiUrls = $state<Map<string, string>>(new Map());
let openGraphData = $state<Map<string, OpenGraphData>>(new Map());
let highlights = $state<Highlight[]>([]); let highlights = $state<Highlight[]>([]);
let highlightMatches = $state<Array<{ start: number; end: number; highlight: Highlight }>>([]); let highlightMatches = $state<Array<{ start: number; end: number; highlight: Highlight }>>([]);
let highlightsLoaded = $state(false); let highlightsLoaded = $state(false);
@ -633,60 +630,6 @@
} }
} }
// Detect and mount OpenGraph cards for standalone URLs
async function mountOpenGraphCards() {
if (!containerRef) return;
// Find all links that look like standalone URLs (not in lists, not markdown images)
const links = containerRef.querySelectorAll('a[href^="http"]:not([data-opengraph-processed])');
for (const link of links) {
const href = link.getAttribute('href');
if (!href) continue;
// Skip if it's an image link or in a list
const parent = link.parentElement;
if (parent?.tagName === 'LI' || parent?.querySelector('img')) {
continue;
}
// Check if it's a standalone link (not part of a paragraph with other content)
const text = link.textContent?.trim() || '';
const parentText = parent?.textContent?.trim() || '';
if (parentText.length > text.length * 1.5) {
// Link is part of larger text, skip
continue;
}
link.setAttribute('data-opengraph-processed', 'true');
// Fetch OpenGraph data
try {
const ogData = await fetchOpenGraph(href);
if (ogData && (ogData.title || ogData.description || ogData.image)) {
// Create placeholder for OpenGraph card
const placeholder = document.createElement('div');
placeholder.setAttribute('data-opengraph-url', href);
placeholder.className = 'opengraph-placeholder';
// Insert card after the link
link.parentNode?.insertBefore(placeholder, link.nextSibling);
// Store data and mount component
openGraphData.set(href, ogData);
// Mount OpenGraphCard component
try {
mountComponent(placeholder, OpenGraphCard as any, { data: ogData, url: href });
} catch (error) {
console.error('Error mounting OpenGraphCard:', error);
}
}
} catch (error) {
console.debug('Error fetching OpenGraph data:', error);
}
}
}
$effect(() => { $effect(() => {
if (!containerRef || !renderedHtml) return; if (!containerRef || !renderedHtml) return;
@ -696,7 +639,6 @@
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
mountProfileBadges(); mountProfileBadges();
mountEmbeddedEvents(); mountEmbeddedEvents();
mountOpenGraphCards();
}, 150); }, 150);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
@ -712,7 +654,6 @@
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
mountProfileBadges(); mountProfileBadges();
mountEmbeddedEvents(); mountEmbeddedEvents();
mountOpenGraphCards();
}); });
observer.observe(containerRef, { observer.observe(containerRef, {
@ -873,12 +814,6 @@
margin: 1rem 0; margin: 1rem 0;
} }
/* OpenGraph card placeholders */
:global(.markdown-content .opengraph-placeholder) {
display: block;
margin: 1rem 0;
}
/* Ensure normal Unicode emojis are displayed correctly */ /* Ensure normal Unicode emojis are displayed correctly */
:global(.markdown-content) { :global(.markdown-content) {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;

519
src/lib/components/content/PollCard.svelte

@ -0,0 +1,519 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
interface Props {
pollEvent: NostrEvent; // The poll event (kind 1068)
}
let { pollEvent }: Props = $props();
interface PollOption {
id: string;
label: string;
}
interface PollVote {
pubkey: string;
optionIds: string[];
created_at: number;
}
let options = $state<PollOption[]>([]);
let pollType = $state<'singlechoice' | 'multiplechoice'>('singlechoice');
let endsAt = $state<number | null>(null);
let relayUrls = $state<string[]>([]);
let votes = $state<Map<string, PollVote>>(new Map()); // pubkey -> vote
let loading = $state(true);
let submittingVote = $state(false);
let userVote = $state<string[]>([]); // Option IDs the current user has voted for
let hasVoted = $state(false);
// Parse poll event
function parsePoll() {
// Extract options
const optionTags = pollEvent.tags.filter(t => t[0] === 'option' && t.length >= 3);
options = optionTags.map(t => ({
id: t[1],
label: t[2]
}));
// Extract polltype (default to singlechoice)
const polltypeTag = pollEvent.tags.find(t => t[0] === 'polltype');
pollType = (polltypeTag?.[1] === 'multiplechoice') ? 'multiplechoice' : 'singlechoice';
// Extract endsAt
const endsAtTag = pollEvent.tags.find(t => t[0] === 'endsAt');
if (endsAtTag?.[1]) {
endsAt = parseInt(endsAtTag[1], 10);
if (isNaN(endsAt)) endsAt = null;
}
// Extract relay URLs
relayUrls = pollEvent.tags
.filter(t => t[0] === 'relay' && t[1])
.map(t => t[1]);
}
// Check if poll has ended
let isExpired = $derived.by(() => {
if (!endsAt) return false;
return Date.now() / 1000 >= endsAt;
});
// Get vote counts per option
let voteCounts = $derived.by(() => {
const counts = new Map<string, number>();
options.forEach(opt => counts.set(opt.id, 0));
votes.forEach(vote => {
if (pollType === 'singlechoice') {
// For singlechoice, only count the first response
if (vote.optionIds.length > 0) {
const optionId = vote.optionIds[0];
counts.set(optionId, (counts.get(optionId) || 0) + 1);
}
} else {
// For multiplechoice, count all responses (deduplicated)
const uniqueOptions = new Set(vote.optionIds);
uniqueOptions.forEach(optionId => {
counts.set(optionId, (counts.get(optionId) || 0) + 1);
});
}
});
return counts;
});
// Get total vote count
let totalVotes = $derived.by(() => {
return votes.size;
});
// Get percentage for each option
function getPercentage(optionId: string): number {
if (totalVotes === 0) return 0;
const count = voteCounts.get(optionId) || 0;
return (count / totalVotes) * 100;
}
// Load poll responses
async function loadVotes() {
if (relayUrls.length === 0) {
// Use default relays if no relay URLs specified
const relays = relayManager.getFeedReadRelays();
await fetchVotesFromRelays(relays);
} else {
await fetchVotesFromRelays(relayUrls);
}
}
async function fetchVotesFromRelays(relays: string[]) {
loading = true;
try {
const now = Math.floor(Date.now() / 1000);
const until = endsAt ? Math.min(endsAt, now) : now;
const responseEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.POLL_RESPONSE], '#e': [pollEvent.id], until, limit: 1000 }],
relays,
{ useCache: true, cacheResults: true }
);
// One vote per pubkey - keep only the newest vote (highest created_at)
// Sort by created_at descending to process newest first
const sortedEvents = [...responseEvents].sort((a, b) => b.created_at - a.created_at);
const voteMap = new Map<string, PollVote>();
sortedEvents.forEach(event => {
// Only add if we haven't seen this pubkey yet, or if this event is newer
// Since we sorted descending, the first occurrence is the newest
if (!voteMap.has(event.pubkey)) {
// Extract response option IDs
const responseTags = event.tags.filter(t => t[0] === 'response' && t[1]);
const optionIds = responseTags.map(t => t[1]);
voteMap.set(event.pubkey, {
pubkey: event.pubkey,
optionIds,
created_at: event.created_at
});
}
});
votes = voteMap;
// Check if current user has voted
const session = sessionManager.getSession();
if (session) {
const userVoteData = votes.get(session.pubkey);
if (userVoteData) {
userVote = userVoteData.optionIds;
hasVoted = true;
}
}
} catch (error) {
console.error('Error loading poll votes:', error);
} finally {
loading = false;
}
}
// Submit vote
async function submitVote(optionId: string) {
const session = sessionManager.getSession();
if (!session) {
alert('Please log in to vote');
return;
}
if (isExpired) {
alert('This poll has ended');
return;
}
if (submittingVote) {
return; // Prevent double-submission
}
if (pollType === 'singlechoice') {
// Single choice: replace any existing vote
userVote = [optionId];
} else {
// Multiple choice: toggle option
if (userVote.includes(optionId)) {
userVote = userVote.filter(id => id !== optionId);
} else {
userVote = [...userVote, optionId];
}
}
// Create response event
const responseTags = [
['e', pollEvent.id],
...userVote.map(id => ['response', id] as [string, string])
];
submittingVote = true;
try {
const result = await signAndPublish({
kind: KIND.POLL_RESPONSE,
content: '',
tags: responseTags,
created_at: Math.floor(Date.now() / 1000),
pubkey: session.pubkey
}, relayUrls.length > 0 ? relayUrls : undefined);
if (result.success.length > 0) {
// Reload votes after successful publish
await loadVotes();
hasVoted = true;
} else {
// Revert userVote on failure
if (pollType === 'singlechoice') {
userVote = [];
} else {
// For multiple choice, we need to check what the previous state was
// For now, just keep the current state
}
alert('Failed to submit vote. Please try again.');
}
} catch (error) {
console.error('Error submitting vote:', error);
// Revert userVote on error
if (pollType === 'singlechoice') {
userVote = [];
}
alert('Failed to submit vote. Please try again.');
} finally {
submittingVote = false;
}
}
onMount(() => {
parsePoll();
loadVotes();
});
</script>
<div class="poll-card">
<div class="poll-question">
{pollEvent.content}
</div>
{#if isExpired}
<div class="poll-status">
Poll ended
</div>
{:else if submittingVote}
<div class="poll-status">
Submitting vote...
</div>
{:else if hasVoted}
<div class="poll-status">
You have already voted
</div>
{/if}
<div class="poll-options">
{#each options as option (option.id)}
{@const percentage = getPercentage(option.id)}
{@const count = voteCounts.get(option.id) || 0}
{@const isSelected = userVote.includes(option.id)}
<button
class="poll-option"
class:selected={isSelected}
class:expired={isExpired}
class:voted={hasVoted}
onclick={() => !isExpired && !submittingVote && !hasVoted && submitVote(option.id)}
disabled={isExpired || submittingVote || hasVoted}
onkeydown={(e) => {
if (!isExpired && !submittingVote && !hasVoted && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
submitVote(option.id);
}
}}
>
<div class="poll-option-content">
<div class="poll-option-label">
{option.label}
</div>
<div class="poll-option-stats">
<span class="poll-percentage">{percentage.toFixed(1)}%</span>
{#if count > 0}
<span class="poll-count">({count})</span>
{/if}
</div>
</div>
<div class="poll-bar-container">
<div
class="poll-bar"
style="width: {percentage}%"
></div>
</div>
</button>
{/each}
</div>
<div class="poll-footer">
<span class="poll-vote-count">{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}</span>
{#if !isExpired && !hasVoted}
<button
class="poll-update-button"
onclick={() => loadVotes()}
disabled={loading}
>
{loading ? 'Loading...' : 'Update results'}
</button>
{/if}
</div>
</div>
<style>
.poll-card {
margin: 1rem 0;
padding: 1rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
}
:global(.dark) .poll-card {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.poll-question {
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--fog-text, #1f2937);
}
:global(.dark) .poll-question {
color: var(--fog-dark-text, #f9fafb);
}
.poll-status {
font-size: 0.875rem;
color: var(--fog-text-light, #9ca3af);
margin-bottom: 0.75rem;
font-style: italic;
}
:global(.dark) .poll-status {
color: var(--fog-dark-text-light, #6b7280);
}
.poll-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.poll-option {
position: relative;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
cursor: pointer;
transition: all 0.2s;
width: 100%;
text-align: left;
font-family: inherit;
font-size: inherit;
}
:global(.dark) .poll-option {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #4b5563);
}
.poll-option:not(.expired):hover {
background: var(--fog-border, #e5e7eb);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .poll-option:not(.expired):hover {
background: var(--fog-dark-border, #4b5563);
border-color: var(--fog-dark-accent, #94a3b8);
}
.poll-option.selected {
border-color: var(--fog-accent, #64748b);
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .poll-option.selected {
border-color: var(--fog-dark-accent, #94a3b8);
background: var(--fog-dark-highlight, #374151);
}
.poll-option:disabled {
cursor: default;
opacity: 0.7;
}
.poll-option.voted {
cursor: default;
}
.poll-option.voted:not(.selected) {
opacity: 0.8;
}
.poll-option-content {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.poll-option-label {
flex: 1;
color: var(--fog-text, #1f2937);
}
:global(.dark) .poll-option-label {
color: var(--fog-dark-text, #f9fafb);
}
.poll-option-stats {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--fog-text-light, #9ca3af);
}
:global(.dark) .poll-option-stats {
color: var(--fog-dark-text-light, #6b7280);
}
.poll-percentage {
font-weight: 600;
}
.poll-count {
font-size: 0.75rem;
}
.poll-bar-container {
height: 4px;
background: var(--fog-border, #e5e7eb);
border-radius: 2px;
overflow: hidden;
margin-top: 0.5rem;
}
:global(.dark) .poll-bar-container {
background: var(--fog-dark-border, #4b5563);
}
.poll-bar {
height: 100%;
background: var(--fog-accent, #64748b);
transition: width 0.3s ease;
}
:global(.dark) .poll-bar {
background: var(--fog-dark-accent, #94a3b8);
}
.poll-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
color: var(--fog-text-light, #9ca3af);
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .poll-footer {
color: var(--fog-dark-text-light, #6b7280);
border-top-color: var(--fog-dark-border, #4b5563);
}
.poll-vote-count {
font-weight: 500;
}
.poll-update-button {
background: transparent;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
color: var(--fog-text, #1f2937);
cursor: pointer;
transition: all 0.2s;
}
:global(.dark) .poll-update-button {
border-color: var(--fog-dark-border, #4b5563);
color: var(--fog-dark-text, #f9fafb);
}
.poll-update-button:hover:not(:disabled) {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .poll-update-button:hover:not(:disabled) {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-accent, #94a3b8);
}
.poll-update-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

2
src/lib/modules/feed/FeedPage.svelte

@ -940,7 +940,7 @@
<div class="loading-state"> <div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading feed...</p> <p class="text-fog-text dark:text-fog-dark-text">Loading feed...</p>
</div> </div>
{:else if posts.length === 0} {:else if posts.length === 0 && highlights.length === 0 && otherFeedEvents.length === 0}
<div class="empty-state"> <div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text"> <p class="text-fog-text dark:text-fog-dark-text">
{#if selectedListId} {#if selectedListId}

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

@ -6,6 +6,7 @@
import QuotedContext from '../../components/content/QuotedContext.svelte'; import QuotedContext from '../../components/content/QuotedContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import EventMenu from '../../components/EventMenu.svelte'; import EventMenu from '../../components/EventMenu.svelte';
import PollCard from '../../components/content/PollCard.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -401,6 +402,13 @@
<p class="text-sm mb-2">{getPreviewContent()}</p> <p class="text-sm mb-2">{getPreviewContent()}</p>
{#if post.kind === KIND.POLL}
{@const optionTags = post.tags.filter(t => t[0] === 'option')}
<div class="text-xs text-fog-text-light dark:text-fog-dark-text-light mb-2">
Poll: {optionTags.length} {optionTags.length === 1 ? 'option' : 'options'}
</div>
{/if}
<div class="flex items-center justify-between text-xs text-fog-text dark:text-fog-dark-text"> <div class="flex items-center justify-between text-xs text-fog-text dark:text-fog-dark-text">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if post.kind === KIND.DISCUSSION_THREAD && (upvotes > 0 || downvotes > 0)} {#if post.kind === KIND.DISCUSSION_THREAD && (upvotes > 0 || downvotes > 0)}
@ -464,7 +472,11 @@
<div class="post-content mb-2"> <div class="post-content mb-2">
<MediaAttachments event={post} /> <MediaAttachments event={post} />
{#if post.kind === KIND.POLL}
<PollCard pollEvent={post} />
{:else}
<MarkdownRenderer content={post.content} event={post} /> <MarkdownRenderer content={post.content} event={post} />
{/if}
</div> </div>
</div> </div>

125
src/lib/modules/feed/HighlightCard.svelte

@ -47,12 +47,61 @@
return null; return null;
} }
// Parse a-tag to extract kind, pubkey, and d-tag
function parseATag(): { kind: number; pubkey: string; dTag: string } | null {
const aTagValue = getSourceAddress();
if (!aTagValue) return null;
const parts = aTagValue.split(':');
if (parts.length !== 3) return null;
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const dTag = parts[2];
if (isNaN(kind) || !pubkey || !dTag) return null;
return { kind, pubkey, dTag };
}
// Extract author pubkey from p-tag
function getAuthorPubkey(): string | null {
const pTag = highlight.tags.find(t => t[0] === 'p' && t[1]);
return pTag?.[1] || null;
}
// Get source event URL (handles both e-tag and a-tag)
function getSourceEventUrl(): string | null {
if (sourceEvent) {
return `/event/${sourceEvent.id}`;
}
const sourceEventId = getSourceEventId();
if (sourceEventId) {
return `/event/${sourceEventId}`;
}
const aTagData = parseATag();
if (aTagData) {
// Use the d-tag for the replaceable route
return `/replaceable/${aTagData.dTag}`;
}
return null;
}
// Extract context tag value // Extract context tag value
function getContext(): string | null { function getContext(): string | null {
const contextTag = highlight.tags.find(t => t[0] === 'context' && t[1]); const contextTag = highlight.tags.find(t => t[0] === 'context' && t[1]);
return contextTag?.[1] || null; return contextTag?.[1] || null;
} }
// Extract URL from url or r tag
function getUrl(): string | null {
const urlTag = highlight.tags.find(t => (t[0] === 'url' || t[0] === 'r') && t[1]);
return urlTag?.[1] || null;
}
// Normalize text for matching (remove extra whitespace, normalize line breaks) // Normalize text for matching (remove extra whitespace, normalize line breaks)
function normalizeText(text: string): string { function normalizeText(text: string): string {
return text.replace(/\s+/g, ' ').trim(); return text.replace(/\s+/g, ' ').trim();
@ -132,12 +181,15 @@
}); });
async function loadSourceEvent() { async function loadSourceEvent() {
const sourceEventId = getSourceEventId(); if (loadingSource) return;
if (!sourceEventId || loadingSource) return;
loadingSource = true; loadingSource = true;
try { try {
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
// Try e-tag first
const sourceEventId = getSourceEventId();
if (sourceEventId) {
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ ids: [sourceEventId], limit: 1 }], [{ ids: [sourceEventId], limit: 1 }],
relays, relays,
@ -146,6 +198,25 @@
if (events.length > 0) { if (events.length > 0) {
sourceEvent = events[0]; sourceEvent = events[0];
loadingSource = false;
return;
}
}
// Try a-tag (replaceable event)
const aTagData = parseATag();
if (aTagData) {
const events = await nostrClient.fetchEvents(
[{ kinds: [aTagData.kind], authors: [aTagData.pubkey], '#d': [aTagData.dTag], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
// For replaceable events, get the most recent one
if (events.length > 0) {
const sorted = events.sort((a, b) => b.created_at - a.created_at);
sourceEvent = sorted[0];
}
} }
} catch (error) { } catch (error) {
console.error('Error loading source event:', error); console.error('Error loading source event:', error);
@ -186,11 +257,11 @@
} }
// Open source event if available // Open source event if available
const sourceUrl = getSourceEventUrl();
if (onOpenEvent && sourceEvent) { if (onOpenEvent && sourceEvent) {
onOpenEvent(sourceEvent); onOpenEvent(sourceEvent);
} else if (sourceEvent) { } else if (sourceUrl) {
// Navigate to source event page window.location.href = sourceUrl;
window.location.href = `/event/${sourceEvent.id}`;
} }
} }
@ -212,10 +283,11 @@
e.preventDefault(); e.preventDefault();
const sourceUrl = getSourceEventUrl();
if (onOpenEvent && sourceEvent) { if (onOpenEvent && sourceEvent) {
onOpenEvent(sourceEvent); onOpenEvent(sourceEvent);
} else if (sourceEvent) { } else if (sourceUrl) {
window.location.href = `/event/${sourceEvent.id}`; window.location.href = sourceUrl;
} }
} }
</script> </script>
@ -226,13 +298,16 @@
id="highlight-{highlight.id}" id="highlight-{highlight.id}"
onclick={handleCardClick} onclick={handleCardClick}
onkeydown={handleCardKeydown} onkeydown={handleCardKeydown}
class:cursor-pointer={!!sourceEvent} class:cursor-pointer={!!(sourceEvent || parseATag())}
{...(sourceEvent ? { role: "button", tabindex: 0 } : {})} {...((sourceEvent || parseATag()) ? { role: "button", tabindex: 0 } : {})}
> >
<div class="highlight-header"> <div class="highlight-header">
<div class="highlight-badge">✨ Highlight</div>
<div class="highlight-meta"> <div class="highlight-meta">
<ProfileBadge pubkey={highlight.pubkey} /> <ProfileBadge pubkey={highlight.pubkey} />
{#if getAuthorPubkey() && getAuthorPubkey() !== highlight.pubkey}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">highlighting</span>
<ProfileBadge pubkey={getAuthorPubkey()!} />
{/if}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{getRelativeTime()}</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{getRelativeTime()}</span>
{#if getClientName()} {#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span>
@ -252,9 +327,9 @@
{/if} {/if}
</div> </div>
{#if sourceEvent} {#if getSourceEventUrl()}
<div class="source-event-link"> <div class="source-event-link">
<a href="/event/{sourceEvent.id}" class="source-link" onclick={(e) => e.stopPropagation()}> <a href={getSourceEventUrl()!} class="source-link" onclick={(e) => e.stopPropagation()}>
View source event → View source event →
</a> </a>
</div> </div>
@ -262,10 +337,12 @@
<div class="source-event-link"> <div class="source-event-link">
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">Loading source event...</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">Loading source event...</span>
</div> </div>
{:else if getSourceEventId()} {/if}
{#if getUrl()}
<div class="source-event-link"> <div class="source-event-link">
<a href="/event/{getSourceEventId()}" class="source-link" onclick={(e) => e.stopPropagation()}> <a href={getUrl()} target="_blank" rel="noopener noreferrer" class="source-link" onclick={(e) => e.stopPropagation()}>
View source event → {getUrl()}
</a> </a>
</div> </div>
{/if} {/if}
@ -284,35 +361,17 @@
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; border-radius: 0.25rem;
position: relative; position: relative;
border-left: 4px solid #fbbf24; /* Yellow accent for highlights */
} }
:global(.dark) .highlight-card { :global(.dark) .highlight-card {
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
border-left-color: #fbbf24;
} }
.highlight-header { .highlight-header {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.highlight-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: #fef3c7;
color: #92400e;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
:global(.dark) .highlight-badge {
background: #78350f;
color: #fef3c7;
}
.highlight-meta { .highlight-meta {
display: flex; display: flex;
align-items: center; align-items: center;

28
src/lib/services/cache/event-cache.ts vendored

@ -92,18 +92,16 @@ export async function getEventsByKind(kind: number, limit?: number): Promise<Cac
const db = await getDB(); const db = await getDB();
const tx = db.transaction('events', 'readonly'); const tx = db.transaction('events', 'readonly');
const index = tx.store.index('kind'); const index = tx.store.index('kind');
const events: CachedEvent[] = [];
let count = 0;
for await (const cursor of index.iterate(kind)) { // Use getAll() to get all matching events in one operation
if (limit && count >= limit) break; // This keeps the transaction active and avoids cursor iteration issues
events.push(cursor.value); const events = await index.getAll(kind);
count++;
}
await tx.done; await tx.done;
return events.sort((a, b) => b.created_at - a.created_at); // Sort and limit after fetching
const sorted = events.sort((a, b) => b.created_at - a.created_at);
return limit ? sorted.slice(0, limit) : sorted;
} catch (error) { } catch (error) {
console.debug('Error getting events by kind from cache:', error); console.debug('Error getting events by kind from cache:', error);
return []; return [];
@ -118,18 +116,16 @@ export async function getEventsByPubkey(pubkey: string, limit?: number): Promise
const db = await getDB(); const db = await getDB();
const tx = db.transaction('events', 'readonly'); const tx = db.transaction('events', 'readonly');
const index = tx.store.index('pubkey'); const index = tx.store.index('pubkey');
const events: CachedEvent[] = [];
let count = 0;
for await (const cursor of index.iterate(pubkey)) { // Use getAll() to get all matching events in one operation
if (limit && count >= limit) break; // This keeps the transaction active and avoids cursor iteration issues
events.push(cursor.value); const events = await index.getAll(pubkey);
count++;
}
await tx.done; await tx.done;
return events.sort((a, b) => b.created_at - a.created_at); // Sort and limit after fetching
const sorted = events.sort((a, b) => b.created_at - a.created_at);
return limit ? sorted.slice(0, limit) : sorted;
} catch (error) { } catch (error) {
console.debug('Error getting events by pubkey from cache:', error); console.debug('Error getting events by pubkey from cache:', error);
return []; return [];

Loading…
Cancel
Save