mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 20:45:01 +01:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e613bd2a2 | ||
|
|
95b882b0d1 | ||
|
|
be00f1434d | ||
|
|
568890e131 | ||
|
|
f000ac3be1 | ||
|
|
2fed1cc6e7 | ||
|
|
4bdcfcaeb4 | ||
|
|
a5494ba15c | ||
|
|
64aad42be3 | ||
|
|
3673849a9a | ||
|
|
c6795f7c18 | ||
|
|
b27f26b639 | ||
|
|
975399e293 | ||
|
|
53b8356373 | ||
|
|
8c5225b271 | ||
|
|
dfac7a5089 | ||
|
|
9fe09b813b | ||
|
|
ea30c136f2 | ||
|
|
623856ffe9 | ||
|
|
d08071def2 | ||
|
|
556e8f2f7d |
38
CHANGELOG.md
38
CHANGELOG.md
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.9.0] - 2025-01-20
|
||||
|
||||
### Added
|
||||
|
||||
- User relay list integration (NIP-65) and blocked relays (NIP-51)
|
||||
- Automatically loads user's relay list from kind 10002 events
|
||||
- Supports blocked relay filtering from kind 10006 mute lists
|
||||
- Integrates with existing relay pool for seamless user experience
|
||||
- Relay list debug section in Debug component
|
||||
- Enhanced debugging capabilities for relay list loading
|
||||
- Detailed logging for relay query diagnostics
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved relay list loading performance
|
||||
- Added streaming callback to relay list service for faster results
|
||||
- User relay list now streams into pool immediately and finalizes after blocked relays
|
||||
- Made relay list loading non-blocking in App.tsx
|
||||
- Enhanced relay URL handling
|
||||
- Normalized relay URLs to match applesauce-relay internal format
|
||||
- Removed relay.dergigi.com from default relays
|
||||
- Use user's relay list exclusively when logged in
|
||||
|
||||
### Fixed
|
||||
|
||||
- Resolved all linting issues across the codebase
|
||||
- Fixed TypeScript type issues in relayListService
|
||||
- Replaced any types with proper NostrEvent types
|
||||
- Improved type safety and code quality
|
||||
- Cleaned up temporary test relays from hardcoded list
|
||||
- Removed non-relay console.log statements and debug output
|
||||
|
||||
### Technical
|
||||
|
||||
- Enhanced relay initialization logging for better diagnostics
|
||||
- Improved error handling and timeout management for relay queries
|
||||
- Better separation of concerns between relay loading and application startup
|
||||
|
||||
## [0.8.6] - 2025-10-20
|
||||
|
||||
### Fixed
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.8.4",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.8.4",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypePrism from 'rehype-prism-plus'
|
||||
import VideoEmbedProcessor from './VideoEmbedProcessor'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||
@@ -733,11 +734,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||
components={{
|
||||
img: ({ src, alt, ...props }) => (
|
||||
img: ({ src, alt }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
@@ -843,10 +843,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<>
|
||||
{markdown ? (
|
||||
renderedMarkdownHtml && finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
<VideoEmbedProcessor
|
||||
ref={contentRef}
|
||||
html={finalHtml}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||
className="reader-markdown"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
@@ -858,10 +859,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
<VideoEmbedProcessor
|
||||
ref={contentRef}
|
||||
html={finalHtml || html || ''}
|
||||
renderVideoLinksAsEmbeds={settings?.renderVideoLinksAsEmbeds === true && !isExternalVideo}
|
||||
className="reader-html"
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
|
||||
@@ -124,7 +124,7 @@ const LoginOptions: React.FC = () => {
|
||||
<div className="login-content">
|
||||
<h2 className="login-title">Hi! I'm Boris.</h2>
|
||||
<p className="login-description">
|
||||
Connect your npub to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights</mark>.
|
||||
<mark className="login-highlight">Connect your npub</mark> to see your bookmarks, explore long-form articles, and create <mark className="login-highlight">your own highlights.</mark>
|
||||
</p>
|
||||
|
||||
<div className="login-buttons">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
@@ -57,6 +57,15 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
[pubkey]
|
||||
)
|
||||
|
||||
// Sort writings by publication date, newest first
|
||||
const sortedWritings = useMemo(() => {
|
||||
return cachedWritings.slice().sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
}, [cachedWritings])
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
@@ -168,7 +177,7 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
}
|
||||
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
const showSkeletons = cachedHighlights.length === 0 && cachedWritings.length === 0
|
||||
const showSkeletons = cachedHighlights.length === 0 && sortedWritings.length === 0
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
@@ -209,13 +218,13 @@ const Profile: React.FC<ProfileProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return cachedWritings.length === 0 ? (
|
||||
return sortedWritings.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No articles written yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{cachedWritings.map((post) => (
|
||||
{sortedWritings.map((post) => (
|
||||
<BlogPostCard
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
|
||||
@@ -6,6 +6,7 @@ import IconButton from './IconButton'
|
||||
import { loadFont } from '../utils/fontLoader'
|
||||
import ThemeSettings from './Settings/ThemeSettings'
|
||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||
import MediaDisplaySettings from './Settings/MediaDisplaySettings'
|
||||
import ExploreSettings from './Settings/ExploreSettings'
|
||||
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
||||
import ZapSettings from './Settings/ZapSettings'
|
||||
@@ -39,6 +40,8 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
useLocalRelayAsCache: true,
|
||||
rebroadcastToAllRelays: false,
|
||||
paragraphAlignment: 'justify',
|
||||
fullWidthImages: true,
|
||||
renderVideoLinksAsEmbeds: true,
|
||||
syncReadingPosition: true,
|
||||
autoMarkAsReadOnCompletion: false,
|
||||
hideBookmarksWithoutCreationDate: true,
|
||||
@@ -169,6 +172,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
<div className="settings-content">
|
||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<MediaDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
|
||||
43
src/components/Settings/MediaDisplaySettings.tsx
Normal file
43
src/components/Settings/MediaDisplaySettings.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface MediaDisplaySettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const MediaDisplaySettings: React.FC<MediaDisplaySettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Media Display</h3>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="fullWidthImages" className="checkbox-label">
|
||||
<input
|
||||
id="fullWidthImages"
|
||||
type="checkbox"
|
||||
checked={settings.fullWidthImages === true}
|
||||
onChange={(e) => onUpdate({ fullWidthImages: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Full-width images in articles</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="renderVideoLinksAsEmbeds" className="checkbox-label">
|
||||
<input
|
||||
id="renderVideoLinksAsEmbeds"
|
||||
type="checkbox"
|
||||
checked={settings.renderVideoLinksAsEmbeds === true}
|
||||
onChange={(e) => onUpdate({ renderVideoLinksAsEmbeds: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Render video links as embeds</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaDisplaySettings
|
||||
@@ -59,6 +59,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
<div className="highlight-level-toggles">
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHeart, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
@@ -21,7 +21,7 @@ type SupporterProfile = ZapSender
|
||||
|
||||
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
||||
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const loadSupporters = async () => {
|
||||
@@ -31,7 +31,8 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
||||
|
||||
if (zappers.length > 0) {
|
||||
const pubkeys = zappers.map(z => z.pubkey)
|
||||
await fetchProfiles(relayPool, eventStore, pubkeys, settings)
|
||||
// Fetch profiles in background without blocking
|
||||
fetchProfiles(relayPool, eventStore, pubkeys, settings).catch(() => {})
|
||||
}
|
||||
|
||||
setSupporters(zappers)
|
||||
@@ -45,14 +46,6 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
||||
loadSupporters()
|
||||
}, [relayPool, eventStore, settings])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
|
||||
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
|
||||
@@ -82,7 +75,32 @@ const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) =>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{supporters.length === 0 ? (
|
||||
{loading ? (
|
||||
<>
|
||||
{/* Loading Skeletons */}
|
||||
<div className="mb-16 md:mb-20">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
|
||||
Legends
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<SupporterSkeleton key={`whale-${i}`} isWhale={true} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
|
||||
Supporters
|
||||
</h2>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<SupporterSkeleton key={`supporter-${i}`} isWhale={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : supporters.length === 0 ? (
|
||||
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<p>No supporters yet. Be the first to zap Boris!</p>
|
||||
</div>
|
||||
@@ -231,5 +249,55 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
|
||||
)
|
||||
}
|
||||
|
||||
interface SupporterSkeletonProps {
|
||||
isWhale: boolean
|
||||
}
|
||||
|
||||
const SupporterSkeleton: React.FC<SupporterSkeletonProps> = ({ isWhale }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative">
|
||||
{/* Avatar Skeleton */}
|
||||
<div
|
||||
className={`rounded-full overflow-hidden flex items-center justify-center animate-pulse
|
||||
${isWhale ? 'w-24 h-24 md:w-28 md:h-28' : 'w-10 h-10 md:w-12 md:h-12'}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-elevated)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`rounded-full ${isWhale ? 'w-20 h-20 md:w-24 md:h-24' : 'w-8 h-8 md:w-10 md:h-10'}`}
|
||||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Whale Badge Skeleton */}
|
||||
{isWhale && (
|
||||
<div
|
||||
className="absolute -bottom-1 -right-1 w-8 h-8 rounded-full animate-pulse border-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-border)',
|
||||
borderColor: 'var(--color-bg)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name and Total Skeleton */}
|
||||
<div className="mt-2 text-center space-y-1">
|
||||
<div
|
||||
className={`rounded animate-pulse ${isWhale ? 'h-4 w-16' : 'h-3 w-12'}`}
|
||||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
/>
|
||||
<div
|
||||
className={`rounded animate-pulse ${isWhale ? 'h-3 w-12' : 'h-2 w-10'}`}
|
||||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Support
|
||||
|
||||
|
||||
212
src/components/VideoEmbedProcessor.tsx
Normal file
212
src/components/VideoEmbedProcessor.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useMemo, forwardRef } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
|
||||
interface VideoEmbedProcessorProps {
|
||||
html: string
|
||||
renderVideoLinksAsEmbeds: boolean
|
||||
className?: string
|
||||
onMouseUp?: (e: React.MouseEvent) => void
|
||||
onTouchEnd?: (e: React.TouchEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that processes HTML content and optionally embeds video links
|
||||
* as ReactPlayer components when renderVideoLinksAsEmbeds is enabled
|
||||
*/
|
||||
const VideoEmbedProcessor = forwardRef<HTMLDivElement, VideoEmbedProcessorProps>(({
|
||||
html,
|
||||
renderVideoLinksAsEmbeds,
|
||||
className,
|
||||
onMouseUp,
|
||||
onTouchEnd
|
||||
}, ref) => {
|
||||
const processedHtml = useMemo(() => {
|
||||
if (!renderVideoLinksAsEmbeds || !html) {
|
||||
return html
|
||||
}
|
||||
|
||||
// Process HTML in stages: <video> blocks, <img> tags with video src, and bare video URLs
|
||||
let result = html
|
||||
|
||||
const collectedUrls: string[] = []
|
||||
let placeholderIndex = 0
|
||||
|
||||
// 1) Replace entire <video>...</video> blocks when they reference a video URL
|
||||
const videoBlockPattern = /<video[^>]*>[\s\S]*?<\/video>/gi
|
||||
const videoBlocks = result.match(videoBlockPattern) || []
|
||||
videoBlocks.forEach((block) => {
|
||||
// Try src on <video>
|
||||
let url: string | null = null
|
||||
const videoSrcMatch = block.match(/<video[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||
if (videoSrcMatch && videoSrcMatch[1]) {
|
||||
url = videoSrcMatch[1]
|
||||
} else {
|
||||
// Try nested <source>
|
||||
const sourceSrcMatch = block.match(/<source[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||
if (sourceSrcMatch && sourceSrcMatch[1]) {
|
||||
url = sourceSrcMatch[1]
|
||||
}
|
||||
}
|
||||
if (url) {
|
||||
collectedUrls.push(url)
|
||||
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||
const escaped = block.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
result = result.replace(new RegExp(escaped, 'g'), placeholder)
|
||||
placeholderIndex++
|
||||
}
|
||||
})
|
||||
|
||||
// 2) Replace entire <img ...> tags if their src points to a video
|
||||
const imgTagPattern = /<img[^>]*>/gi
|
||||
const allImgTags = result.match(imgTagPattern) || []
|
||||
allImgTags.forEach((imgTag) => {
|
||||
const srcMatch = imgTag.match(/src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?/i)
|
||||
if (srcMatch && srcMatch[1]) {
|
||||
const videoUrl = srcMatch[1]
|
||||
collectedUrls.push(videoUrl)
|
||||
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||
const escapedTag = imgTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
result = result.replace(new RegExp(escapedTag, 'g'), placeholder)
|
||||
placeholderIndex++
|
||||
}
|
||||
})
|
||||
|
||||
// 3) Replace remaining bare video URLs (direct files or recognized video platforms)
|
||||
const fileVideoPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
|
||||
const fileVideoUrls: string[] = result.match(fileVideoPattern) || []
|
||||
|
||||
const allUrlPattern = /https?:\/\/[^\s<>"']+(?=\s|>|"|'|$)/gi
|
||||
const allUrls: string[] = result.match(allUrlPattern) || []
|
||||
const platformVideoUrls = allUrls.filter(url => {
|
||||
// include URLs classified as video and not already collected
|
||||
const classification = classifyUrl(url)
|
||||
return classification.type === 'video' && !collectedUrls.includes(url)
|
||||
})
|
||||
|
||||
const remainingUrls = [...fileVideoUrls, ...platformVideoUrls].filter(url => !collectedUrls.includes(url))
|
||||
|
||||
let processedHtml = result
|
||||
remainingUrls.forEach((url) => {
|
||||
const placeholder = `__VIDEO_EMBED_${placeholderIndex}__`
|
||||
processedHtml = processedHtml.replace(new RegExp(url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), placeholder)
|
||||
collectedUrls.push(url)
|
||||
placeholderIndex++
|
||||
})
|
||||
|
||||
// If nothing collected, return original html
|
||||
if (collectedUrls.length === 0) {
|
||||
return html
|
||||
}
|
||||
|
||||
return processedHtml
|
||||
}, [html, renderVideoLinksAsEmbeds])
|
||||
|
||||
const videoUrls = useMemo(() => {
|
||||
if (!renderVideoLinksAsEmbeds || !html) {
|
||||
return []
|
||||
}
|
||||
|
||||
const urls: string[] = []
|
||||
|
||||
// 1) Extract from <video> blocks first (video src or nested source src)
|
||||
const videoBlockPattern = /<video[^>]*>[\s\S]*?<\/video>/gi
|
||||
const videoBlocks = html.match(videoBlockPattern) || []
|
||||
videoBlocks.forEach((block) => {
|
||||
let url: string | null = null
|
||||
const videoSrcMatch = block.match(/<video[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||
if (videoSrcMatch && videoSrcMatch[1]) {
|
||||
url = videoSrcMatch[1]
|
||||
} else {
|
||||
const sourceSrcMatch = block.match(/<source[^>]*\s+src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?[^>]*>/i)
|
||||
if (sourceSrcMatch && sourceSrcMatch[1]) {
|
||||
url = sourceSrcMatch[1]
|
||||
}
|
||||
}
|
||||
if (url && !urls.includes(url)) urls.push(url)
|
||||
})
|
||||
|
||||
// 2) Extract from <img> tags with video src
|
||||
const imgTagPattern = /<img[^>]*>/gi
|
||||
const allImgTags = html.match(imgTagPattern) || []
|
||||
allImgTags.forEach((imgTag) => {
|
||||
const srcMatch = imgTag.match(/src=["']?(https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)[^\s<>"']*)["']?/i)
|
||||
if (srcMatch && srcMatch[1] && !urls.includes(srcMatch[1])) {
|
||||
urls.push(srcMatch[1])
|
||||
}
|
||||
})
|
||||
|
||||
// 3) Extract remaining direct file URLs and platform-classified video URLs
|
||||
const fileVideoPattern = /https?:\/\/[^\s<>"']+\.(mp4|webm|ogg|mov|avi|mkv|m4v)(?:\?[^\s<>"']*)?/gi
|
||||
const fileVideoUrls: string[] = html.match(fileVideoPattern) || []
|
||||
fileVideoUrls.forEach(u => { if (!urls.includes(u)) urls.push(u) })
|
||||
|
||||
const allUrlPattern = /https?:\/\/[^\s<>"']+(?=\s|>|"|'|$)/gi
|
||||
const allUrls: string[] = html.match(allUrlPattern) || []
|
||||
allUrls.forEach(u => {
|
||||
const classification = classifyUrl(u)
|
||||
if (classification.type === 'video' && !urls.includes(u)) {
|
||||
urls.push(u)
|
||||
}
|
||||
})
|
||||
|
||||
return urls
|
||||
}, [html, renderVideoLinksAsEmbeds])
|
||||
|
||||
// If no video embedding is enabled, just render the HTML normally
|
||||
if (!renderVideoLinksAsEmbeds || videoUrls.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: processedHtml }}
|
||||
onMouseUp={onMouseUp}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Split the HTML by video placeholders and render with embedded players
|
||||
const parts = processedHtml.split(/(__VIDEO_EMBED_\d+__)/)
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} onMouseUp={onMouseUp} onTouchEnd={onTouchEnd}>
|
||||
{parts.map((part, index) => {
|
||||
const videoMatch = part.match(/^__VIDEO_EMBED_(\d+)__$/)
|
||||
if (videoMatch) {
|
||||
const videoIndex = parseInt(videoMatch[1])
|
||||
const videoUrl = videoUrls[videoIndex]
|
||||
if (videoUrl) {
|
||||
return (
|
||||
<div key={index} className="reader-video" style={{ margin: '1rem 0' }}>
|
||||
<ReactPlayer
|
||||
url={videoUrl}
|
||||
controls
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '16/9'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Regular HTML content
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
dangerouslySetInnerHTML={{ __html: part }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
VideoEmbedProcessor.displayName = 'VideoEmbedProcessor'
|
||||
|
||||
export default VideoEmbedProcessor
|
||||
@@ -16,7 +16,7 @@ interface UseSettingsParams {
|
||||
}
|
||||
|
||||
export function useSettings({ relayPool, eventStore, pubkey, accountManager }: UseSettingsParams) {
|
||||
const [settings, setSettings] = useState<UserSettings>({})
|
||||
const [settings, setSettings] = useState<UserSettings>({ renderVideoLinksAsEmbeds: true })
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const loadAndWatch = async () => {
|
||||
try {
|
||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
||||
if (loadedSettings) setSettings(loadedSettings)
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, ...loadedSettings })
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
loadAndWatch()
|
||||
|
||||
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
||||
if (loadedSettings) setSettings(loadedSettings)
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, ...loadedSettings })
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
@@ -73,6 +73,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
// Set paragraph alignment
|
||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||
|
||||
// Set image max-width based on full-width setting
|
||||
root.setProperty('--image-max-width', settings.fullWidthImages ? 'none' : '100%')
|
||||
|
||||
}
|
||||
|
||||
applyStyles()
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface UserSettings {
|
||||
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
||||
// Reading settings
|
||||
paragraphAlignment?: 'left' | 'justify' // default: justify
|
||||
fullWidthImages?: boolean // default: false
|
||||
renderVideoLinksAsEmbeds?: boolean // default: false
|
||||
// Reading position sync
|
||||
syncReadingPosition?: boolean // default: false (opt-in)
|
||||
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
.login-highlight {
|
||||
background-color: var(--highlight-color-mine, #fde047);
|
||||
color: var(--color-text);
|
||||
color: #000000;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
/* Video container - responsive wrapper following react-player docs */
|
||||
.reader-video {
|
||||
position: relative;
|
||||
width: 80vw; /* 80% of viewport width */
|
||||
min-width: 400px; /* Minimum width */
|
||||
max-width: 1000px; /* Maximum width */
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
margin: 0 -0.75rem 1rem -0.75rem; /* Negative margins to counteract reader padding */
|
||||
margin: 0 0 1rem 0; /* align with reader padding, no bleed */
|
||||
background: rgb(0 0 0); /* black */
|
||||
overflow: hidden;
|
||||
}
|
||||
.reader.empty { color: var(--color-text-secondary); }
|
||||
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: var(--color-text-secondary); }
|
||||
@@ -54,7 +54,15 @@
|
||||
.reader .reader-html h1, .reader .reader-html h2, .reader .reader-html h3, .reader .reader-html h4, .reader .reader-html h5, .reader .reader-html h6,
|
||||
.reader .reader-markdown h1, .reader .reader-markdown h2, .reader .reader-markdown h3, .reader .reader-markdown h4, .reader .reader-markdown h5, .reader .reader-markdown h6 { text-align: left !important; }
|
||||
/* Tame images from external content */
|
||||
.reader .reader-html img, .reader .reader-markdown img { max-width: 100%; max-height: 70vh; height: auto; width: auto; display: block; margin: 0.75rem 0; border-radius: 6px; }
|
||||
.reader .reader-html img, .reader .reader-markdown img {
|
||||
max-width: var(--image-max-width, 100%);
|
||||
max-height: 70vh;
|
||||
height: auto;
|
||||
width: auto;
|
||||
display: block;
|
||||
margin: 0.75rem auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
/* Headlines with Tailwind typography */
|
||||
.reader-markdown h1, .reader-html h1 {
|
||||
font-size: 2.25rem; /* text-4xl */
|
||||
|
||||
Reference in New Issue
Block a user