Compare commits

...

12 Commits

Author SHA1 Message Date
Gigi
a12a883cc6 chore: bump version to 0.6.15 2025-10-15 17:50:47 +02:00
Gigi
0cf076b010 chore: change default paragraph alignment to justify 2025-10-15 17:45:10 +02:00
Gigi
e2c712033f feat: add paragraph alignment setting
- Add paragraphAlignment setting (left/justify) to UserSettings interface
- Add UI control with icon buttons in ReadingDisplaySettings
- Apply alignment via CSS variable to reader content and preview
- Default to left-aligned to maintain current behavior
- Keep headings always left-aligned for better readability
2025-10-15 17:43:31 +02:00
Gigi
e38237ca8e fix: resolve linter errors for unused variables 2025-10-15 17:39:22 +02:00
Gigi
1fff44fc6c chore: update button label from 'Open on Nostr' to 'Open with njump' 2025-10-15 17:37:38 +02:00
Gigi
4e50073e07 feat: update gateway config to use nostr.at and define ants.sh as search portal 2025-10-15 17:31:39 +02:00
Gigi
0ce64fe83f feat: open three-dot menus upward when insufficient space below 2025-10-15 17:28:44 +02:00
Gigi
ef848aa93e feat: update external article menu with Share (Boris link) and Search options 2025-10-15 17:25:29 +02:00
Gigi
67b287d75d feat: add Search option to article menu for ants.sh portal 2025-10-15 17:20:59 +02:00
Gigi
b795dfd2c6 feat: add Copy Link and Copy Original options to article menu 2025-10-15 17:19:20 +02:00
Gigi
c68d855983 feat: add Share and Share Original options to article menu 2025-10-15 17:18:36 +02:00
Gigi
fb1c19e64b docs: update CHANGELOG.md for v0.6.14 2025-10-15 16:30:11 +02:00
12 changed files with 298 additions and 36 deletions

View File

@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.14] - 2025-10-15
### Added
- Support for bookmark sets (NIP-51 kind:30003)
- Bookmark sets now display alongside regular bookmark lists
- Properly handles AddressPointer bookmarks for long-form articles
- Content type icons for bookmarks
- Article, video, web, and image icons to indicate bookmark content type
- Camera icon for image bookmarks
- Sticky note icon for text-only bookmarks without URLs
- Bookmark grouping and sections
- Grouped sections in sidebar and `/me` reading-list
- Web bookmarks, default bookmarks, and legacy bookmarks in separate sections
- Grouping and sorting helpers for organizing bookmark sections
- Adaptive text color for publication date over hero images
- Automatically detects image brightness and adjusts text color
- Improved contrast for better readability
### Changed
- Renamed "Amethyst-style bookmarks" to "Old Bookmarks (Legacy)"
- Hide cover images in compact view for cleaner layout
- Support button improvements
- Moved to bottom-left of bookmarks bar
- Changed icon from lightning bolt to heart (orange color)
- Left-aligned support button, right-aligned view mode buttons
- Section headings improved with better typography (removed counts)
- Icon changed from book to file-lines for default bookmarks
- Use regular (outlined) icon variants for lighter, more refined appearance
- Add bookmark button moved to web bookmarks section
- Empty state messages replaced with loading spinners
- Section dividers made more subtle
- Simplified bookmark filtering to only exclude empty content
### Fixed
- Removed borders from compact bookmark cards for cleaner look
- Removed duplicate type indicator icons from bookmark cards
- Reduced section heading bottom padding for better spacing
- Aligned add bookmark button with section heading
- Removed redundant loading spinner above tabs
- Resolved linter and type errors
- Include kind:30003 in default bookmark list detection
- Removed text shadows from publication date for cleaner look
- Improved shadow contrast without background overlay
- Corrected async handling in adaptive color detection
- Corrected FastAverageColor import to use named export
- Section heading styles now properly override with `!important`
- Removed unused articleImage prop from CompactView
## [0.6.13] - 2025-10-15
### Added
@@ -1415,7 +1466,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.13...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.14...HEAD
[0.6.14]: https://github.com/dergigi/boris/compare/v0.6.13...v0.6.14
[0.6.13]: https://github.com/dergigi/boris/compare/v0.6.12...v0.6.13
[0.6.12]: https://github.com/dergigi/boris/compare/v0.6.11...v0.6.12
[0.6.11]: https://github.com/dergigi/boris/compare/v0.6.10...v0.6.11

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.6.14",
"version": "0.6.15",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

View File

@@ -142,7 +142,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
}
if (viewMode === 'compact') {
const { articleImage: _articleImage, ...compactProps } = sharedProps
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { articleImage, ...compactProps } = sharedProps
return <CompactView {...compactProps} />
}

View File

@@ -6,10 +6,10 @@ import rehypeRaw from 'rehype-raw'
import rehypePrism from 'rehype-prism-plus'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import 'prismjs/themes/prism-tomorrow.css'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
import { ContentSkeleton } from './Skeletons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl } from '../config/nostrGateways'
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
@@ -100,6 +100,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const [showArticleMenu, setShowArticleMenu] = useState(false)
const [showVideoMenu, setShowVideoMenu] = useState(false)
const [showExternalMenu, setShowExternalMenu] = useState(false)
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
const articleMenuRef = useRef<HTMLDivElement>(null)
const videoMenuRef = useRef<HTMLDivElement>(null)
const externalMenuRef = useRef<HTMLDivElement>(null)
@@ -161,6 +164,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
}, [showArticleMenu, showVideoMenu, showExternalMenu])
// Check available space and position menu upward if needed
useEffect(() => {
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (value: boolean) => void) => {
if (!menuRef.current) return
const menuWrapper = menuRef.current
const menuElement = menuWrapper.querySelector('.article-menu') as HTMLElement
if (!menuElement) return
const rect = menuWrapper.getBoundingClientRect()
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - rect.bottom
const menuHeight = menuElement.offsetHeight || 300 // estimate if not rendered yet
// Open upward if there's not enough space below (with 20px buffer)
setOpenUpward(spaceBelow < menuHeight + 20 && rect.top > menuHeight)
}
if (showArticleMenu) {
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
}
if (showVideoMenu) {
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
}
if (showExternalMenu) {
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
}
}, [showArticleMenu, showVideoMenu, showExternalMenu])
const readingStats = useMemo(() => {
const content = markdown || html || ''
if (!content) return null
@@ -218,9 +250,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
relays: relayHints
})
// Check for source URL in 'r' tags
const sourceUrl = currentArticle.tags.find(t => t[0] === 'r')?.[1]
return {
portal: getNostrUrl(naddr),
native: `nostr:${naddr}`
native: `nostr:${naddr}`,
naddr,
sourceUrl,
borisUrl: `${window.location.origin}/a/${naddr}`
}
}
@@ -245,6 +283,73 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
setShowArticleMenu(false)
}
const handleShareBoris = async () => {
try {
if (!articleLinks) return
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: title || 'Article',
url: articleLinks.borisUrl
})
} else {
await navigator.clipboard.writeText(articleLinks.borisUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowArticleMenu(false)
}
}
const handleShareOriginal = async () => {
try {
if (!articleLinks?.sourceUrl) return
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: title || 'Article',
url: articleLinks.sourceUrl
})
} else {
await navigator.clipboard.writeText(articleLinks.sourceUrl)
}
} catch (e) {
console.warn('Share failed', e)
} finally {
setShowArticleMenu(false)
}
}
const handleCopyBoris = async () => {
try {
if (!articleLinks) return
await navigator.clipboard.writeText(articleLinks.borisUrl)
} catch (e) {
console.warn('Copy failed', e)
} finally {
setShowArticleMenu(false)
}
}
const handleCopyOriginal = async () => {
try {
if (!articleLinks?.sourceUrl) return
await navigator.clipboard.writeText(articleLinks.sourceUrl)
} catch (e) {
console.warn('Copy failed', e)
} finally {
setShowArticleMenu(false)
}
}
const handleOpenSearch = () => {
if (articleLinks) {
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
}
setShowArticleMenu(false)
}
// Video actions
const handleOpenVideoExternal = () => {
@@ -307,10 +412,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const handleShareExternalUrl = async () => {
try {
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Article', url: selectedUrl })
} else if (selectedUrl) {
await navigator.clipboard.writeText(selectedUrl)
if (!selectedUrl) return
const borisUrl = `${window.location.origin}/r/${encodeURIComponent(selectedUrl)}`
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
title: title || 'Article',
url: borisUrl
})
} else {
await navigator.clipboard.writeText(borisUrl)
}
} catch (e) {
console.warn('Share failed', e)
@@ -318,6 +429,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
setShowExternalMenu(false)
}
}
const handleSearchExternalUrl = () => {
if (selectedUrl) {
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
}
setShowExternalMenu(false)
}
// Check if article is already marked as read when URL/article changes
useEffect(() => {
@@ -501,7 +619,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showVideoMenu && (
<div className="article-menu">
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Link</span>
@@ -582,13 +700,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</button>
{showExternalMenu && (
<div className="article-menu">
<div className={`article-menu ${externalMenuOpenUpward ? 'open-upward' : ''}`}>
<button
className="article-menu-item"
onClick={handleOpenExternalUrl}
onClick={handleShareExternalUrl}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original URL</span>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
<button
className="article-menu-item"
@@ -599,10 +717,17 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</button>
<button
className="article-menu-item"
onClick={handleShareExternalUrl}
onClick={handleOpenExternalUrl}
>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open Original</span>
</button>
<button
className="article-menu-item"
onClick={handleSearchExternalUrl}
>
<FontAwesomeIcon icon={faSearch} />
<span>Search</span>
</button>
</div>
)}
@@ -623,13 +748,52 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</button>
{showArticleMenu && (
<div className="article-menu">
<div className={`article-menu ${articleMenuOpenUpward ? 'open-upward' : ''}`}>
<button
className="article-menu-item"
onClick={handleShareBoris}
>
<FontAwesomeIcon icon={faShare} />
<span>Share</span>
</button>
{articleLinks.sourceUrl && (
<button
className="article-menu-item"
onClick={handleShareOriginal}
>
<FontAwesomeIcon icon={faShare} />
<span>Share Original</span>
</button>
)}
<button
className="article-menu-item"
onClick={handleCopyBoris}
>
<FontAwesomeIcon icon={faCopy} />
<span>Copy Link</span>
</button>
{articleLinks.sourceUrl && (
<button
className="article-menu-item"
onClick={handleCopyOriginal}
>
<FontAwesomeIcon icon={faCopy} />
<span>Copy Original</span>
</button>
)}
<button
className="article-menu-item"
onClick={handleOpenSearch}
>
<FontAwesomeIcon icon={faSearch} />
<span>Search</span>
</button>
<button
className="article-menu-item"
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
<span>Open with njump</span>
</button>
<button
className="article-menu-item"

View File

@@ -546,7 +546,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onClick={handleOpenPortal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
<span>Open with njump</span>
</button>
<button
className="highlight-menu-item"

View File

@@ -35,6 +35,7 @@ const DEFAULT_SETTINGS: UserSettings = {
zapSplitAuthorWeight: 50,
useLocalRelayAsCache: true,
rebroadcastToAllRelays: false,
paragraphAlignment: 'justify',
}
interface SettingsProps {

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser, faAlignLeft, faAlignJustify } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
import ColorPicker from '../ColorPicker'
@@ -48,6 +48,26 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div>
</div>
<div className="setting-group setting-inline">
<label>Paragraph Alignment</label>
<div className="setting-buttons">
<IconButton
icon={faAlignLeft}
onClick={() => onUpdate({ paragraphAlignment: 'left' })}
title="Left aligned"
ariaLabel="Left aligned"
variant={settings.paragraphAlignment === 'left' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faAlignJustify}
onClick={() => onUpdate({ paragraphAlignment: 'justify' })}
title="Justified"
ariaLabel="Justified"
variant={(settings.paragraphAlignment || 'justify') === 'justify' ? 'primary' : 'ghost'}
/>
</div>
</div>
<div className="setting-group setting-inline">
<label>Highlight Style</label>
<div className="setting-buttons">
@@ -157,7 +177,8 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
style={{
fontFamily: previewFontFamily,
fontSize: `${settings.fontSize || 21}px`,
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00')
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
'--paragraph-alignment': settings.paragraphAlignment || 'justify'
} as React.CSSProperties}
>
<h3>The Quick Brown Fox</h3>

View File

@@ -2,20 +2,21 @@
* Nostr gateway URLs for viewing events and profiles on the web
*/
export const NOSTR_GATEWAY = 'https://ants.sh' as const
export const NOSTR_GATEWAY = 'https://nostr.at' as const
export const SEARCH_PORTAL = 'https://ants.sh' as const
/**
* Get a profile URL on the gateway
*/
export function getProfileUrl(npub: string): string {
return `${NOSTR_GATEWAY}/p/${npub}`
return `${NOSTR_GATEWAY}/${npub}`
}
/**
* Get an event URL on the gateway
*/
export function getEventUrl(nevent: string): string {
return `${NOSTR_GATEWAY}/e/${nevent}`
return `${NOSTR_GATEWAY}/${nevent}`
}
/**
@@ -23,12 +24,14 @@ export function getEventUrl(nevent: string): string {
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
*/
export function getNostrUrl(identifier: string): string {
// Check the prefix to determine if it's a profile or event
if (identifier.startsWith('npub') || identifier.startsWith('nprofile')) {
return `${NOSTR_GATEWAY}/p/${identifier}`
}
// Everything else (note, nevent, naddr) goes to /e/
return `${NOSTR_GATEWAY}/e/${identifier}`
// nostr.at uses simple /{identifier} format for all types
return `${NOSTR_GATEWAY}/${identifier}`
}
/**
* Get a search portal URL with a query
*/
export function getSearchUrl(query: string): string {
return `${SEARCH_PORTAL}/?q=${encodeURIComponent(query)}`
}

View File

@@ -73,6 +73,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
// Set paragraph alignment
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
console.log('✅ All styles applied')
}

View File

@@ -52,6 +52,8 @@ export interface UserSettings {
theme?: 'dark' | 'light' | 'system' // default: system
darkColorTheme?: 'black' | 'midnight' | 'charcoal' // default: midnight
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
// Reading settings
paragraphAlignment?: 'left' | 'justify' // default: justify
}
export async function loadSettings(

View File

@@ -41,6 +41,7 @@
.preview-content p {
margin: 0.75rem 0;
word-wrap: break-word;
text-align: var(--paragraph-alignment, justify);
}
.setting-select { width: 100%; padding: 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }

View File

@@ -37,9 +37,22 @@
.highlight-indicator svg { font-size: 0.875rem; }
.reader-html { color: var(--color-text); line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
.reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
/* Ensure content is left-aligned even if source markup uses center */
.reader .reader-html *, .reader .reader-markdown * { text-align: left !important; font-family: inherit !important; }
.reader center, .reader [align="center"] { text-align: left !important; }
/* Ensure font inheritance */
.reader .reader-html *, .reader .reader-markdown * { font-family: inherit !important; }
/* Apply paragraph alignment from settings */
.reader .reader-html p,
.reader .reader-markdown p,
.reader .reader-html div,
.reader .reader-markdown div,
.reader .reader-html li,
.reader .reader-markdown li,
.reader .reader-html blockquote,
.reader .reader-markdown blockquote { text-align: var(--paragraph-alignment, justify); }
/* Override centered content with user preference */
.reader center, .reader [align="center"] { text-align: var(--paragraph-alignment, justify) !important; }
/* Keep headings left-aligned */
.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; }
/* Headlines with Tailwind typography */
@@ -192,6 +205,7 @@
.article-menu-btn { background: none; border: none; color: var(--color-text-secondary); cursor: pointer; padding: 0.5rem 0.75rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s ease; border-radius: 6px; }
.article-menu-btn:hover { color: var(--color-primary); background: rgba(99, 102, 241, 0.1); }
.article-menu { position: absolute; right: 0; top: calc(100% + 4px); background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 180px; overflow: hidden; }
.article-menu.open-upward { top: auto; bottom: calc(100% + 4px); }
.article-menu-item { width: 100%; background: none; border: none; color: var(--color-text); padding: 0.75rem 1rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.75rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
.article-menu-item:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
.article-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }