mirror of
https://github.com/dergigi/boris.git
synced 2025-12-17 14:44:26 +01:00
Merge pull request #27 from dergigi/fixes-after-midnight-as-always
feat: improve OpenGraph extraction with fetch-opengraph library
This commit is contained in:
60
package-lock.json
generated
60
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.19",
|
"version": "0.10.23",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.19",
|
"version": "0.10.23",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"applesauce-relay": "^4.0.0",
|
"applesauce-relay": "^4.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"fast-average-color": "^9.5.0",
|
"fast-average-color": "^9.5.0",
|
||||||
|
"fetch-opengraph": "^1.0.36",
|
||||||
"nostr-tools": "^2.4.0",
|
"nostr-tools": "^2.4.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -4502,6 +4503,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||||
"version": "0.4.14",
|
"version": "0.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
|
"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"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
@@ -6264,6 +6284,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -6896,6 +6936,22 @@
|
|||||||
"he": "bin/he"
|
"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": {
|
"node_modules/html-url-attributes": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"applesauce-relay": "^4.0.0",
|
"applesauce-relay": "^4.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"fast-average-color": "^9.5.0",
|
"fast-average-color": "^9.5.0",
|
||||||
|
"fetch-opengraph": "^1.0.36",
|
||||||
"nostr-tools": "^2.4.0",
|
"nostr-tools": "^2.4.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
@@ -4,41 +4,40 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|||||||
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { fetchReadableContent } from '../services/readerService'
|
import { fetchReadableContent } from '../services/readerService'
|
||||||
|
import { fetch as fetchOpenGraph } from 'fetch-opengraph'
|
||||||
|
|
||||||
interface AddBookmarkModalProps {
|
interface AddBookmarkModalProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
|
onSave: (url: string, title?: string, description?: string, tags?: string[]) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to extract metadata from HTML
|
// Helper to extract tags from OpenGraph data
|
||||||
function extractMetaTag(html: string, patterns: string[]): string | null {
|
function extractTagsFromOgData(ogData: Record<string, unknown>): string[] {
|
||||||
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[] {
|
|
||||||
const tags: string[] = []
|
const tags: string[] = []
|
||||||
|
|
||||||
// Extract keywords meta tag
|
// Extract keywords from OpenGraph data
|
||||||
const keywords = extractMetaTag(html, [
|
if (ogData.keywords && typeof ogData.keywords === 'string') {
|
||||||
'<meta\\s+name=["\'"]keywords["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
ogData.keywords.split(/[,;]/)
|
||||||
])
|
.map((k: string) => k.trim().toLowerCase())
|
||||||
if (keywords) {
|
.filter((k: string) => k.length > 0 && k.length < 30)
|
||||||
keywords.split(/[,;]/)
|
.forEach((k: string) => tags.push(k))
|
||||||
.map(k => k.trim().toLowerCase())
|
|
||||||
.filter(k => k.length > 0 && k.length < 30)
|
|
||||||
.forEach(k => tags.push(k))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract article:tag (multiple possible)
|
// Extract article:tag from OpenGraph data
|
||||||
const articleTagRegex = /<meta\s+property=["']article:tag["']\s+content=["']([^"']+)["']/gi
|
if (ogData['article:tag']) {
|
||||||
let match
|
const articleTagValue = ogData['article:tag']
|
||||||
while ((match = articleTagRegex.exec(html)) !== null) {
|
const articleTags = Array.isArray(articleTagValue)
|
||||||
const tag = match[1].trim().toLowerCase()
|
? articleTagValue
|
||||||
if (tag && tag.length < 30) tags.push(tag)
|
: [articleTagValue]
|
||||||
|
|
||||||
|
articleTags.forEach((tag: unknown) => {
|
||||||
|
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)
|
return Array.from(new Set(tags)).slice(0, 5)
|
||||||
@@ -83,17 +82,34 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
fetchTimeoutRef.current = window.setTimeout(async () => {
|
fetchTimeoutRef.current = window.setTimeout(async () => {
|
||||||
setIsFetchingMetadata(true)
|
setIsFetchingMetadata(true)
|
||||||
try {
|
try {
|
||||||
const content = await fetchReadableContent(normalizedUrl)
|
// Fetch both readable content and OpenGraph data in parallel
|
||||||
lastFetchedUrlRef.current = normalizedUrl
|
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
|
let extractedAnything = false
|
||||||
|
|
||||||
// Extract title: prioritize og:title > twitter:title > <title>
|
// Extract title: prioritize og:title > twitter:title > content.title
|
||||||
if (!title && content.html) {
|
if (!title) {
|
||||||
const extractedTitle = extractMetaTag(content.html, [
|
let extractedTitle = null
|
||||||
'<meta\\s+property=["\'"]og:title["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
|
||||||
'<meta\\s+name=["\'"]twitter:title["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
if (ogData) {
|
||||||
]) || content.title
|
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) {
|
if (extractedTitle) {
|
||||||
setTitle(extractedTitle)
|
setTitle(extractedTitle)
|
||||||
@@ -102,12 +118,15 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract description: prioritize og:description > twitter:description > meta description
|
// Extract description: prioritize og:description > twitter:description > meta description
|
||||||
if (!description && content.html) {
|
if (!description && ogData) {
|
||||||
const extractedDesc = extractMetaTag(content.html, [
|
const extractedDesc = ogData['og:description'] || ogData['twitter:description'] || ogData.description
|
||||||
'<meta\\s+property=["\'"]og:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
|
||||||
'<meta\\s+name=["\'"]twitter:description["\'"]\\s+content=["\'"]([^"\']+)["\']',
|
console.log('🔍 Description extraction debug:', {
|
||||||
'<meta\\s+name=["\'"]description["\'"]\\s+content=["\'"]([^"\']+)["\']'
|
currentDescription: description,
|
||||||
])
|
hasOgData: !!ogData,
|
||||||
|
extractedDesc: extractedDesc,
|
||||||
|
willSetDescription: !!extractedDesc
|
||||||
|
})
|
||||||
|
|
||||||
if (extractedDesc) {
|
if (extractedDesc) {
|
||||||
setDescription(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)
|
// Extract tags from keywords and article:tag (only if user hasn't modified tags)
|
||||||
if (!tagsInput && content.html) {
|
if (!tagsInput && ogData) {
|
||||||
const extractedTags = extractTags(content.html)
|
const extractedTags = extractTagsFromOgData(ogData)
|
||||||
|
|
||||||
// Only add boris tag if we extracted something
|
// Only add boris tag if we extracted something
|
||||||
if (extractedAnything || extractedTags.length > 0) {
|
if (extractedAnything || extractedTags.length > 0) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
|||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||||
import { useEventLoader } from '../hooks/useEventLoader'
|
import { useEventLoader } from '../hooks/useEventLoader'
|
||||||
|
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
@@ -58,6 +59,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
const showSupport = location.pathname === '/support'
|
const showSupport = location.pathname === '/support'
|
||||||
const eventId = eventIdParam
|
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
|
// Extract tab from explore routes
|
||||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
|
|||||||
@@ -280,8 +280,8 @@ const Me: React.FC<MeProps> = ({
|
|||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
|
||||||
// Derive links from bookmarks immediately (bookmarks come from centralized loading in App.tsx)
|
// Derive links from bookmarks with OpenGraph enhancement
|
||||||
const initialLinks = deriveLinksFromBookmarks(bookmarks)
|
const initialLinks = await deriveLinksFromBookmarks(bookmarks)
|
||||||
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
||||||
setLinksMap(initialMap)
|
setLinksMap(initialMap)
|
||||||
setLinks(initialLinks)
|
setLinks(initialLinks)
|
||||||
|
|||||||
@@ -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 { useLocation } from 'react-router-dom'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import type { IEventStore } from 'applesauce-core'
|
import type { IEventStore } from 'applesauce-core'
|
||||||
@@ -12,6 +12,7 @@ import { ReadableContent } from '../services/readerService'
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { useDocumentTitle } from './useDocumentTitle'
|
||||||
|
|
||||||
interface PreviewData {
|
interface PreviewData {
|
||||||
title: string
|
title: string
|
||||||
@@ -64,6 +65,10 @@ export function useArticleLoader({
|
|||||||
// Extract preview data from navigation state (from blog post cards)
|
// Extract preview data from navigation state (from blog post cards)
|
||||||
const previewData = (location.state as { previewData?: PreviewData })?.previewData
|
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(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true
|
mountedRef.current = true
|
||||||
|
|
||||||
@@ -82,6 +87,7 @@ export function useArticleLoader({
|
|||||||
|
|
||||||
// If we have preview data from navigation, show it immediately (no skeleton!)
|
// If we have preview data from navigation, show it immediately (no skeleton!)
|
||||||
if (previewData) {
|
if (previewData) {
|
||||||
|
setCurrentTitle(previewData.title)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: previewData.title,
|
title: previewData.title,
|
||||||
markdown: '', // Will be loaded from store or relay
|
markdown: '', // Will be loaded from store or relay
|
||||||
@@ -121,6 +127,7 @@ export function useArticleLoader({
|
|||||||
latestEvent = storedEvent as NostrEvent
|
latestEvent = storedEvent as NostrEvent
|
||||||
firstEmitted = true
|
firstEmitted = true
|
||||||
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
const title = Helpers.getArticleTitle(storedEvent) || 'Untitled Article'
|
||||||
|
setCurrentTitle(title)
|
||||||
const image = Helpers.getArticleImage(storedEvent)
|
const image = Helpers.getArticleImage(storedEvent)
|
||||||
const summary = Helpers.getArticleSummary(storedEvent)
|
const summary = Helpers.getArticleSummary(storedEvent)
|
||||||
const published = Helpers.getArticlePublished(storedEvent)
|
const published = Helpers.getArticlePublished(storedEvent)
|
||||||
@@ -167,6 +174,7 @@ export function useArticleLoader({
|
|||||||
if (!firstEmitted) {
|
if (!firstEmitted) {
|
||||||
firstEmitted = true
|
firstEmitted = true
|
||||||
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
|
const title = Helpers.getArticleTitle(evt) || 'Untitled Article'
|
||||||
|
setCurrentTitle(title)
|
||||||
const image = Helpers.getArticleImage(evt)
|
const image = Helpers.getArticleImage(evt)
|
||||||
const summary = Helpers.getArticleSummary(evt)
|
const summary = Helpers.getArticleSummary(evt)
|
||||||
const published = Helpers.getArticlePublished(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
|
const finalEvent = (events.sort((a, b) => b.created_at - a.created_at)[0]) || latestEvent
|
||||||
if (finalEvent) {
|
if (finalEvent) {
|
||||||
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
|
const title = Helpers.getArticleTitle(finalEvent) || 'Untitled Article'
|
||||||
|
setCurrentTitle(title)
|
||||||
const image = Helpers.getArticleImage(finalEvent)
|
const image = Helpers.getArticleImage(finalEvent)
|
||||||
const summary = Helpers.getArticleSummary(finalEvent)
|
const summary = Helpers.getArticleSummary(finalEvent)
|
||||||
const published = Helpers.getArticlePublished(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)
|
// As a last resort, fall back to the legacy helper (which includes cache)
|
||||||
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
|
const article = await fetchArticleByNaddr(relayPool, naddr, false, settingsRef.current)
|
||||||
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
if (!mountedRef.current || currentRequestIdRef.current !== requestId) return
|
||||||
|
setCurrentTitle(article.title)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: article.title,
|
title: article.title,
|
||||||
markdown: article.markdown,
|
markdown: article.markdown,
|
||||||
|
|||||||
35
src/hooks/useDocumentTitle.ts
Normal file
35
src/hooks/useDocumentTitle.ts
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useCallback } from 'react'
|
import { useEffect, useCallback, useState } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { ReadableContent } from '../services/readerService'
|
import { ReadableContent } from '../services/readerService'
|
||||||
import { eventManager } from '../services/eventManager'
|
import { eventManager } from '../services/eventManager'
|
||||||
import { fetchProfiles } from '../services/profileService'
|
import { fetchProfiles } from '../services/profileService'
|
||||||
|
import { useDocumentTitle } from './useDocumentTitle'
|
||||||
|
|
||||||
interface UseEventLoaderProps {
|
interface UseEventLoaderProps {
|
||||||
eventId?: string
|
eventId?: string
|
||||||
@@ -25,6 +26,9 @@ export function useEventLoader({
|
|||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
setIsCollapsed
|
setIsCollapsed
|
||||||
}: UseEventLoaderProps) {
|
}: UseEventLoaderProps) {
|
||||||
|
// Track the current event title for document title
|
||||||
|
const [currentTitle, setCurrentTitle] = useState<string | undefined>()
|
||||||
|
useDocumentTitle({ title: currentTitle })
|
||||||
const displayEvent = useCallback((event: NostrEvent) => {
|
const displayEvent = useCallback((event: NostrEvent) => {
|
||||||
// Escape HTML in content and convert newlines to breaks for plain text display
|
// Escape HTML in content and convert newlines to breaks for plain text display
|
||||||
const escapedContent = event.content
|
const escapedContent = event.content
|
||||||
@@ -46,6 +50,7 @@ export function useEventLoader({
|
|||||||
title,
|
title,
|
||||||
published: event.created_at
|
published: event.created_at
|
||||||
}
|
}
|
||||||
|
setCurrentTitle(title)
|
||||||
setReaderContent(baseContent)
|
setReaderContent(baseContent)
|
||||||
|
|
||||||
// Background: resolve author profile for kind:1 and update title
|
// Background: resolve author profile for kind:1 and update title
|
||||||
@@ -80,7 +85,9 @@ export function useEventLoader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
setReaderContent({ ...baseContent, title: `Note by @${resolved}` })
|
const updatedTitle = `Note by @${resolved}`
|
||||||
|
setCurrentTitle(updatedTitle)
|
||||||
|
setReaderContent({ ...baseContent, title: updatedTitle })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore profile failures; keep fallback title
|
// 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>`,
|
html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`,
|
||||||
title: 'Error'
|
title: 'Error'
|
||||||
}
|
}
|
||||||
|
setCurrentTitle('Error')
|
||||||
setReaderContent(errorContent)
|
setReaderContent(errorContent)
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useMemo } from 'react'
|
import { useEffect, useRef, useMemo, useState } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||||
@@ -7,6 +7,7 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { useStoreTimeline } from './useStoreTimeline'
|
import { useStoreTimeline } from './useStoreTimeline'
|
||||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { useDocumentTitle } from './useDocumentTitle'
|
||||||
|
|
||||||
// Helper to extract filename from URL
|
// Helper to extract filename from URL
|
||||||
function getFilenameFromUrl(url: string): string {
|
function getFilenameFromUrl(url: string): string {
|
||||||
@@ -52,6 +53,10 @@ export function useExternalUrlLoader({
|
|||||||
// Track in-flight request to prevent stale updates when switching quickly
|
// Track in-flight request to prevent stale updates when switching quickly
|
||||||
const currentRequestIdRef = useRef(0)
|
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
|
// Load cached URL-specific highlights from event store
|
||||||
const urlFilter = useMemo(() => {
|
const urlFilter = useMemo(() => {
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
@@ -88,6 +93,7 @@ export function useExternalUrlLoader({
|
|||||||
if (!mountedRef.current) return
|
if (!mountedRef.current) return
|
||||||
if (currentRequestIdRef.current !== requestId) return
|
if (currentRequestIdRef.current !== requestId) return
|
||||||
|
|
||||||
|
setCurrentTitle(content.title)
|
||||||
setReaderContent(content)
|
setReaderContent(content)
|
||||||
setReaderLoading(false)
|
setReaderLoading(false)
|
||||||
|
|
||||||
|
|||||||
114
src/services/opengraphEnhancer.ts
Normal file
114
src/services/opengraphEnhancer.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,3 +110,4 @@ export async function fetchReadableContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { Bookmark } from '../types/bookmarks'
|
|||||||
import { ReadItem } from '../services/readsService'
|
import { ReadItem } from '../services/readsService'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { fallbackTitleFromUrl } from './readItemMerge'
|
import { fallbackTitleFromUrl } from './readItemMerge'
|
||||||
|
import { enhanceReadItemsWithOpenGraph } from '../services/opengraphEnhancer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives ReadItems from bookmarks for external URLs:
|
* Derives ReadItems from bookmarks for external URLs:
|
||||||
* - Web bookmarks (kind:39701)
|
* - Web bookmarks (kind:39701)
|
||||||
* - Any bookmark with http(s) URLs in content or urlReferences
|
* - 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 linksMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
@@ -59,11 +60,14 @@ export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by most recent bookmark activity
|
// Get initial items sorted by most recent bookmark activity
|
||||||
return Array.from(linksMap.values()).sort((a, b) => {
|
const initialItems = Array.from(linksMap.values()).sort((a, b) => {
|
||||||
const timeA = a.readingTimestamp || 0
|
const timeA = a.readingTimestamp || 0
|
||||||
const timeB = b.readingTimestamp || 0
|
const timeB = b.readingTimestamp || 0
|
||||||
return timeB - timeA
|
return timeB - timeA
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Enhance with OpenGraph data
|
||||||
|
return await enhanceReadItemsWithOpenGraph(initialItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user