Browse Source

fix highlights and deal with authorizations from extension

imwald
Silberengel 5 months ago
parent
commit
dd6aeb6bb2
  1. 115
      src/components/Note/Highlight/index.tsx
  2. 166
      src/components/PostEditor/HighlightEditor.tsx
  3. 89
      src/components/PostEditor/PostContent.tsx
  4. 3
      src/components/PostEditor/PostOptions.tsx
  5. 153
      src/lib/draft-event.ts
  6. 4
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  7. 10
      src/pages/primary/DiscussionsPage/ThreadCard.tsx
  8. 21
      src/pages/primary/DiscussionsPage/index.tsx
  9. 55
      src/providers/NostrProvider/index.tsx
  10. 19
      src/services/client.service.ts

115
src/components/Note/Highlight/index.tsx

@ -0,0 +1,115 @@ @@ -0,0 +1,115 @@
import { SecondaryPageLink } from '@/PageManager'
import { Event } from 'nostr-tools'
import { ExternalLink, Highlighter } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { nip19 } from 'nostr-tools'
import { toNote } from '@/lib/link'
export default function Highlight({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
// Extract the source (e-tag, a-tag, or r-tag)
const source = useMemo(() => {
const eTag = event.tags.find(tag => tag[0] === 'e')
if (eTag) {
const eventId = eTag[1]
return {
type: 'event' as const,
value: eventId,
bech32: nip19.noteEncode(eventId)
}
}
const aTag = event.tags.find(tag => tag[0] === 'a')
if (aTag) {
const [kind, pubkey, identifier] = aTag[1].split(':')
const relay = aTag[2]
return {
type: 'addressable' as const,
value: aTag[1],
bech32: nip19.naddrEncode({
kind: parseInt(kind),
pubkey,
identifier: identifier || '',
relays: relay ? [relay] : []
})
}
}
const rTag = event.tags.find(tag => tag[0] === 'r' && tag[2] === 'source')
if (rTag) {
return {
type: 'url' as const,
value: rTag[1],
bech32: rTag[1]
}
}
return null
}, [event.tags])
// Extract the context (optional comment/surrounding context)
const context = useMemo(() => {
const contextTag = event.tags.find(tag => tag[0] === 'context')
return contextTag?.[1] || ''
}, [event.tags])
return (
<div className={`relative border-l-4 border-yellow-500 bg-yellow-50/50 dark:bg-yellow-950/20 rounded-r-lg p-4 ${className || ''}`}>
<div className="flex items-start gap-3">
<Highlighter className="w-5 h-5 text-yellow-600 dark:text-yellow-500 shrink-0 mt-1" />
<div className="flex-1 min-w-0">
{/* Highlighted text */}
{event.content && (
<blockquote className="text-base font-normal mb-3 whitespace-pre-wrap break-words italic">
"{event.content}"
</blockquote>
)}
{/* Context (user's comment or surrounding context) - rendered as plaintext */}
{context && (
<div className="text-sm text-muted-foreground bg-background/50 rounded p-2 mb-3 whitespace-pre-wrap break-words">
{context}
</div>
)}
{/* Source link */}
{source && (
<div className="text-xs text-muted-foreground flex items-center gap-2">
<span>{t('Source')}:</span>
{source.type === 'url' ? (
<a
href={source.value}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline flex items-center gap-1"
>
{source.value.length > 50 ? source.value.substring(0, 50) + '...' : source.value}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<SecondaryPageLink
to={toNote(source.bech32)}
className="text-blue-500 hover:underline font-mono"
>
{source.type === 'event'
? `note1${source.bech32.substring(5, 13)}...`
: `naddr1${source.bech32.substring(6, 14)}...`
}
</SecondaryPageLink>
)}
</div>
)}
</div>
</div>
</div>
)
}

166
src/components/PostEditor/HighlightEditor.tsx

@ -0,0 +1,166 @@ @@ -0,0 +1,166 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { X } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { nip19 } from 'nostr-tools'
export interface HighlightData {
sourceType: 'nostr' | 'url'
sourceValue: string // nevent/naddr/note/hex for nostr, https:// URL for url
sourceHexId?: string // converted hex ID for nostr sources
description?: string // optional comment/description
}
interface HighlightEditorProps {
highlightData: HighlightData
setHighlightData: (data: HighlightData) => void
setIsHighlight: (value: boolean) => void
}
export default function HighlightEditor({
highlightData,
setHighlightData,
setIsHighlight
}: HighlightEditorProps) {
const { t } = useTranslation()
const [sourceInput, setSourceInput] = useState(highlightData.sourceValue)
const [description, setDescription] = useState(highlightData.description || '')
const [error, setError] = useState<string>('')
// Validate and parse the source input
useEffect(() => {
if (!sourceInput.trim()) {
setError('')
return
}
// Check if it's a URL
if (sourceInput.startsWith('https://')) {
setError('')
setHighlightData({
sourceType: 'url',
sourceValue: sourceInput,
description
})
return
}
// Try to parse as nostr identifier
try {
let hexId: string | undefined
// Check if it's already a hex ID (64 char hex string)
if (/^[a-f0-9]{64}$/i.test(sourceInput)) {
hexId = sourceInput.toLowerCase()
setError('')
setHighlightData({
sourceType: 'nostr',
sourceValue: sourceInput,
sourceHexId: hexId,
description
})
return
}
// Try to decode as nip19 identifier
const decoded = nip19.decode(sourceInput)
if (decoded.type === 'note') {
hexId = decoded.data
setError('')
setHighlightData({
sourceType: 'nostr',
sourceValue: sourceInput, // Keep original for reference
sourceHexId: hexId, // Store the hex ID
description
})
} else if (decoded.type === 'nevent') {
hexId = decoded.data.id
setError('')
setHighlightData({
sourceType: 'nostr',
sourceValue: sourceInput, // Keep the nevent for relay info
sourceHexId: hexId, // Store the hex ID
description
})
} else if (decoded.type === 'naddr') {
// For naddr, we need to keep the full naddr string to extract kind:pubkey:identifier
setError('')
setHighlightData({
sourceType: 'nostr',
sourceValue: sourceInput, // Keep the naddr for a-tag building
sourceHexId: undefined, // No hex ID for addressable events
description
})
} else {
setError(t('Invalid source. Please enter a note ID, nevent, naddr, hex ID, or URL.'))
return
}
} catch (err) {
setError(t('Invalid source. Please enter a note ID, nevent, naddr, hex ID, or URL.'))
}
}, [sourceInput, description, setHighlightData, t])
return (
<div className="rounded-lg border bg-muted/40 p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{t('Highlight Settings')}</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setIsHighlight(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="highlight-source">
{t('Source')} <span className="text-destructive">*</span>
</Label>
<Input
id="highlight-source"
value={sourceInput}
onChange={(e) => setSourceInput(e.target.value)}
placeholder={t('nevent1..., naddr1..., note1..., hex ID, or https://...')}
className={error ? 'border-destructive' : ''}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<p className="text-xs text-muted-foreground">
{t('Enter a Nostr event identifier (nevent, naddr, note, or hex ID) OR a web URL (https://). Not both.')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="highlight-context">
{t('Context')} <span className="text-muted-foreground text-xs">({t('optional')})</span>
</Label>
<Textarea
id="highlight-context"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('Add your comment or surrounding context for this highlight...')}
rows={3}
maxLength={500}
/>
<p className="text-xs text-muted-foreground">
{description.length}/500 {t('characters')}
</p>
</div>
<div className="text-xs text-muted-foreground bg-background/50 rounded p-2">
<p className="font-medium mb-1">{t('About Highlights (NIP-84)')}</p>
<p>
{t('The highlighted text goes in the main content. The source and optional context will be added as tags.')}
</p>
</div>
</div>
)
}

89
src/components/PostEditor/PostContent.tsx

@ -7,6 +7,7 @@ import { @@ -7,6 +7,7 @@ import {
createPublicMessageDraftEvent,
createPublicMessageReplyDraftEvent,
createShortTextNoteDraftEvent,
createHighlightDraftEvent,
deleteDraftEventCache
} from '@/lib/draft-event'
import { ExtendedKind } from '@/constants'
@ -15,7 +16,7 @@ import { useNostr } from '@/providers/NostrProvider' @@ -15,7 +16,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider'
import postEditorCache from '@/services/post-editor-cache.service'
import { TPollCreateData } from '@/types'
import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X } from 'lucide-react'
import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -27,6 +28,7 @@ import PostOptions from './PostOptions' @@ -27,6 +28,7 @@ import PostOptions from './PostOptions'
import PostRelaySelector from './PostRelaySelector'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
import Uploader from './Uploader'
import HighlightEditor, { HighlightData } from './HighlightEditor'
export default function PostContent({
defaultContent = '',
@ -49,7 +51,7 @@ export default function PostContent({ @@ -49,7 +51,7 @@ export default function PostContent({
{ file: File; progress: number; cancel: () => void }[]
>([])
const [showMoreOptions, setShowMoreOptions] = useState(false)
const [addClientTag, setAddClientTag] = useState(false)
const [addClientTag, setAddClientTag] = useState(true) // Default to true to always add client tag
const [mentions, setMentions] = useState<string[]>([])
const [isNsfw, setIsNsfw] = useState(false)
const [isPoll, setIsPoll] = useState(false)
@ -57,6 +59,12 @@ export default function PostContent({ @@ -57,6 +59,12 @@ export default function PostContent({
const [publicMessageRecipients, setPublicMessageRecipients] = useState<string[]>([])
const [isProtectedEvent, setIsProtectedEvent] = useState(false)
const [additionalRelayUrls, setAdditionalRelayUrls] = useState<string[]>([])
const [isHighlight, setIsHighlight] = useState(false)
const [highlightData, setHighlightData] = useState<HighlightData>({
sourceType: 'nostr',
sourceValue: '',
description: ''
})
const [pollCreateData, setPollCreateData] = useState<TPollCreateData>({
isMultipleChoice: false,
options: ['', ''],
@ -73,24 +81,10 @@ export default function PostContent({ @@ -73,24 +81,10 @@ export default function PostContent({
!uploadProgresses.length &&
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) &&
(!isPublicMessage || publicMessageRecipients.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) &&
(!isProtectedEvent || additionalRelayUrls.length > 0)
(!isProtectedEvent || additionalRelayUrls.length > 0) &&
(!isHighlight || highlightData.sourceValue.trim() !== '')
)
// Debug logging for public message replies
if (parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) {
console.log('Public message reply debug:', {
pubkey: !!pubkey,
text: !!text,
posting,
uploadProgresses: uploadProgresses.length,
isPoll,
pollCreateDataValid: !isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2,
publicMessageCheck: !isPublicMessage || publicMessageRecipients.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE,
protectedEventCheck: !isProtectedEvent || additionalRelayUrls.length > 0,
canPost: result
})
}
return result
}, [
pubkey,
@ -103,7 +97,9 @@ export default function PostContent({ @@ -103,7 +97,9 @@ export default function PostContent({
publicMessageRecipients,
parentEvent?.kind,
isProtectedEvent,
additionalRelayUrls
additionalRelayUrls,
isHighlight,
highlightData
])
useEffect(() => {
@ -124,7 +120,7 @@ export default function PostContent({ @@ -124,7 +120,7 @@ export default function PostContent({
relays: []
}
)
setAddClientTag(cachedSettings.addClientTag ?? false)
setAddClientTag(cachedSettings.addClientTag ?? true) // Default to true
}
return
}
@ -202,7 +198,20 @@ export default function PostContent({ @@ -202,7 +198,20 @@ export default function PostContent({
try {
let draftEvent
if (isPublicMessage) {
if (isHighlight) {
// For highlights, pass the original sourceValue which contains the full identifier
// The createHighlightDraftEvent function will parse it correctly
draftEvent = await createHighlightDraftEvent(
text,
highlightData.sourceType,
highlightData.sourceValue,
highlightData.description,
{
addClientTag,
isNsfw
}
)
} else if (isPublicMessage) {
draftEvent = await createPublicMessageDraftEvent(text, publicMessageRecipients, {
addClientTag,
isNsfw
@ -306,6 +315,11 @@ export default function PostContent({ @@ -306,6 +315,11 @@ export default function PostContent({
if (parentEvent) return
setIsPoll((prev) => !prev)
if (!isPoll) {
// When enabling poll mode, clear other modes
setIsPublicMessage(false)
setIsHighlight(false)
}
}
const handlePublicMessageToggle = () => {
@ -315,6 +329,19 @@ export default function PostContent({ @@ -315,6 +329,19 @@ export default function PostContent({
if (!isPublicMessage) {
// When enabling public message mode, clear other modes
setIsPoll(false)
setIsHighlight(false)
}
}
const handleHighlightToggle = () => {
if (parentEvent) return
setIsHighlight((prev) => !prev)
if (!isHighlight) {
// When enabling highlight mode, clear other modes and set client tag to true
setIsPoll(false)
setIsPublicMessage(false)
setAddClientTag(true)
}
}
@ -349,6 +376,8 @@ export default function PostContent({ @@ -349,6 +376,8 @@ export default function PostContent({
t('New Poll')
) : isPublicMessage ? (
t('New Public Message')
) : isHighlight ? (
t('New Highlight')
) : (
t('New Note')
)}
@ -381,6 +410,13 @@ export default function PostContent({ @@ -381,6 +410,13 @@ export default function PostContent({
setIsPoll={setIsPoll}
/>
)}
{isHighlight && (
<HighlightEditor
highlightData={highlightData}
setHighlightData={setHighlightData}
setIsHighlight={setIsHighlight}
/>
)}
{isPublicMessage && (
<div className="rounded-lg border bg-muted/40 p-3">
<div className="mb-2 text-sm font-medium">{t('Recipients')}</div>
@ -490,6 +526,17 @@ export default function PostContent({ @@ -490,6 +526,17 @@ export default function PostContent({
<MessageCircle />
</Button>
)}
{!parentEvent && (
<Button
variant="ghost"
size="icon"
title={t('Create Highlight')}
className={isHighlight ? 'bg-accent' : ''}
onClick={handleHighlightToggle}
>
<Highlighter />
</Button>
)}
<Button
variant="ghost"
size="icon"

3
src/components/PostEditor/PostOptions.tsx

@ -27,7 +27,8 @@ export default function PostOptions({ @@ -27,7 +27,8 @@ export default function PostOptions({
const { t } = useTranslation()
useEffect(() => {
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
const stored = window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG)
setAddClientTag(stored === null ? true : stored === 'true') // Default to true if not set
}, [])
if (!show) return null

153
src/lib/draft-event.ts

@ -146,6 +146,7 @@ export async function createShortTextNoteDraftEvent( @@ -146,6 +146,7 @@ export async function createShortTextNoteDraftEvent(
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
@ -243,6 +244,7 @@ export async function createCommentDraftEvent( @@ -243,6 +244,7 @@ export async function createCommentDraftEvent(
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
@ -311,6 +313,7 @@ export async function createPublicMessageReplyDraftEvent( @@ -311,6 +313,7 @@ export async function createPublicMessageReplyDraftEvent(
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
@ -360,6 +363,7 @@ export async function createPublicMessageDraftEvent( @@ -360,6 +363,7 @@ export async function createPublicMessageDraftEvent(
if (options.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options.isNsfw) {
@ -522,6 +526,7 @@ export async function createPollDraftEvent( @@ -522,6 +526,7 @@ export async function createPollDraftEvent(
if (addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (isNsfw) {
@ -847,6 +852,10 @@ function buildClientTag() { @@ -847,6 +852,10 @@ function buildClientTag() {
return ['client', 'jumble']
}
function buildAltTag() {
return ['alt', 'This event was published by https://jumble.imwald.eu.']
}
function buildNsfwTag() {
return ['content-warning', 'NSFW']
}
@ -863,3 +872,147 @@ function trimTagEnd(tag: string[]) { @@ -863,3 +872,147 @@ function trimTagEnd(tag: string[]) {
return tag.slice(0, endIndex + 1)
}
/**
* Create a highlight draft event (NIP-84 kind 9802)
* @param highlightedText - The highlighted text (goes in .content)
* @param sourceType - Type of source ('nostr' or 'url')
* @param sourceValue - The source identifier (hex ID, naddr) or URL
* @param description - Optional comment/description
* @param options - Additional options (client tag, nsfw)
*/
export async function createHighlightDraftEvent(
highlightedText: string,
sourceType: 'nostr' | 'url',
sourceValue: string,
description?: string,
options?: {
addClientTag?: boolean
isNsfw?: boolean
}
): Promise<TDraftEvent> {
const tags: string[][] = []
// Add source tag (e or a tag for nostr, r tag for URL)
if (sourceType === 'nostr') {
// Check if it's an naddr (addressable event)
if (sourceValue.startsWith('naddr')) {
try {
const decoded = nip19.decode(sourceValue)
if (decoded.type === 'naddr') {
const { kind, pubkey, identifier } = decoded.data
const relays = decoded.data.relays && decoded.data.relays.length > 0
? decoded.data.relays[0]
: ''
// Build a-tag: ["a", "<kind>:<pubkey>:<d-identifier>", <relay-url>]
// Format: kind:pubkey:d-tag-value
const aTagValue = `${kind}:${pubkey}:${identifier}`
if (relays) {
tags.push(['a', aTagValue, relays])
} else {
tags.push(['a', aTagValue])
}
}
} catch (err) {
console.error('Failed to decode naddr:', err)
}
} else if (sourceValue.startsWith('nevent')) {
// Handle nevent
try {
const decoded = nip19.decode(sourceValue)
if (decoded.type === 'nevent') {
const eventId = decoded.data.id
const relays = decoded.data.relays && decoded.data.relays.length > 0
? decoded.data.relays[0]
: client.getEventHint(eventId)
const author = decoded.data.author
// Build e-tag: ["e", <event-id>, <relay-url>, <author-pubkey>]
if (author) {
tags.push(trimTagEnd(['e', eventId, relays, author]))
} else if (relays) {
tags.push(['e', eventId, relays])
} else {
tags.push(['e', eventId])
}
}
} catch (err) {
console.error('Failed to decode nevent:', err)
}
} else if (sourceValue.startsWith('note')) {
// Handle note1... (bech32 encoded event ID)
try {
const decoded = nip19.decode(sourceValue)
if (decoded.type === 'note') {
const eventId = decoded.data
const relay = client.getEventHint(eventId)
// Build e-tag: ["e", <event-id>, <relay-url>]
if (relay) {
tags.push(['e', eventId, relay])
} else {
tags.push(['e', eventId])
}
}
} catch (err) {
console.error('Failed to decode note:', err)
}
} else {
// Regular hex event ID
const relay = client.getEventHint(sourceValue)
if (relay) {
tags.push(['e', sourceValue, relay])
} else {
tags.push(['e', sourceValue])
}
}
} else if (sourceType === 'url') {
// Add r-tag with 'source' attribute
tags.push(['r', sourceValue, 'source'])
}
// Add context tag if provided (user's comment about the highlight)
// NIP-84 specifies using 'context' for additional context around the highlight
if (description && description.trim()) {
tags.push(['context', description.trim()])
}
// Add p-tag for the author of the source material (if we can determine it)
if (sourceType === 'nostr') {
if (sourceValue.startsWith('naddr')) {
try {
const decoded = nip19.decode(sourceValue)
if (decoded.type === 'naddr') {
const { pubkey } = decoded.data
tags.push(['p', pubkey])
}
} catch (err) {
// Already logged above
}
} else if (sourceValue.startsWith('nevent')) {
try {
const decoded = nip19.decode(sourceValue)
if (decoded.type === 'nevent' && decoded.data.author) {
tags.push(['p', decoded.data.author])
}
} catch (err) {
// Already logged above
}
}
// Note: For regular event IDs, we don't have the author pubkey readily available
}
// Add optional tags
if (options?.addClientTag) {
tags.push(buildClientTag())
tags.push(buildAltTag())
}
if (options?.isNsfw) {
tags.push(buildNsfwTag())
}
return setDraftEventCache({
kind: 9802, // NIP-84 highlight kind
tags,
content: highlightedText
})
}

4
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -252,8 +252,8 @@ export default function CreateThreadDialog({ @@ -252,8 +252,8 @@ export default function CreateThreadDialog({
const selectedTopicInfo = DISCUSSION_TOPICS.find(t => t.id === selectedTopic) || DISCUSSION_TOPICS[0]
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4">
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto relative bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-xl font-semibold">{t('Create New Thread')}</CardTitle>
<Button

10
src/pages/primary/DiscussionsPage/ThreadCard.tsx

@ -78,16 +78,6 @@ export default function ThreadCard({ thread, onThreadClick, className, subtopics @@ -78,16 +78,6 @@ export default function ThreadCard({ thread, onThreadClick, className, subtopics
return false
})
// Debug logging
if (thread.content.includes('readings')) {
console.log('DEBUG ThreadCard:', {
threadId: thread.id,
content: thread.content.substring(0, 50),
allTopics,
subtopics,
matchingSubtopics
})
}
// Format relay name for display
const formatRelayName = (relaySource: string) => {

21
src/pages/primary/DiscussionsPage/index.tsx

@ -239,9 +239,7 @@ const DiscussionsPage = forwardRef((_, ref) => { @@ -239,9 +239,7 @@ const DiscussionsPage = forwardRef((_, ref) => {
}
])
// Debug: Show date range of fetched events
// Filter and sort threads
// Filter and sort threads
const validThreads = events
.filter(event => {
// Ensure it has a title tag
@ -829,11 +827,6 @@ const DiscussionsPage = forwardRef((_, ref) => { @@ -829,11 +827,6 @@ const DiscussionsPage = forwardRef((_, ref) => {
</Card>
) : viewMode === 'grouped' && selectedTopic === 'all' ? (
<div className="space-y-6">
{Object.entries(groupedThreads).length === 0 && (
<div className="text-center py-8 text-muted-foreground">
Debug: No grouped threads found. groupedThreads keys: {Object.keys(groupedThreads).join(', ')}
</div>
)}
{Object.entries(groupedThreads).map(([topicId, topicThreads]) => {
const topicInfo = DISCUSSION_TOPICS.find(t => t.id === topicId)
if (!topicInfo || topicThreads.length === 0) return null
@ -880,18 +873,6 @@ const DiscussionsPage = forwardRef((_, ref) => { @@ -880,18 +873,6 @@ const DiscussionsPage = forwardRef((_, ref) => {
? ['readings'] // Always include readings for literature threads
: getDynamicSubtopics(topicAnalysis.get(categorizedTopic), 3)
// Debug logging
if (thread.content.includes('readin')) {
console.log('DEBUG DiscussionsPage ThreadCard props:', {
threadId: thread.id,
content: thread.content.substring(0, 50),
allTopics: extractAllTopics(thread),
categorizedTopic,
threadSubtopics,
availableTopicIds,
topicAnalysisForCategorizedTopic: topicAnalysis.get(categorizedTopic)
})
}
return (
<ThreadCard

55
src/providers/NostrProvider/index.tsx

@ -114,6 +114,43 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -114,6 +114,43 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [signer, setSigner] = useState<ISigner | null>(null)
const [openLoginDialog, setOpenLoginDialog] = useState(false)
const [profile, setProfile] = useState<TProfile | null>(null)
// Cleanup on page unload to prevent extension UI issues
useEffect(() => {
const handleBeforeUnload = () => {
// Try to clean up any pending operations
if (signer && 'disconnect' in signer) {
try {
(signer as any).disconnect()
} catch (error) {
console.warn('Failed to disconnect signer:', error)
}
}
}
const handleUnload = () => {
// Additional cleanup for extensions that might leave UI elements
try {
// Clear any pending timeouts or intervals
if (window.nostr && typeof window.nostr === 'object') {
// Some extensions might have cleanup methods
if ('cleanup' in window.nostr && typeof window.nostr.cleanup === 'function') {
window.nostr.cleanup()
}
}
} catch (error) {
console.warn('Extension cleanup failed:', error)
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
window.addEventListener('unload', handleUnload)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('unload', handleUnload)
}
}, [signer])
const [profileEvent, setProfileEvent] = useState<Event | null>(null)
const [relayList, setRelayList] = useState<TRelayList | null>(null)
const [followListEvent, setFollowListEvent] = useState<Event | null>(null)
@ -604,7 +641,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -604,7 +641,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const signEvent = async (draftEvent: TDraftEvent) => {
const event = await signer?.signEvent(draftEvent)
// Add timeout to prevent hanging
const signEventWithTimeout = new Promise(async (resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Signing request timed out. Your Nostr extension may be waiting for authorization. Try closing this tab and restarting your browser to surface any pending authorization requests from your extension.'))
}, 30000) // 30 second timeout
try {
const event = await signer?.signEvent(draftEvent)
clearTimeout(timeout)
resolve(event)
} catch (error) {
clearTimeout(timeout)
reject(error)
}
})
const event = await signEventWithTimeout as VerifiedEvent
if (!event) {
throw new Error('sign event failed')
}

19
src/services/client.service.ts

@ -142,12 +142,9 @@ class ClientService extends EventTarget { @@ -142,12 +142,9 @@ class ClientService extends EventTarget {
// Use current user's relay list
const relayList = this.pubkey ? await this.fetchRelayList(this.pubkey) : { write: [], read: [] }
console.log('DEBUG: User relay list write URLs:', relayList?.write)
const senderWriteRelays = relayList?.write.slice(0, 6) ?? []
console.log('DEBUG: Selected sender write relays:', senderWriteRelays)
const recipientReadRelays = Array.from(new Set(_additionalRelayUrls))
relays = senderWriteRelays.concat(recipientReadRelays)
console.log('DEBUG: Final relay URLs before optimization:', relays)
}
if (!relays.length) {
@ -241,7 +238,6 @@ class ClientService extends EventTarget { @@ -241,7 +238,6 @@ class ClientService extends EventTarget {
totalCount: number
}> {
const uniqueRelayUrls = this.optimizeRelaySelection(Array.from(new Set(relayUrls)))
console.log('DEBUG: uniqueRelayUrls after optimization:', uniqueRelayUrls)
const relayStatuses: Array<{
url: string
@ -272,7 +268,6 @@ class ClientService extends EventTarget { @@ -272,7 +268,6 @@ class ClientService extends EventTarget {
this.emitNewEvent(event)
}
resolved = true
console.log('DEBUG: Publishing completed. relayStatuses:', relayStatuses)
resolve({
success: isSuccess,
relayStatuses,
@ -285,7 +280,6 @@ class ClientService extends EventTarget { @@ -285,7 +280,6 @@ class ClientService extends EventTarget {
// Handle case where no relays succeed
if (finishedCount >= uniqueRelayUrls.length && !resolved && successCount === 0) {
resolved = true
console.log('DEBUG: All relays failed. relayStatuses:', relayStatuses)
const aggregateError = new AggregateError(
errors.map(
({ url, error }) => {
@ -328,17 +322,14 @@ class ClientService extends EventTarget { @@ -328,17 +322,14 @@ class ClientService extends EventTarget {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this
console.log('DEBUG: Attempting to publish to relay:', url)
try {
try {
// Throttle requests to prevent "too many concurrent REQs" errors
await this.throttleRequest(url)
const relay = await this.pool.ensureRelay(url)
relay.publishTimeout = 8_000 // 8s
console.log('DEBUG: Publishing to relay:', url)
await relay.publish(event)
console.log('DEBUG: Successfully published to relay:', url)
this.trackEventSeenOn(event.id, relay)
this.recordSuccess(url)
successCount++
@ -351,7 +342,6 @@ class ClientService extends EventTarget { @@ -351,7 +342,6 @@ class ClientService extends EventTarget {
checkCompletion()
} catch (error) {
console.log('DEBUG: Failed to publish to relay:', url, 'Error:', error)
let errorMessage = 'Unknown error'
if (error instanceof Error) {
errorMessage = error.message || 'Empty error message'
@ -387,16 +377,12 @@ class ClientService extends EventTarget { @@ -387,16 +377,12 @@ class ClientService extends EventTarget {
error.message.startsWith('auth-required') &&
!!that.signer
) {
console.log('DEBUG: Attempting authentication for relay:', url)
try {
// Throttle auth requests too
await this.throttleRequest(url)
const relay = await this.pool.ensureRelay(url)
// Attempt auth with proper timeout handling
console.log('DEBUG: Starting auth for relay:', url)
const authPromise = relay.auth((authEvt: EventTemplate) => {
// Ensure the auth event has the correct pubkey
const authEventWithPubkey = { ...authEvt, pubkey: that.pubkey }
@ -408,10 +394,8 @@ class ClientService extends EventTarget { @@ -408,10 +394,8 @@ class ClientService extends EventTarget {
})
await Promise.race([authPromise, authTimeoutPromise])
console.log('DEBUG: Auth successful for relay:', url)
await relay.publish(event)
console.log('DEBUG: Publish successful for relay:', url)
this.trackEventSeenOn(event.id, relay)
this.recordSuccess(url)
successCount++
@ -425,7 +409,6 @@ class ClientService extends EventTarget { @@ -425,7 +409,6 @@ class ClientService extends EventTarget {
checkCompletion()
} catch (authError) {
console.log('DEBUG: Auth failed for relay:', url, 'Error:', authError)
let authErrorMessage = 'Unknown auth error'
if (authError instanceof Error) {
authErrorMessage = authError.message || 'Empty auth error message'

Loading…
Cancel
Save