Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zap.cooking",
"license": "MIT",
"version": "4.2.117",
"version": "4.2.119",
"private": true,
"scripts": {
"dev": "vite dev",
Expand Down
8 changes: 7 additions & 1 deletion src/components/ArticleCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@
imageError = true;
}

function handleImageLoad() {
function handleImageLoad(e: Event) {
const img = e.target as HTMLImageElement;
// Treat tiny images (likely error pages/placeholders) as broken
if (img.naturalWidth < 10 || img.naturalHeight < 10) {
imageError = true;
return;
}
imageLoaded = true;
}

Expand Down
289 changes: 56 additions & 233 deletions src/components/LongformFoodFeed.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,246 +3,76 @@
import { ndk, userPublickey, getCurrentRelayGeneration, ndkConnected } from '$lib/nostr';
import type { NDKEvent, NDKFilter, NDKSubscription } from '@nostr-dev-kit/ndk';
import { NDKRelaySet } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import ArticleFeed from './ArticleFeed.svelte';
import { RECIPE_TAGS } from '$lib/consts';
import { validateMarkdownTemplate } from '$lib/parser';
import { FOOD_LONGFORM_HASHTAGS, TOP_RELAY_FOOD_HASHTAGS } from '$lib/articleUtils';

let events: NDKEvent[] = [];
import {
TOP_RELAY_FOOD_HASHTAGS,
isValidLongformArticle,
eventToArticleData,
type ArticleData
} from '$lib/articleUtils';
import { foodArticles, addArticles } from '$lib/articleStore';

let localArticles: ArticleData[] = [];
let loading = true;
let subscription: NDKSubscription | null = null;
let seenEventIds = new Set<string>();

// Image extensions
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.svg'];
const IMAGE_URL_REGEX = /(https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|avif|svg)(\?[^\s]*)?)/gi;

// Extract image from content (featured image → first content image)
function extractImage(event: NDKEvent): string | null {
const content = event.content || '';

// Check for featured image tag
const imageTag = event.tags.find((t) => t[0] === 'image' || t[0] === 'picture');
if (imageTag && imageTag[1]) {
return imageTag[1];
}

// Extract first image URL from content
const imageMatches = content.match(IMAGE_URL_REGEX);
if (imageMatches && imageMatches.length > 0) {
return imageMatches[0];
}

// Check for nostr.news format (may have image in metadata)
const nostrNewsMatch = content.match(/https?:\/\/nostr\.news\/[^\s]+/);
if (nostrNewsMatch) {
// nostr.news articles might have images, but we'll handle them separately
return null;
}

return null;
}

// Calculate read time (average reading speed: 200 words per minute)
function calculateReadTime(content: string): number {
// Remove markdown syntax for word count
const text = content
.replace(/#+\s+/g, '') // Remove headers
.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') // Convert links to text
.replace(/!\[([^\]]*)\]\([^\)]+\)/g, '') // Remove images
.replace(/`[^`]+`/g, '') // Remove code blocks
.replace(/\*\*([^\*]+)\*\*/g, '$1') // Remove bold
.replace(/\*([^\*]+)\*/g, '$1') // Remove italic
.trim();

const words = text.split(/\s+/).filter((word) => word.length > 0);
const minutes = Math.ceil(words.length / 200);
return Math.max(1, minutes); // Minimum 1 minute
}

// Clean content for preview (remove URLs, markdown, etc.)
function cleanPreview(content: string): string {
let cleaned = content;

// Handle nostr.news format - extract the actual article content
const nostrNewsMatch = cleaned.match(/https?:\/\/nostr\.news\/[^\s]+/);
if (nostrNewsMatch) {
// Remove the nostr.news URL and any metadata before it
cleaned = cleaned.replace(/.*https?:\/\/nostr\.news\/[^\s]+\s*/i, '');
}

// Remove URLs
cleaned = cleaned.replace(/https?:\/\/[^\s]+/g, '');

// Remove markdown images
cleaned = cleaned.replace(/!\[([^\]]*)\]\([^\)]+\)/g, '');

// Remove markdown links but keep text
cleaned = cleaned.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1');

// Remove markdown headers
cleaned = cleaned.replace(/^#+\s+/gm, '');

// Remove markdown formatting
cleaned = cleaned.replace(/\*\*([^\*]+)\*\*/g, '$1');
cleaned = cleaned.replace(/\*([^\*]+)\*/g, '$1');
cleaned = cleaned.replace(/`[^`]+`/g, '');

// Get first paragraph (non-empty lines)
const lines = cleaned.split('\n').filter((line) => line.trim().length > 0);
if (lines.length > 0) {
const preview = lines[0].trim();
// Limit to ~150 characters (2-3 lines)
return preview.length > 150 ? preview.substring(0, 150).trim() + '...' : preview;
}

return cleaned.trim().substring(0, 150);
}

function getNoteUrl(event: NDKEvent): string | null {
if (!event) return null;

// For kind 30023 (longform), use naddr
if (event.kind === 30023) {
const dTag = event.tags.find((t) => t[0] === 'd')?.[1];
if (dTag && event.author?.hexpubkey) {
try {
const naddr = nip19.naddrEncode({
identifier: dTag,
kind: 30023,
pubkey: event.author.hexpubkey
});
return `/recipe/${naddr}`;
} catch (e) {
console.warn('Failed to encode naddr:', e);
}
}
}

return null;
}

function getTitle(event: NDKEvent): string {
// Try to get title from tags first
const titleTag = event.tags.find((t) => t[0] === 'title');
if (titleTag && titleTag[1]) {
return titleTag[1];
}

// Fallback: extract from content (first line or first heading)
const content = event.content || '';
const lines = content.split('\n').filter((line) => line.trim());
if (lines.length > 0) {
// Check if first line is a heading
const firstLine = lines[0].trim();
if (firstLine.startsWith('# ')) {
return firstLine.substring(2).trim();
}
// Return first line if it's short enough
if (firstLine.length < 100) {
return firstLine;
}
}

return 'Untitled Article';
}

function getTags(event: NDKEvent): string[] {
return event.tags
.filter((t) => t[0] === 't' && t[1])
.map((t) => t[1])
.filter((tag) => FOOD_LONGFORM_HASHTAGS.includes(tag.toLowerCase()));
}

// Format articles for ArticleFeed
function formatArticles(events: NDKEvent[]) {
return events.map((event) => {
const imageUrl = extractImage(event);
const title = getTitle(event);
const preview = cleanPreview(event.content || '');
const readTime = calculateReadTime(event.content || '');
const tags = getTags(event);
const articleUrl = getNoteUrl(event) || '#';

return {
event,
imageUrl,
title,
preview,
readTime,
tags,
articleUrl
};
});
}

function shouldIncludeEvent(event: NDKEvent): boolean {
// Check muted users
if ($userPublickey) {
// You can add muted user check here if needed
}

// Exclude recipes: check if event has zapcooking or nostrcooking tags
const hasRecipeTag = event.tags.some(
(tag) =>
Array.isArray(tag) &&
tag[0] === 't' &&
RECIPE_TAGS.includes(tag[1]?.toLowerCase() || '')
);

if (hasRecipeTag) {
return false; // Exclude recipes
}

// Exclude events that match recipe format (have Ingredients/Directions sections)
const content = event.content || '';
const recipeValidation = validateMarkdownTemplate(content);
// If validateMarkdownTemplate returns a MarkdownTemplate object (not a string error),
// it means it's a valid recipe format - exclude it
if (typeof recipeValidation !== 'string') {
// It's a valid recipe format (has ingredients and directions sections)
return false; // Exclude recipe format content
}

// Check for food-related hashtags
const hasFoodHashtag = event.tags.some(
(tag) =>
Array.isArray(tag) && tag[0] === 't' && FOOD_LONGFORM_HASHTAGS.includes(tag[1]?.toLowerCase() || '')
);

return hasFoodHashtag;
}
// Use shared store if it already has food articles (from reads page visit)
$: sharedFoodArticles = $foodArticles;
$: if (sharedFoodArticles.length > 0 && localArticles.length === 0) {
localArticles = sharedFoodArticles;
loading = false;
}

// Display the best source — shared store or local fetch
$: displayArticles = sharedFoodArticles.length > localArticles.length
? sharedFoodArticles
: localArticles;

// Format for ArticleFeed component
$: formattedArticles = displayArticles
.filter((a) => a.imageUrl) // Require images for explore display
.slice(0, 20)
.map((a) => ({
event: a.event,
imageUrl: a.imageUrl,
title: a.title,
preview: a.preview,
readTime: a.readTimeMinutes,
tags: a.tags,
articleUrl: a.articleUrl
}));

async function loadLongformArticles() {
const startGeneration = getCurrentRelayGeneration();

if (!$ndk || !$ndkConnected) {
console.warn('NDK not connected');
loading = false;
return;
}

// Stop existing subscription
// If shared store already has food articles, skip fetch
if ($foodArticles.length >= 6) {
loading = false;
return;
}

if (subscription) {
subscription.stop();
subscription = null;
}

loading = true;
events = [];
seenEventIds.clear();

try {
// Use the small relay-friendly tag set for the relay filter;
// full FOOD_LONGFORM_HASHTAGS list is applied client-side via shouldIncludeEvent.
const filter: NDKFilter = {
kinds: [30023],
'#t': TOP_RELAY_FOOD_HASHTAGS,
limit: 50
limit: 100,
since: Math.floor(Date.now() / 1000) - (365 * 24 * 60 * 60)
};

// Target article-optimized relays for better longform discovery
const articleRelays = [
'wss://relay.primal.net',
'wss://relay.damus.io',
Expand All @@ -252,33 +82,27 @@
];
const relaySet = NDKRelaySet.fromRelayUrls(articleRelays, $ndk, true);
subscription = $ndk.subscribe(filter, { closeOnEose: true }, relaySet);
const fetchedEvents: NDKEvent[] = [];

subscription.on('event', (event: NDKEvent) => {
// Ignore events from old relay generation
if (getCurrentRelayGeneration() !== startGeneration) {
return;
}

// Skip duplicates
if (seenEventIds.has(event.id)) {
return;
}
if (getCurrentRelayGeneration() !== startGeneration) return;
if (seenEventIds.has(event.id)) return;
seenEventIds.add(event.id);

// Filter for food-related content
if (shouldIncludeEvent(event)) {
fetchedEvents.push(event);
// Sort by created_at descending
events = [...fetchedEvents].sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
const articleData = eventToArticleData(event, true);
if (articleData && isValidLongformArticle(event)) {
localArticles = [...localArticles, articleData]
.filter((a, i, arr) => arr.findIndex((x) => x.id === a.id) === i)
.sort((a, b) => b.publishedAt - a.publishedAt);

// Also push to shared store so reads page benefits
addArticles([articleData]);
}
});

subscription.on('eose', () => {
loading = false;
});

// Timeout after 10 seconds
setTimeout(() => {
if (loading) {
loading = false;
Expand Down Expand Up @@ -307,7 +131,7 @@
</script>

<div class="flex flex-col gap-4">
{#if loading}
{#if loading && formattedArticles.length === 0}
<div class="article-feed-horizontal">
{#each Array(6) as _}
<div class="article-card-skeleton-wrapper">
Expand All @@ -326,8 +150,7 @@
</div>
{/each}
</div>
{:else if events.length > 0}
{@const formattedArticles = formatArticles(events)}
{:else if formattedArticles.length > 0}
<ArticleFeed articles={formattedArticles} />
{:else}
<div class="text-center py-8 text-caption">
Expand Down Expand Up @@ -394,4 +217,4 @@
height: 560px;
}
}
</style>
</style>
Loading
Loading