diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index a09df8ce..2f1c2410 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -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 = ({ relayPool, onLogout }) => { const [showSettings, setShowSettings] = useState(false) const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState(undefined) const [currentArticleEventId, setCurrentArticleEventId] = useState(undefined) - const [highlightMode, setHighlightMode] = useState('others') + const [highlightVisibility, setHighlightVisibility] = useState({ + nostrverse: true, + friends: true, + mine: true + }) + const [followedPubkeys, setFollowedPubkeys] = useState>(new Set()) const activeAccount = Hooks.useActiveAccount() const accountManager = Hooks.useAccountManager() const eventStore = useEventStore() @@ -72,8 +78,15 @@ const Bookmarks: React.FC = ({ 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) @@ -194,8 +207,9 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { onRefresh={handleFetchHighlights} onHighlightClick={setSelectedHighlightId} currentUserPubkey={activeAccount?.pubkey} - highlightMode={highlightMode} - onHighlightModeChange={setHighlightMode} + highlightVisibility={highlightVisibility} + onHighlightVisibilityChange={setHighlightVisibility} + followedPubkeys={followedPubkeys} /> diff --git a/src/components/HighlightsPanel.tsx b/src/components/HighlightsPanel.tsx index 0902ed9f..9e6ea6c7 100644 --- a/src/components/HighlightsPanel.tsx +++ b/src/components/HighlightsPanel.tsx @@ -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 } export const HighlightsPanel: React.FC = ({ @@ -34,8 +39,9 @@ export const HighlightsPanel: React.FC = ({ 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 = ({ 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 = ({ }) } - // 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 @@ -115,24 +128,46 @@ export const HighlightsPanel: React.FC = ({ {!loading && ({filteredHighlights.length})}
- {currentUserPubkey && onHighlightModeChange && ( -
+ {onHighlightVisibilityChange && ( +
+ {currentUserPubkey && ( + + )}
)} {onRefresh && ( diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index becd51b5..3ded4754 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -122,13 +122,37 @@ const Settings: React.FC = ({ settings, onSave, onClose }) => {
- + setLocalSettings({ ...localSettings, highlightColor: color })} />
+
+ + setLocalSettings({ ...localSettings, highlightColorMine: color })} + /> +
+ +
+ + setLocalSettings({ ...localSettings, highlightColorFriends: color })} + /> +
+ +
+ + setLocalSettings({ ...localSettings, highlightColorNostrverse: color })} + /> +
+
Preview
{ diff --git a/src/index.css b/src/index.css index e7a2f2b0..cea0dd7a 100644 --- a/src/index.css +++ b/src/index.css @@ -1293,6 +1293,39 @@ 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; +} + .refresh-highlights-btn, .toggle-underlines-btn, .toggle-highlights-btn { @@ -1555,6 +1588,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, @@ -1577,6 +1672,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); diff --git a/src/services/contactService.ts b/src/services/contactService.ts new file mode 100644 index 00000000..f3a9cc77 --- /dev/null +++ b/src/services/contactService.ts @@ -0,0 +1,51 @@ +import { RelayPool, completeOnEose } from 'applesauce-relay' +import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs' +import { NostrEvent } from 'nostr-tools' + +/** + * 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> => { + 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() + 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() + } +} diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 3e7c1317..d0b3f778 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -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( diff --git a/src/types/highlights.ts b/src/types/highlights.ts index faec449c..7e0744ab 100644 --- a/src/types/highlights.ts +++ b/src/types/highlights.ts @@ -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 } diff --git a/src/utils/highlightMatching.tsx b/src/utils/highlightMatching.tsx index 31d70883..d6243836 100644 --- a/src/utils/highlightMatching.tsx +++ b/src/utils/highlightMatching.tsx @@ -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( {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