Compare commits

...

10 Commits

Author SHA1 Message Date
Gigi
0199c59014 chore: bump version to 0.5.2 2025-10-13 00:12:51 +02:00
Gigi
44fb63fc59 fix: resolve linting errors in HighlightItem
- Remove unused handleDeleteClick function (replaced by handleMenuDeleteClick)
- Remove onSelectUrl from destructuring (not used after menu refactor)
- Add comment explaining onSelectUrl is kept in props for API compatibility
2025-10-13 00:12:25 +02:00
Gigi
13a28d2dbd fix: make getNostrUrl detect identifier type for ants.sh
- ants.sh requires /p/ for profiles (npub/nprofile) and /e/ for events (note/nevent/naddr)
- Updated getNostrUrl to automatically detect identifier type and use correct path
2025-10-13 00:07:46 +02:00
Gigi
f87a7da32e feat: switch Nostr gateway to ants.sh
- Replace njump.me and search.dergigi.com with ants.sh
- Use ants.sh/p/ for profiles and ants.sh/e/ for events
- All existing helper functions continue to work with new gateway
2025-10-13 00:05:44 +02:00
Gigi
8fdf9938c2 refactor: centralize Nostr gateway URLs in config
- Create nostrGateways.ts config file with PRIMARY (njump.me) and SEARCH (search.dergigi.com) gateways
- Add helper functions: getProfileUrl, getEventUrl, getNostrUrl
- Update all hardcoded gateway URLs across the codebase to use the config
- Updated files: HighlightItem, nostrUriResolver, BookmarkViews (Card/Large), ResolvedMention
2025-10-13 00:05:11 +02:00
Gigi
ee4d480961 refactor: remove 'Loading your highlights' text from Me page 2025-10-13 00:01:41 +02:00
Gigi
bd866549a0 fix: open on nostr now opens the highlight event itself
- Changed 'Open on Nostr' to link to the highlight event (kind 9802)
- Previously it was linking to the article being highlighted
- Menu item is now always shown since every highlight has an event ID
- Removed unused handleLinkClick function
2025-10-13 00:01:11 +02:00
Gigi
7c39f1d821 style: change three-dot menu icon to horizontal 2025-10-13 00:00:05 +02:00
Gigi
e6a7bb4c98 feat: add three-dot menu to highlight cards
- Replace external link button with three-dot menu in highlight cards
- Move 'Open source/Nostr' and 'Delete' actions into dropdown menu
- Add click-outside functionality to close menu
- Style menu for both dark and light themes
2025-10-12 23:59:28 +02:00
Gigi
14cf3189b8 chore: remove 'Refreshing posts…' text from Explore
Only show spinner icon without text for cleaner UI
2025-10-12 23:56:23 +02:00
10 changed files with 223 additions and 97 deletions

View File

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

View File

@@ -9,6 +9,7 @@ import { classifyUrl } from '../../utils/helpers'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
import { getProfileUrl, getEventUrl } from '../../config/nostrGateways'
interface CardViewProps {
bookmark: IndividualBookmark
@@ -79,7 +80,7 @@ export const CardView: React.FC<CardViewProps> = ({
{eventNevent ? (
<a
href={`https://search.dergigi.com/e/${eventNevent}`}
href={getEventUrl(eventNevent)}
target="_blank"
rel="noopener noreferrer"
className="bookmark-date-link"
@@ -159,7 +160,7 @@ export const CardView: React.FC<CardViewProps> = ({
<div className="bookmark-footer">
<div className="bookmark-meta-minimal">
<a
href={`https://search.dergigi.com/p/${authorNpub}`}
href={getProfileUrl(authorNpub)}
target="_blank"
rel="noopener noreferrer"
className="author-link-minimal"

View File

@@ -6,6 +6,7 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared'
import { useImageCache } from '../../hooks/useImageCache'
import { UserSettings } from '../../services/settingsService'
import { getProfileUrl, getEventUrl } from '../../config/nostrGateways'
interface LargeViewProps {
bookmark: IndividualBookmark
@@ -79,7 +80,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
<div className="large-footer">
<span className="large-author">
<a
href={`https://search.dergigi.com/p/${authorNpub}`}
href={getProfileUrl(authorNpub)}
target="_blank"
rel="noopener noreferrer"
className="author-link-minimal"
@@ -90,7 +91,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
{eventNevent && (
<a
href={`https://search.dergigi.com/e/${eventNevent}`}
href={getEventUrl(eventNevent)}
target="_blank"
rel="noopener noreferrer"
className="bookmark-date-link"

View File

@@ -157,7 +157,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
{loading && (
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
<FontAwesomeIcon icon={faSpinner} spin />
<span>Refreshing posts</span>
</div>
)}
<div className="explore-grid">

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
@@ -13,6 +13,7 @@ import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
import ConfirmDialog from './ConfirmDialog'
import { getNostrUrl } from '../config/nostrGateways'
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -31,7 +32,7 @@ interface HighlightItemProps {
export const HighlightItem: React.FC<HighlightItemProps> = ({
highlight,
onSelectUrl,
// onSelectUrl is not used but kept in props for API compatibility
isSelected,
onHighlightClick,
relayPool,
@@ -40,11 +41,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onHighlightDelete
}) => {
const itemRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [showMenu, setShowMenu] = useState(false)
const activeAccount = Hooks.useActiveAccount()
@@ -97,61 +100,45 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}
}, [isSelected])
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowMenu(false)
}
}
if (showMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
}, [showMenu])
const handleItemClick = () => {
if (onHighlightClick) {
onHighlightClick(highlight.id)
}
}
const handleLinkClick = (url: string, e: React.MouseEvent) => {
if (onSelectUrl) {
e.preventDefault()
onSelectUrl(url)
}
const getHighlightLink = () => {
// Encode the highlight event itself (kind 9802) as a nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const nevent = nip19.neventEncode({
id: highlight.id,
relays: relayHints,
author: highlight.pubkey,
kind: 9802
})
return getNostrUrl(nevent)
}
const getSourceLink = () => {
if (highlight.eventReference) {
// Check if it's a coordinate string (kind:pubkey:identifier) or a simple event ID
if (highlight.eventReference.includes(':')) {
// It's an addressable event coordinate, encode as naddr
const parts = highlight.eventReference.split(':')
if (parts.length === 3) {
const [kindStr, pubkey, identifier] = parts
const kind = parseInt(kindStr, 10)
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const naddr = nip19.naddrEncode({
kind,
pubkey,
identifier,
relays: relayHints
})
return `https://njump.me/${naddr}`
}
} else {
// It's a simple event ID, encode as nevent
// Get non-local relays for the hint
const relayHints = RELAYS.filter(r =>
!r.includes('localhost') && !r.includes('127.0.0.1')
).slice(0, 3) // Include up to 3 relay hints
const nevent = nip19.neventEncode({
id: highlight.eventReference,
relays: relayHints,
author: highlight.author
})
return `https://njump.me/${nevent}`
}
}
return highlight.urlReference
}
const sourceLink = getSourceLink()
const highlightLink = getHighlightLink()
// Handle rebroadcast to all relays
const handleRebroadcast = async (e: React.MouseEvent) => {
@@ -255,11 +242,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
// Check if current user can delete this highlight
const canDelete = activeAccount && highlight.pubkey === activeAccount.pubkey
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteConfirm(true)
}
const handleConfirmDelete = async () => {
if (!activeAccount || !relayPool) {
console.warn('Cannot delete: no account or relay pool')
@@ -295,6 +277,23 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
setShowDeleteConfirm(false)
}
const handleMenuToggle = (e: React.MouseEvent) => {
e.stopPropagation()
setShowMenu(!showMenu)
}
const handleOpenExternal = (e: React.MouseEvent) => {
e.stopPropagation()
window.open(highlightLink, '_blank', 'noopener,noreferrer')
setShowMenu(false)
}
const handleMenuDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
setShowMenu(false)
setShowDeleteConfirm(true)
}
return (
<>
<div
@@ -316,15 +315,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
</div>
)}
{canDelete && (
<div
className="highlight-delete-btn"
title="Delete highlight"
onClick={handleDeleteClick}
>
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
</div>
)}
</div>
<div className="highlight-content">
@@ -348,18 +338,37 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
{formatDateCompact(highlight.created_at)}
</span>
{sourceLink && (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
className="highlight-source"
title={highlight.eventReference ? 'Open on Nostr' : 'Open source'}
<div className="highlight-menu-wrapper" ref={menuRef}>
<button
className="highlight-menu-btn"
onClick={handleMenuToggle}
title="More options"
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</a>
)}
<FontAwesomeIcon icon={faEllipsisH} />
</button>
{showMenu && (
<div className="highlight-menu">
<button
className="highlight-menu-item"
onClick={handleOpenExternal}
>
<FontAwesomeIcon icon={faExternalLinkAlt} />
<span>Open on Nostr</span>
</button>
{canDelete && (
<button
className="highlight-menu-item highlight-menu-item-danger"
onClick={handleMenuDeleteClick}
disabled={isDeleting}
>
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
<span>Delete</span>
</button>
)}
</div>
)}
</div>
</div>
</div>
</div>

View File

@@ -73,7 +73,6 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
<div className="explore-container">
<div className="explore-loading">
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<p>Loading your highlights...</p>
</div>
</div>
)

View File

@@ -2,6 +2,7 @@ import React from 'react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, Helpers } from 'applesauce-core'
import { decode, npubEncode } from 'nostr-tools/nip19'
import { getProfileUrl } from '../config/nostrGateways'
const { getPubkeyFromDecodeResult } = Helpers
@@ -25,7 +26,7 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
if (npub) {
return (
<a
href={`https://search.dergigi.com/p/${npub}`}
href={getProfileUrl(npub)}
className="nostr-mention"
target="_blank"
rel="noopener noreferrer"

View File

@@ -0,0 +1,34 @@
/**
* Nostr gateway URLs for viewing events and profiles on the web
*/
export const NOSTR_GATEWAY = 'https://ants.sh' as const
/**
* Get a profile URL on the gateway
*/
export function getProfileUrl(npub: string): string {
return `${NOSTR_GATEWAY}/p/${npub}`
}
/**
* Get an event URL on the gateway
*/
export function getEventUrl(nevent: string): string {
return `${NOSTR_GATEWAY}/e/${nevent}`
}
/**
* Get a general nostr link on the gateway
* 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}`
}

View File

@@ -1783,6 +1783,35 @@ body.mobile-sidebar-open {
.highlight-text {
color: #213547;
}
.highlight-menu-btn {
color: #666;
}
.highlight-menu-btn:hover {
color: #646cff;
background: rgba(100, 108, 255, 0.08);
}
.highlight-menu {
background: #fff;
border-color: #ddd;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.highlight-menu-item {
color: #213547;
}
.highlight-menu-item:hover {
background: rgba(100, 108, 255, 0.08);
color: #000;
}
.highlight-menu-item-danger:hover {
background: rgba(255, 68, 68, 0.08);
color: #cc0000;
}
}
/* Highlights Panel Styles */
@@ -2236,25 +2265,77 @@ body.mobile-sidebar-open {
line-height: 1;
}
.highlight-source {
.highlight-menu-wrapper {
position: relative;
margin-left: auto;
flex-shrink: 0;
}
.highlight-menu-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.375rem;
color: #646cff;
text-decoration: none;
transition: color 0.2s ease;
flex-shrink: 0;
margin-left: auto;
line-height: 1;
border-radius: 4px;
}
.highlight-source:hover {
color: #535bf2;
text-decoration: underline;
.highlight-menu-btn:hover {
color: #646cff;
background: rgba(100, 108, 255, 0.1);
}
.highlight-source svg {
.highlight-menu {
position: absolute;
right: 0;
top: calc(100% + 4px);
background: #2a2a2a;
border: 1px solid #444;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
min-width: 160px;
overflow: hidden;
}
.highlight-menu-item {
width: 100%;
background: none;
border: none;
color: #ddd;
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.625rem;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
white-space: nowrap;
}
.highlight-menu-item:hover {
background: rgba(100, 108, 255, 0.15);
color: #fff;
}
.highlight-menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.highlight-menu-item-danger:hover {
background: rgba(255, 68, 68, 0.15);
color: #ff4444;
}
.highlight-menu-item svg {
font-size: 0.875rem;
flex-shrink: 0;
}
/* Inline content highlights - fluorescent marker style */

View File

@@ -1,4 +1,5 @@
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
import { getNostrUrl } from '../config/nostrGateways'
/**
* Regular expression to match nostr: URIs and bare NIP-19 identifiers
@@ -39,7 +40,7 @@ export function extractNaddrUris(text: string): string[] {
/**
* Decode a NIP-19 identifier and return a human-readable link
* For articles (naddr), returns an internal app link
* For other types, returns an external njump.me link
* For other types, returns an external gateway link
*/
export function createNostrLink(encoded: string): string {
try {
@@ -53,13 +54,13 @@ export function createNostrLink(encoded: string): string {
case 'nprofile':
case 'note':
case 'nevent':
return `https://njump.me/${encoded}`
return getNostrUrl(encoded)
default:
return `https://njump.me/${encoded}`
return getNostrUrl(encoded)
}
} catch (error) {
console.warn('Failed to decode nostr URI:', encoded, error)
return `https://njump.me/${encoded}`
return getNostrUrl(encoded)
}
}