Compare commits

...

11 Commits

Author SHA1 Message Date
Gigi
9638ab0b84 chore: bump version to 0.1.10 2025-10-05 21:20:16 +01:00
Gigi
8d7b853e75 fix: ensure highlights always render on markdown content
- Add logic to wait for HTML conversion when highlights need to be applied
- Prevent rendering plain markdown when highlights are pending
- Show ReactMarkdown fallback only when no highlights need to be applied
- Fixes default article highlights not showing
2025-10-05 21:15:30 +01:00
Gigi
cdbb920a5f fix: resolve linter errors
- Add missing useMemo import to Bookmarks component
- Remove unused NostrEvent import from contactService
- All ESLint checks passing
- All TypeScript type checks passing
2025-10-05 21:13:39 +01:00
Gigi
cc311c7dc4 fix: classify highlights before passing to ContentPanel
- Add classifiedHighlights memo in Bookmarks to ensure highlights have level property
- Pass classified highlights to ContentPanel so color-coded rendering works
- Reduce reader border-radius from 12px to 8px to reduce visual separation
- Fixes highlights not showing with proper colors on default article
2025-10-05 20:26:03 +01:00
Gigi
d4d54b1a7c fix: position toggle buttons directly adjacent to main panel
- Reduce padding on collapsed containers to minimal spacing
- Move spacing from pane containers to content containers
- Toggle buttons now appear immediately next to article view with no gap
2025-10-05 20:20:28 +01:00
Gigi
235d6e33a9 fix: make panel toggle buttons stick to main content
- Remove grid gap and use padding on expanded panels instead
- Toggle buttons now appear directly adjacent to main panel when collapsed
- Maintain visual spacing only when panels are expanded
- Improves UX by making collapse/expand buttons more accessible
2025-10-05 20:19:03 +01:00
Gigi
0fe1085457 feat: always show friends and user highlight buttons
- Show friends and user highlight buttons regardless of login status
- Disable buttons when user is not logged in (instead of hiding them)
- Add helpful tooltips indicating login is required
- Add disabled state styling with reduced opacity and not-allowed cursor
2025-10-05 20:18:06 +01:00
Gigi
65e7709c63 fix: remove Highlights title and count from panel, fix markdown rendering
- Remove 'Highlights' text and count number to save space in panel
- Fix markdown rendering fallback to always show content when finalHtml is not ready
- Simplify render logic by removing highlight count condition that prevented content display
2025-10-05 20:17:23 +01:00
Gigi
17b5ffd96e feat: implement three-level highlight system
- Add three highlight levels: nostrverse (all), friends (followed), and mine (user's own)
- Create contactService to fetch user's follow list from kind 3 events
- Add three configurable colors in settings (purple, orange, yellow defaults)
- Replace mode switcher with independent toggle buttons for each level
- Update highlight rendering to apply level-specific colors using CSS custom properties
- Add CSS styles for three-level highlights in both marker and underline modes
- Classify highlights dynamically based on user's context and follow list
- All three levels can be shown/hidden independently via toggle buttons
2025-10-05 20:11:10 +01:00
Gigi
7f95eae405 fix: ensure highlights are shown for markdown content
- Only show raw ReactMarkdown when there are no highlights
- Wait for finalHtml (with highlights) when highlights are present
- Prevents highlights from being bypassed during markdown conversion
2025-10-05 20:01:41 +01:00
Gigi
8f1e5e1082 fix: prevent highlight bleeding into sidebar
- Add overflow-x: hidden and contain: layout style to .pane.main
- Add overflow: hidden and contain: layout style to .reader
- Add contain: layout style to highlight elements
- Prevents yellow highlights from bleeding into the right sidebar
2025-10-05 19:08:43 +01:00
11 changed files with 381 additions and 68 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.1.9",
"version": "0.1.10",
"description": "A minimal nostr client for bookmark management",
"type": "module",
"scripts": {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
@@ -8,6 +8,7 @@ import { Highlight } from '../types/highlights'
import { BookmarkList } from './BookmarkList'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService'
import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel'
import { ReadableContent } from '../services/readerService'
@@ -16,7 +17,7 @@ import Toast from './Toast'
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { loadContent, BookmarkReference } from '../utils/contentLoader'
import { HighlightMode } from './HighlightsPanel'
import { HighlightVisibility } from './HighlightsPanel'
export type ViewMode = 'compact' | 'cards' | 'large'
interface BookmarksProps {
@@ -40,7 +41,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const [showSettings, setShowSettings] = useState(false)
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
const [highlightMode, setHighlightMode] = useState<HighlightMode>('others')
const [highlightVisibility, setHighlightVisibility] = useState<HighlightVisibility>({
nostrverse: true,
friends: true,
mine: true
})
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const eventStore = useEventStore()
@@ -72,8 +78,15 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
if (!relayPool || !activeAccount) return
handleFetchBookmarks()
handleFetchHighlights()
handleFetchContacts()
}, [relayPool, activeAccount?.pubkey])
const handleFetchContacts = async () => {
if (!relayPool || !activeAccount) return
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
setFollowedPubkeys(contacts)
}
// Apply UI settings
useEffect(() => {
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
@@ -115,6 +128,19 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}
}
// Classify highlights with levels based on user context
const classifiedHighlights = useMemo(() => {
return highlights.map(h => {
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
if (h.pubkey === activeAccount?.pubkey) {
level = 'mine'
} else if (followedPubkeys.has(h.pubkey)) {
level = 'friends'
}
return { ...h, level }
})
}, [highlights, activeAccount?.pubkey, followedPubkeys])
const handleSelectUrl = async (url: string, bookmark?: BookmarkReference) => {
if (!relayPool) return
@@ -169,7 +195,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
markdown={readerContent?.markdown}
image={readerContent?.image}
selectedUrl={selectedUrl}
highlights={highlights}
highlights={classifiedHighlights}
showUnderlines={showUnderlines}
highlightStyle={settings.highlightStyle || 'marker'}
highlightColor={settings.highlightColor || '#ffff00'}
@@ -194,8 +220,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
onRefresh={handleFetchHighlights}
onHighlightClick={setSelectedHighlightId}
currentUserPubkey={activeAccount?.pubkey}
highlightMode={highlightMode}
onHighlightModeChange={setHighlightMode}
highlightVisibility={highlightVisibility}
onHighlightVisibilityChange={setHighlightVisibility}
followedPubkeys={followedPubkeys}
/>
</div>
</div>

View File

@@ -74,6 +74,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
return sourceHtml
}, [html, renderedHtml, markdown, relevantHighlights, showUnderlines, highlightStyle])
// Check if we need to wait for HTML conversion
const needsHtmlConversion = markdown && !renderedHtml
const shouldShowContent = !needsHtmlConversion || relevantHighlights.length === 0
// Attach click handlers to highlight marks
useEffect(() => {
if (!onHighlightClick || !contentRef.current) return
@@ -184,24 +188,24 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</div>
)}
{markdown || html ? (
finalHtml ? (
<div
ref={contentRef}
className={markdown ? "reader-markdown" : "reader-html"}
dangerouslySetInnerHTML={{ __html: finalHtml }}
/>
) : markdown ? (
<div
ref={contentRef}
className="reader-markdown"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
</div>
) : (
<div className="reader-html" ref={contentRef} />
)
finalHtml || (markdown && shouldShowContent) ? (
finalHtml ? (
<div
ref={contentRef}
className={markdown ? "reader-markdown" : "reader-html"}
dangerouslySetInnerHTML={{ __html: finalHtml }}
/>
) : (
<div
ref={contentRef}
className="reader-markdown"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
</div>
)
) : null
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>

View File

@@ -1,10 +1,14 @@
import React, { useMemo, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
export type HighlightMode = 'mine' | 'others'
export interface HighlightVisibility {
nostrverse: boolean
friends: boolean
mine: boolean
}
interface HighlightsPanelProps {
highlights: Highlight[]
@@ -18,8 +22,9 @@ interface HighlightsPanelProps {
onRefresh?: () => void
onHighlightClick?: (highlightId: string) => void
currentUserPubkey?: string
highlightMode?: HighlightMode
onHighlightModeChange?: (mode: HighlightMode) => void
highlightVisibility?: HighlightVisibility
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
followedPubkeys?: Set<string>
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -34,8 +39,9 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onRefresh,
onHighlightClick,
currentUserPubkey,
highlightMode = 'others',
onHighlightModeChange
highlightVisibility = { nostrverse: true, friends: true, mine: true },
onHighlightVisibilityChange,
followedPubkeys = new Set()
}) => {
const [showUnderlines, setShowUnderlines] = useState(true)
@@ -45,7 +51,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onToggleUnderlines?.(newValue)
}
// Filter highlights based on mode and URL
// Filter highlights based on visibility levels and URL
const filteredHighlights = useMemo(() => {
if (!selectedUrl) return highlights
@@ -75,18 +81,25 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
})
}
// Filter by mode (mine vs others)
if (!currentUserPubkey) {
// If no user is logged in, show all highlights (others mode only makes sense)
return urlFiltered
}
if (highlightMode === 'mine') {
return urlFiltered.filter(h => h.pubkey === currentUserPubkey)
} else {
return urlFiltered.filter(h => h.pubkey !== currentUserPubkey)
}
}, [highlights, selectedUrl, highlightMode, currentUserPubkey])
// Classify and filter by visibility levels
return urlFiltered
.map(h => {
// Classify highlight level
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
if (h.pubkey === currentUserPubkey) {
level = 'mine'
} else if (followedPubkeys.has(h.pubkey)) {
level = 'friends'
}
return { ...h, level }
})
.filter(h => {
// Filter by visibility settings
if (h.level === 'mine') return highlightVisibility.mine
if (h.level === 'friends') return highlightVisibility.friends
return highlightVisibility.nostrverse
})
}, [highlights, selectedUrl, highlightVisibility, currentUserPubkey, followedPubkeys])
if (isCollapsed) {
const hasHighlights = filteredHighlights.length > 0
@@ -109,30 +122,47 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
return (
<div className="highlights-container">
<div className="highlights-header">
<div className="highlights-title">
<FontAwesomeIcon icon={faHighlighter} />
<h3>Highlights</h3>
{!loading && <span className="count">({filteredHighlights.length})</span>}
</div>
<div className="highlights-actions">
{currentUserPubkey && onHighlightModeChange && (
<div className="highlight-mode-toggle">
{onHighlightVisibilityChange && (
<div className="highlight-level-toggles">
<button
onClick={() => onHighlightModeChange('mine')}
className={`mode-btn ${highlightMode === 'mine' ? 'active' : ''}`}
title="My highlights"
aria-label="Show my highlights"
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
nostrverse: !highlightVisibility.nostrverse
})}
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
title="Toggle nostrverse highlights"
aria-label="Toggle nostrverse highlights"
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
>
<FontAwesomeIcon icon={faUser} />
<FontAwesomeIcon icon={faGlobe} />
</button>
<button
onClick={() => onHighlightModeChange('others')}
className={`mode-btn ${highlightMode === 'others' ? 'active' : ''}`}
title="Other highlights"
aria-label="Show highlights from others"
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
friends: !highlightVisibility.friends
})}
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
aria-label="Toggle friends highlights"
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
<button
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
mine: !highlightVisibility.mine
})}
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
aria-label="Toggle my highlights"
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
disabled={!currentUserPubkey}
>
<FontAwesomeIcon icon={faUser} />
</button>
</div>
)}
{onRefresh && (

View File

@@ -122,13 +122,37 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
</div>
<div className="setting-group setting-inline">
<label>Highlight Color</label>
<label>Highlight Color (Legacy)</label>
<ColorPicker
selectedColor={localSettings.highlightColor || '#ffff00'}
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColor: color })}
/>
</div>
<div className="setting-group setting-inline">
<label>My Highlights Color</label>
<ColorPicker
selectedColor={localSettings.highlightColorMine || '#eab308'}
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorMine: color })}
/>
</div>
<div className="setting-group setting-inline">
<label>Friends Highlights Color</label>
<ColorPicker
selectedColor={localSettings.highlightColorFriends || '#f97316'}
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorFriends: color })}
/>
</div>
<div className="setting-group setting-inline">
<label>Nostrverse Highlights Color</label>
<ColorPicker
selectedColor={localSettings.highlightColorNostrverse || '#9333ea'}
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorNostrverse: color })}
/>
</div>
<div className="setting-preview">
<div className="preview-label">Preview</div>
<div

View File

@@ -52,6 +52,11 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
if (fontKey !== 'system') loadFont(fontKey)
root.setProperty('--reading-font', getFontFamily(fontKey))
root.setProperty('--reading-font-size', `${settings.fontSize || 16}px`)
// Set highlight colors for three levels
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#eab308')
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
}, [settings])
const saveSettingsWithToast = useCallback(async (newSettings: UserSettings) => {

View File

@@ -99,6 +99,7 @@ body {
/* Bookmarks Styles */
.bookmarks-container {
text-align: left;
padding-left: 1rem;
}
.sidebar-header-bar {
@@ -180,7 +181,7 @@ body {
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 0.75rem 0 0 0;
padding: 0.75rem 0.5rem 0 0;
background: transparent;
border: none;
}
@@ -423,7 +424,7 @@ body {
.two-pane {
display: grid;
grid-template-columns: 360px 1fr;
gap: 1rem;
column-gap: 0;
height: calc(100vh - 4rem);
transition: grid-template-columns 0.3s ease;
}
@@ -436,7 +437,7 @@ body {
.three-pane {
display: grid;
grid-template-columns: 360px 1fr 360px;
gap: 1rem;
column-gap: 0;
height: calc(100vh - 4rem);
transition: grid-template-columns 0.3s ease;
}
@@ -464,6 +465,8 @@ body {
max-width: 900px;
margin: 0 auto;
padding: 0 2rem;
overflow-x: hidden;
contain: layout style;
}
.pane.highlights {
@@ -474,9 +477,11 @@ body {
.reader {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
border-radius: 8px;
padding: 1rem;
text-align: left;
overflow: hidden;
contain: layout style;
}
.reader.empty {
@@ -1171,13 +1176,14 @@ body {
flex-direction: column;
height: 100%;
overflow: hidden;
padding-right: 1rem;
}
.highlights-container.collapsed {
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 0.75rem 0 0 0;
padding: 0.75rem 0 0 0.5rem;
background: transparent;
border: none;
}
@@ -1289,6 +1295,48 @@ body {
color: #fff;
}
/* Three-level highlight toggles */
.highlight-level-toggles {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.highlight-level-toggles .level-toggle-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.375rem 0.5rem;
border-radius: 3px;
transition: all 0.2s;
font-size: 0.9rem;
}
.highlight-level-toggles .level-toggle-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.highlight-level-toggles .level-toggle-btn.active {
background: rgba(255, 255, 255, 0.1);
opacity: 1;
}
.highlight-level-toggles .level-toggle-btn:not(.active) {
opacity: 0.4;
}
.highlight-level-toggles .level-toggle-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.highlight-level-toggles .level-toggle-btn:disabled:hover {
background: none;
}
.refresh-highlights-btn,
.toggle-underlines-btn,
.toggle-highlights-btn {
@@ -1482,6 +1530,7 @@ body {
position: relative;
border-radius: 2px;
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
contain: layout style;
}
.content-highlight:hover,
@@ -1501,6 +1550,7 @@ body {
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.8);
text-decoration-thickness: 2px;
text-underline-offset: 2px;
contain: layout style;
}
.content-highlight-underline:hover {
@@ -1549,6 +1599,68 @@ body {
text-decoration: none;
}
/* Three-level highlight colors */
.content-highlight-marker.level-mine,
.content-highlight.level-mine {
background: color-mix(in srgb, var(--highlight-color-mine, #eab308) 35%, transparent);
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-mine, #eab308) 20%, transparent);
}
.content-highlight-marker.level-mine:hover,
.content-highlight.level-mine:hover {
background: color-mix(in srgb, var(--highlight-color-mine, #eab308) 50%, transparent);
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, #eab308) 30%, transparent);
}
.content-highlight-marker.level-friends,
.content-highlight.level-friends {
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 35%, transparent);
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-friends, #f97316) 20%, transparent);
}
.content-highlight-marker.level-friends:hover,
.content-highlight.level-friends:hover {
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 50%, transparent);
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-friends, #f97316) 30%, transparent);
}
.content-highlight-marker.level-nostrverse,
.content-highlight.level-nostrverse {
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 35%, transparent);
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 20%, transparent);
}
.content-highlight-marker.level-nostrverse:hover,
.content-highlight.level-nostrverse:hover {
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 50%, transparent);
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 30%, transparent);
}
/* Underline styles for three levels */
.content-highlight-underline.level-mine {
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #eab308) 80%, transparent);
}
.content-highlight-underline.level-mine:hover {
text-decoration-color: var(--highlight-color-mine, #eab308);
}
.content-highlight-underline.level-friends {
text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 80%, transparent);
}
.content-highlight-underline.level-friends:hover {
text-decoration-color: var(--highlight-color-friends, #f97316);
}
.content-highlight-underline.level-nostrverse {
text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 80%, transparent);
}
.content-highlight-underline.level-nostrverse:hover {
text-decoration-color: var(--highlight-color-nostrverse, #9333ea);
}
/* Ensure highlights work in both light and dark mode */
@media (prefers-color-scheme: light) {
.content-highlight,
@@ -1571,6 +1683,55 @@ body {
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1);
}
/* Three-level overrides for light mode */
.content-highlight-marker.level-mine,
.content-highlight.level-mine {
background: color-mix(in srgb, var(--highlight-color-mine, #eab308) 40%, transparent);
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-mine, #eab308) 15%, transparent);
}
.content-highlight-marker.level-mine:hover,
.content-highlight.level-mine:hover {
background: color-mix(in srgb, var(--highlight-color-mine, #eab308) 55%, transparent);
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-mine, #eab308) 25%, transparent);
}
.content-highlight-marker.level-friends,
.content-highlight.level-friends {
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 40%, transparent);
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-friends, #f97316) 15%, transparent);
}
.content-highlight-marker.level-friends:hover,
.content-highlight.level-friends:hover {
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 55%, transparent);
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent);
}
.content-highlight-marker.level-nostrverse,
.content-highlight.level-nostrverse {
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 40%, transparent);
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 15%, transparent);
}
.content-highlight-marker.level-nostrverse:hover,
.content-highlight.level-nostrverse:hover {
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 55%, transparent);
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent);
}
.content-highlight-underline.level-mine {
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #eab308) 90%, transparent);
}
.content-highlight-underline.level-friends {
text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 90%, transparent);
}
.content-highlight-underline.level-nostrverse {
text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 90%, transparent);
}
.highlight-indicator {
background: rgba(100, 108, 255, 0.15);
border-color: rgba(100, 108, 255, 0.4);

View File

@@ -0,0 +1,50 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
/**
* Fetches the contact list (follows) for a specific user
* @param relayPool - The relay pool to query
* @param pubkey - The user's public key
* @returns Set of pubkeys that the user follows
*/
export const fetchContacts = async (
relayPool: RelayPool,
pubkey: string
): Promise<Set<string>> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
const events = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [3], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
console.log('📊 Contact events fetched:', events.length)
if (events.length === 0) {
return new Set()
}
// Get the most recent contact list
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const contactList = sortedEvents[0]
// Extract pubkeys from 'p' tags
const followedPubkeys = new Set<string>()
for (const tag of contactList.tags) {
if (tag[0] === 'p' && tag[1]) {
followedPubkeys.add(tag[1])
}
}
console.log('👥 Followed contacts:', followedPubkeys.size)
return followedPubkeys
} catch (error) {
console.error('Failed to fetch contacts:', error)
return new Set()
}
}

View File

@@ -18,6 +18,10 @@ export interface UserSettings {
fontSize?: number
highlightStyle?: 'marker' | 'underline'
highlightColor?: string
// Three-level highlight colors
highlightColorNostrverse?: string
highlightColorFriends?: string
highlightColorMine?: string
}
export async function loadSettings(

View File

@@ -1,4 +1,6 @@
// NIP-84 Highlight types
export type HighlightLevel = 'nostrverse' | 'friends' | 'mine'
export interface Highlight {
id: string
pubkey: string
@@ -11,5 +13,7 @@ export interface Highlight {
author?: string // 'p' tag with 'author' role
context?: string // surrounding text context
comment?: string // optional comment about the highlight
// Level classification (computed based on user's context)
level?: HighlightLevel
}

View File

@@ -73,11 +73,13 @@ export function applyHighlightsToText(
// Add the highlighted text
const highlightedText = text.substring(match.startIndex, match.endIndex)
const levelClass = match.highlight.level ? ` level-${match.highlight.level}` : ''
result.push(
<mark
key={`highlight-${match.highlight.id}-${match.startIndex}`}
className="content-highlight"
className={`content-highlight${levelClass}`}
data-highlight-id={match.highlight.id}
data-highlight-level={match.highlight.level || 'nostrverse'}
title={`Highlighted ${new Date(match.highlight.created_at * 1000).toLocaleDateString()}`}
>
{highlightedText}
@@ -101,8 +103,10 @@ const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
// Helper to create a mark element for a highlight
function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement {
const mark = document.createElement('mark')
mark.className = `content-highlight-${highlightStyle}`
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
mark.className = `content-highlight-${highlightStyle}${levelClass}`
mark.setAttribute('data-highlight-id', highlight.id)
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
mark.textContent = matchText
return mark