diff --git a/package-lock.json b/package-lock.json index ff1de4e2..f77a5416 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "boris", - "version": "0.10.19", + "version": "0.10.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "boris", - "version": "0.10.19", + "version": "0.10.23", "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0", @@ -23,6 +23,7 @@ "applesauce-relay": "^4.0.0", "date-fns": "^4.1.0", "fast-average-color": "^9.5.0", + "fetch-opengraph": "^1.0.36", "nostr-tools": "^2.4.0", "prismjs": "^1.30.0", "react": "^18.2.0", @@ -4502,6 +4503,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", @@ -6171,6 +6181,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-opengraph": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/fetch-opengraph/-/fetch-opengraph-1.0.36.tgz", + "integrity": "sha512-w2Gs64zjL1O86E0I6E26MrxeXpTrR8Y1vWrgupmZN6NXKV8F5I3W0tlh+ZX686jZwxyilWnQjYwgnWpdETdHWw==", + "license": "MIT", + "dependencies": { + "axios": "^0.21.1", + "html-entities": "^2.3.2" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -6264,6 +6284,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -6896,6 +6936,22 @@ "he": "bin/he" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", diff --git a/package.json b/package.json index f7c364a7..04b8e1d8 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "applesauce-relay": "^4.0.0", "date-fns": "^4.1.0", "fast-average-color": "^9.5.0", + "fetch-opengraph": "^1.0.36", "nostr-tools": "^2.4.0", "prismjs": "^1.30.0", "react": "^18.2.0", diff --git a/src/components/AddBookmarkModal.tsx b/src/components/AddBookmarkModal.tsx index b5eff9c3..1c534a75 100644 --- a/src/components/AddBookmarkModal.tsx +++ b/src/components/AddBookmarkModal.tsx @@ -4,41 +4,40 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons' import IconButton from './IconButton' import { fetchReadableContent } from '../services/readerService' +import { fetch as fetchOpenGraph } from 'fetch-opengraph' interface AddBookmarkModalProps { onClose: () => void onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise } -// Helper to extract metadata from HTML -function extractMetaTag(html: string, patterns: string[]): string | null { - for (const pattern of patterns) { - const match = html.match(new RegExp(pattern, 'i')) - if (match) return match[1] - } - return null -} - -function extractTags(html: string): string[] { +// Helper to extract tags from OpenGraph data +function extractTagsFromOgData(ogData: Record): string[] { const tags: string[] = [] - // Extract keywords meta tag - const keywords = extractMetaTag(html, [ - ' k.trim().toLowerCase()) - .filter(k => k.length > 0 && k.length < 30) - .forEach(k => tags.push(k)) + // Extract keywords from OpenGraph data + if (ogData.keywords && typeof ogData.keywords === 'string') { + ogData.keywords.split(/[,;]/) + .map((k: string) => k.trim().toLowerCase()) + .filter((k: string) => k.length > 0 && k.length < 30) + .forEach((k: string) => tags.push(k)) } - // Extract article:tag (multiple possible) - const articleTagRegex = / { + if (typeof tag === 'string') { + const cleanTag = tag.trim().toLowerCase() + if (cleanTag && cleanTag.length < 30) { + tags.push(cleanTag) + } + } + }) } return Array.from(new Set(tags)).slice(0, 5) @@ -83,17 +82,34 @@ const AddBookmarkModal: React.FC = ({ onClose, onSave }) fetchTimeoutRef.current = window.setTimeout(async () => { setIsFetchingMetadata(true) try { - const content = await fetchReadableContent(normalizedUrl) - lastFetchedUrlRef.current = normalizedUrl + // Fetch both readable content and OpenGraph data in parallel + const [content, ogData] = await Promise.all([ + fetchReadableContent(normalizedUrl), + fetchOpenGraph(normalizedUrl).catch(() => null) // Don't fail if OpenGraph fetch fails + ]) + console.log('🔍 Modal fetch debug:', { + url: normalizedUrl, + hasContent: !!content, + hasOgData: !!ogData, + ogDataKeys: ogData ? Object.keys(ogData) : null + }) + + lastFetchedUrlRef.current = normalizedUrl let extractedAnything = false - // Extract title: prioritize og:title > twitter:title > - if (!title && content.html) { - const extractedTitle = extractMetaTag(content.html, [ - '<meta\\s+property=["\'"]og:title["\'"]\\s+content=["\'"]([^"\']+)["\']', - '<meta\\s+name=["\'"]twitter:title["\'"]\\s+content=["\'"]([^"\']+)["\']' - ]) || content.title + // Extract title: prioritize og:title > twitter:title > content.title + if (!title) { + let extractedTitle = null + + if (ogData) { + extractedTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title + } + + // Fallback to content.title if no OpenGraph title found + if (!extractedTitle) { + extractedTitle = content.title + } if (extractedTitle) { setTitle(extractedTitle) @@ -102,12 +118,15 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave }) } // Extract description: prioritize og:description > twitter:description > meta description - if (!description && content.html) { - const extractedDesc = extractMetaTag(content.html, [ - '<meta\\s+property=["\'"]og:description["\'"]\\s+content=["\'"]([^"\']+)["\']', - '<meta\\s+name=["\'"]twitter:description["\'"]\\s+content=["\'"]([^"\']+)["\']', - '<meta\\s+name=["\'"]description["\'"]\\s+content=["\'"]([^"\']+)["\']' - ]) + if (!description && ogData) { + const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description + + console.log('🔍 Description extraction debug:', { + currentDescription: description, + hasOgData: !!ogData, + extractedDesc: extractedDesc, + willSetDescription: !!extractedDesc + }) if (extractedDesc) { setDescription(extractedDesc) @@ -116,8 +135,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave }) } // Extract tags from keywords and article:tag (only if user hasn't modified tags) - if (!tagsInput && content.html) { - const extractedTags = extractTags(content.html) + if (!tagsInput && ogData) { + const extractedTags = extractTagsFromOgData(ogData) // Only add boris tag if we extracted something if (extractedAnything || extractedTags.length > 0) { diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index d5de67a0..5833ac66 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -14,6 +14,7 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI' import { useRelayStatus } from '../hooks/useRelayStatus' import { useOfflineSync } from '../hooks/useOfflineSync' import { useEventLoader } from '../hooks/useEventLoader' +import { useDocumentTitle } from '../hooks/useDocumentTitle' import { Bookmark } from '../types/bookmarks' import ThreePaneLayout from './ThreePaneLayout' import Explore from './Explore' @@ -58,6 +59,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ const showSupport = location.pathname === '/support' const eventId = eventIdParam + // Manage document title based on current route + const isViewingContent = !!(naddr || externalUrl || eventId) + useDocumentTitle({ + title: isViewingContent ? undefined : 'Boris - Read, Highlight, Explore' + }) + // Extract tab from explore routes const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights' diff --git a/src/components/Me.tsx b/src/components/Me.tsx index b03a2fa2..bd80894a 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -280,8 +280,8 @@ const Me: React.FC<MeProps> = ({ try { if (!hasBeenLoaded) setLoading(true) - // Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx) - const initialLinks = deriveLinksFromBookmarks(bookmarks) + // Derive links from bookmarks with OpenGraph enhancement + const initialLinks = await deriveLinksFromBookmarks(bookmarks) const initialMap = new Map(initialLinks.map(item => [item.id, item])) setLinksMap(initialMap) setLinks(initialLinks) diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 5f785a6c..252e9df2 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, Dispatch, SetStateAction } from 'react' +import { useEffect, useRef, useState, Dispatch, SetStateAction } from 'react' import { useLocation } from 'react-router-dom' import { RelayPool } from 'applesauce-relay' import type { IEventStore } from 'applesauce-core' @@ -12,6 +12,7 @@ import { ReadableContent } from '../services/readerService' import { Highlight } from '../types/highlights' import { NostrEvent } from 'nostr-tools' import { UserSettings } from '../services/settingsService' +import { useDocumentTitle } from './useDocumentTitle' interface PreviewData { title: string @@ -64,6 +65,10 @@ export function useArticleLoader({ // Extract preview data from navigation state (from blog post cards) const previewData = (location.state as { previewData?: PreviewData })?.previewData + // Track the current article title for document title + const [currentTitle, setCurrentTitle] = useState<string | undefined>() + useDocumentTitle({ title: currentTitle }) + useEffect(() => { mountedRef.current = true @@ -82,6 +87,7 @@ export function useArticleLoader({ // If we have preview data from navigation, show it immediately (no skeleton!) if (previewData) { + setCurrentTitle(previewData.title) setReaderContent({ title: previewData.title, markdown: '', // Will be loaded from store or relay @@ -121,6 +127,7 @@ export function useArticleLoader({ latestEvent = storedEvent as NostrEvent firstEmitted = true const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article' + setCurrentTitle(title) const image = Helpers.getArticleImage(storedEvent) const summary = Helpers.getArticleSummary(storedEvent) const published = Helpers.getArticlePublished(storedEvent) @@ -167,6 +174,7 @@ export function useArticleLoader({ if (!firstEmitted) { firstEmitted = true const title = Helpers.getArticleTitle(evt) || 'Untitled Article' + setCurrentTitle(title) const image = Helpers.getArticleImage(evt) const summary = Helpers.getArticleSummary(evt) const published = Helpers.getArticlePublished(evt) @@ -194,6 +202,7 @@ export function useArticleLoader({ const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent if (finalEvent) { const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article' + setCurrentTitle(title) const image = Helpers.getArticleImage(finalEvent) const summary = Helpers.getArticleSummary(finalEvent) const published = Helpers.getArticlePublished(finalEvent) @@ -215,6 +224,7 @@ export function useArticleLoader({ // As a last resort, fall back to the legacy helper (which includes cache) const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current) if (!mountedRef.current || currentRequestIdRef.current !== requestId) return + setCurrentTitle(article.title) setReaderContent({ title: article.title, markdown: article.markdown, diff --git a/src/hooks/useDocumentTitle.ts b/src/hooks/useDocumentTitle.ts new file mode 100644 index 00000000..204ce207 --- /dev/null +++ b/src/hooks/useDocumentTitle.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef } from 'react' + +const DEFAULT_TITLE = 'Boris - Read, Highlight, Explore' + +interface UseDocumentTitleProps { + title?: string + fallback?: string +} + +export function useDocumentTitle({ title, fallback }: UseDocumentTitleProps) { + const originalTitleRef = useRef<string>(document.title) + + useEffect(() => { + // Store the original title on first mount + if (originalTitleRef.current === DEFAULT_TITLE) { + originalTitleRef.current = document.title + } + + // Set the new title if provided, otherwise use fallback or default + const newTitle = title || fallback || DEFAULT_TITLE + document.title = newTitle + + // Cleanup: restore original title when component unmounts + return () => { + document.title = originalTitleRef.current + } + }, [title, fallback]) + + // Return a function to manually reset to default + const resetTitle = () => { + document.title = DEFAULT_TITLE + } + + return { resetTitle } +} diff --git a/src/hooks/useEventLoader.ts b/src/hooks/useEventLoader.ts index c2cea86e..ecd70ee4 100644 --- a/src/hooks/useEventLoader.ts +++ b/src/hooks/useEventLoader.ts @@ -1,10 +1,11 @@ -import { useEffect, useCallback } from 'react' +import { useEffect, useCallback, useState } from 'react' import { RelayPool } from 'applesauce-relay' import { IEventStore } from 'applesauce-core' import { NostrEvent } from 'nostr-tools' import { ReadableContent } from '../services/readerService' import { eventManager } from '../services/eventManager' import { fetchProfiles } from '../services/profileService' +import { useDocumentTitle } from './useDocumentTitle' interface UseEventLoaderProps { eventId?: string @@ -25,6 +26,9 @@ export function useEventLoader({ setReaderLoading, setIsCollapsed }: UseEventLoaderProps) { + // Track the current event title for document title + const [currentTitle, setCurrentTitle] = useState<string | undefined>() + useDocumentTitle({ title: currentTitle }) const displayEvent = useCallback((event: NostrEvent) => { // Escape HTML in content and convert newlines to breaks for plain text display const escapedContent = event.content @@ -46,6 +50,7 @@ export function useEventLoader({ title, published: event.created_at } + setCurrentTitle(title) setReaderContent(baseContent) // Background: resolve author profile for kind:1 and update title @@ -80,7 +85,9 @@ export function useEventLoader({ } if (resolved) { - setReaderContent({ ...baseContent, title: `Note by @${resolved}` }) + const updatedTitle = `Note by @${resolved}` + setCurrentTitle(updatedTitle) + setReaderContent({ ...baseContent, title: updatedTitle }) } } catch { // ignore profile failures; keep fallback title @@ -119,6 +126,7 @@ export function useEventLoader({ html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`, title: 'Error' } + setCurrentTitle('Error') setReaderContent(errorContent) setReaderLoading(false) } diff --git a/src/hooks/useExternalUrlLoader.ts b/src/hooks/useExternalUrlLoader.ts index a29ab79b..ad4b18d2 100644 --- a/src/hooks/useExternalUrlLoader.ts +++ b/src/hooks/useExternalUrlLoader.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useMemo } from 'react' +import { useEffect, useRef, useMemo, useState } from 'react' import { RelayPool } from 'applesauce-relay' import { IEventStore } from 'applesauce-core' import { fetchReadableContent, ReadableContent } from '../services/readerService' @@ -7,6 +7,7 @@ import { Highlight } from '../types/highlights' import { useStoreTimeline } from './useStoreTimeline' import { eventToHighlight } from '../services/highlightEventProcessor' import { KINDS } from '../config/kinds' +import { useDocumentTitle } from './useDocumentTitle' // Helper to extract filename from URL function getFilenameFromUrl(url: string): string { @@ -52,6 +53,10 @@ export function useExternalUrlLoader({ // Track in-flight request to prevent stale updates when switching quickly const currentRequestIdRef = useRef(0) + // Track the current content title for document title + const [currentTitle, setCurrentTitle] = useState<string | undefined>() + useDocumentTitle({ title: currentTitle }) + // Load cached URL-specific highlights from event store const urlFilter = useMemo(() => { if (!url) return null @@ -88,6 +93,7 @@ export function useExternalUrlLoader({ if (!mountedRef.current) return if (currentRequestIdRef.current !== requestId) return + setCurrentTitle(content.title) setReaderContent(content) setReaderLoading(false) diff --git a/src/services/opengraphEnhancer.ts b/src/services/opengraphEnhancer.ts new file mode 100644 index 00000000..26d96d7b --- /dev/null +++ b/src/services/opengraphEnhancer.ts @@ -0,0 +1,114 @@ +import { fetch as fetchOpenGraph } from 'fetch-opengraph' +import { ReadItem } from './readsService' + +// Cache for OpenGraph data to avoid repeated requests +const ogCache = new Map<string, Record<string, unknown>>() + +function getCachedOgData(url: string): Record<string, unknown> | null { + const cached = ogCache.get(url) + if (!cached) return null + + return cached +} + +function setCachedOgData(url: string, data: Record<string, unknown>): void { + ogCache.set(url, data) +} + +/** + * Enhances a ReadItem with OpenGraph data + * Only fetches if the item doesn't already have good metadata + */ +export async function enhanceReadItemWithOpenGraph(item: ReadItem): Promise<ReadItem> { + // Skip if we already have good metadata + if (item.title && item.title !== fallbackTitleFromUrl(item.url || '') && item.image) { + return item + } + + if (!item.url) return item + + try { + // Check cache first + let ogData = getCachedOgData(item.url) + + if (!ogData) { + // Fetch OpenGraph data + const fetchedOgData = await fetchOpenGraph(item.url) + if (fetchedOgData) { + ogData = fetchedOgData + setCachedOgData(item.url, fetchedOgData) + } + } + + if (!ogData) return item + + // Enhance the item with OpenGraph data + const enhanced: ReadItem = { ...item } + + // Use OpenGraph title if we don't have a good title + if (!enhanced.title || enhanced.title === fallbackTitleFromUrl(item.url)) { + const ogTitle = ogData['og:title'] || ogData['twitter:title'] || ogData.title + if (typeof ogTitle === 'string') { + enhanced.title = ogTitle + } + } + + // Use OpenGraph description if we don't have a summary + if (!enhanced.summary) { + const ogDescription = ogData['og:description'] || ogData['twitter:description'] || ogData.description + if (typeof ogDescription === 'string') { + enhanced.summary = ogDescription + } + } + + // Use OpenGraph image if we don't have an image + if (!enhanced.image) { + const ogImage = ogData['og:image'] || ogData['twitter:image'] || ogData.image + if (typeof ogImage === 'string') { + enhanced.image = ogImage + } + } + + return enhanced + } catch (error) { + console.warn('Failed to enhance ReadItem with OpenGraph data:', error) + return item + } +} + +/** + * Enhances multiple ReadItems with OpenGraph data in parallel + * Uses batching to avoid overwhelming the service + */ +export async function enhanceReadItemsWithOpenGraph(items: ReadItem[]): Promise<ReadItem[]> { + const BATCH_SIZE = 5 + const BATCH_DELAY = 1000 // 1 second between batches + + const enhancedItems: ReadItem[] = [] + + for (let i = 0; i < items.length; i += BATCH_SIZE) { + const batch = items.slice(i, i + BATCH_SIZE) + + // Process batch in parallel + const batchPromises = batch.map(item => enhanceReadItemWithOpenGraph(item)) + const batchResults = await Promise.all(batchPromises) + enhancedItems.push(...batchResults) + + // Add delay between batches to be respectful to the service + if (i + BATCH_SIZE < items.length) { + await new Promise(resolve => setTimeout(resolve, BATCH_DELAY)) + } + } + + return enhancedItems +} + +// Helper function to generate fallback title from URL +function fallbackTitleFromUrl(url: string): string { + try { + const urlObj = new URL(url) + return urlObj.hostname.replace('www.', '') + } catch { + return url + } +} diff --git a/src/services/readerService.ts b/src/services/readerService.ts index 04e4ca11..4d8d043f 100644 --- a/src/services/readerService.ts +++ b/src/services/readerService.ts @@ -110,3 +110,4 @@ export async function fetchReadableContent( } + diff --git a/src/utils/linksFromBookmarks.ts b/src/utils/linksFromBookmarks.ts index ec83c5e4..e7227bfe 100644 --- a/src/utils/linksFromBookmarks.ts +++ b/src/utils/linksFromBookmarks.ts @@ -2,13 +2,14 @@ import { Bookmark } from '../types/bookmarks' import { ReadItem } from '../services/readsService' import { KINDS } from '../config/kinds' import { fallbackTitleFromUrl } from './readItemMerge' +import { enhanceReadItemsWithOpenGraph } from '../services/opengraphEnhancer' /** * Derives ReadItems from bookmarks for external URLs: * - Web bookmarks (kind:39701) * - Any bookmark with http(s) URLs in content or urlReferences */ -export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { +export async function deriveLinksFromBookmarks(bookmarks: Bookmark[]): Promise<ReadItem[]> { const linksMap = new Map<string, ReadItem>() const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) @@ -59,11 +60,14 @@ export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { } } - // Sort by most recent bookmark activity - return Array.from(linksMap.values()).sort((a, b) => { + // Get initial items sorted by most recent bookmark activity + const initialItems = Array.from(linksMap.values()).sort((a, b) => { const timeA = a.readingTimestamp || 0 const timeB = b.readingTimestamp || 0 return timeB - timeA }) + + // Enhance with OpenGraph data + return await enhanceReadItemsWithOpenGraph(initialItems) }