Add keyboard mode toggle and QR scanner improvements
- Add keyboard mode toggle button (⇧K) in sidebar - Triple-Escape to quickly exit keyboard mode - Extract QrScannerModal to shared component - Add QR scanner for NWC wallet connection in Settings - Update Help page with keyboard toggle documentation - Fix keyboard navigation getting stuck on inbox - Improve feed loading after login (loads immediately) - DM conversation page layout improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,78 +1,12 @@
|
|||||||
|
import QrScannerModal from '@/components/QrScannerModal'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ScanLine, X } from 'lucide-react'
|
import { ScanLine } from 'lucide-react'
|
||||||
import QrScanner from 'qr-scanner'
|
|
||||||
|
|
||||||
function QrScannerModal({
|
|
||||||
onScan,
|
|
||||||
onClose
|
|
||||||
}: {
|
|
||||||
onScan: (result: string) => void
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
|
||||||
const scannerRef = useRef<QrScanner | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleScan = useCallback(
|
|
||||||
(result: QrScanner.ScanResult) => {
|
|
||||||
onScan(result.data)
|
|
||||||
onClose()
|
|
||||||
},
|
|
||||||
[onScan, onClose]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!videoRef.current) return
|
|
||||||
|
|
||||||
const scanner = new QrScanner(videoRef.current, handleScan, {
|
|
||||||
preferredCamera: 'environment',
|
|
||||||
highlightScanRegion: true,
|
|
||||||
highlightCodeOutline: true
|
|
||||||
})
|
|
||||||
|
|
||||||
scannerRef.current = scanner
|
|
||||||
|
|
||||||
scanner.start().catch(() => {
|
|
||||||
setError(t('Failed to access camera'))
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
scanner.destroy()
|
|
||||||
}
|
|
||||||
}, [handleScan, t])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center">
|
|
||||||
<div className="relative w-full max-w-sm mx-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute -top-12 right-0 text-white hover:bg-white/20"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X className="h-6 w-6" />
|
|
||||||
</Button>
|
|
||||||
<div className="rounded-lg overflow-hidden bg-black">
|
|
||||||
{error ? (
|
|
||||||
<div className="p-8 text-center text-destructive">{error}</div>
|
|
||||||
) : (
|
|
||||||
<video ref={videoRef} className="w-full" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-center text-white/70 text-sm mt-4">
|
|
||||||
{t('Point camera at QR code')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PrivateKeyLogin({
|
export default function PrivateKeyLogin({
|
||||||
back,
|
back,
|
||||||
|
|||||||
@@ -23,15 +23,33 @@ export default function Help() {
|
|||||||
<AccordionContent className="pb-4">
|
<AccordionContent className="pb-4">
|
||||||
<div className="space-y-4 text-sm text-muted-foreground">
|
<div className="space-y-4 text-sm text-muted-foreground">
|
||||||
<p>{t('Navigate the app entirely with your keyboard:')}</p>
|
<p>{t('Navigate the app entirely with your keyboard:')}</p>
|
||||||
|
<p className="font-medium">{t('Toggle Keyboard Mode:')}</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<KeyBinding keys={['Arrow Up', 'Arrow Down']} description={t('Move between items in a list')} />
|
<KeyBinding keys={['⇧K']} description={t('Toggle keyboard navigation on/off')} />
|
||||||
<KeyBinding keys={['Arrow Left', 'Arrow Right']} description={t('Switch between columns (sidebar, feed, detail)')} />
|
<KeyBinding keys={['Esc', 'Esc', 'Esc']} description={t('Triple-Escape to quickly exit keyboard mode')} />
|
||||||
<KeyBinding keys={['Enter']} description={t('Open or activate the selected item')} />
|
|
||||||
<KeyBinding keys={['Escape']} description={t('Close current view or go to sidebar')} />
|
|
||||||
<KeyBinding keys={['Backspace']} description={t('Go back to previous view')} />
|
|
||||||
<KeyBinding keys={['Page Up', 'Page Down']} description={t('Jump to top or bottom of list')} />
|
|
||||||
<KeyBinding keys={['Tab']} description={t('Cycle through note actions (reply, repost, quote, react, zap)')} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs opacity-70">{t('You can also click the keyboard button in the sidebar to toggle.')}</p>
|
||||||
|
<p className="font-medium mt-4">{t('Movement:')}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<KeyBinding keys={['↑', '↓']} altKeys={['k', 'j']} description={t('Move between items in a list')} />
|
||||||
|
<KeyBinding keys={['Tab']} description={t('Switch to next column (Shift+Tab for previous)')} />
|
||||||
|
<KeyBinding keys={['Page Up', 'Page Down']} description={t('Jump to top or bottom of list')} />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium mt-4">{t('Actions:')}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<KeyBinding keys={['→', 'Enter']} altKeys={['l']} description={t('Activate the selected item')} />
|
||||||
|
<KeyBinding keys={['←']} altKeys={['h']} description={t('Go back (close panel or move to sidebar)')} />
|
||||||
|
<KeyBinding keys={['Escape']} description={t('Close current view or cancel')} />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium mt-4">{t('Note Actions (when a note is selected):')}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<KeyBinding keys={['r']} description={t('Reply')} />
|
||||||
|
<KeyBinding keys={['p']} description={t('Repost')} />
|
||||||
|
<KeyBinding keys={['q']} description={t('Quote')} />
|
||||||
|
<KeyBinding keys={['R']} description={t('React with emoji')} />
|
||||||
|
<KeyBinding keys={['z']} description={t('Zap (send sats)')} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs opacity-70 pt-2">{t('Selected items are centered on screen for easy viewing.')}</p>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@@ -156,18 +174,33 @@ export default function Help() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeyBinding({ keys, description }: { keys: string[]; description: string }) {
|
function KeyBinding({
|
||||||
|
keys,
|
||||||
|
altKeys,
|
||||||
|
description
|
||||||
|
}: {
|
||||||
|
keys: string[]
|
||||||
|
altKeys?: string[]
|
||||||
|
description: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{keys.map((key) => (
|
{keys.map((key) => (
|
||||||
<kbd
|
<kbd key={key} className="px-2 py-1 text-xs font-mono bg-muted border rounded">
|
||||||
key={key}
|
|
||||||
className="px-2 py-1 text-xs font-mono bg-muted border rounded"
|
|
||||||
>
|
|
||||||
{key}
|
{key}
|
||||||
</kbd>
|
</kbd>
|
||||||
))}
|
))}
|
||||||
|
{altKeys && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-muted-foreground mx-1">/</span>
|
||||||
|
{altKeys.map((key) => (
|
||||||
|
<kbd key={key} className="px-2 py-1 text-xs font-mono bg-muted border rounded">
|
||||||
|
{key}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span>{description}</span>
|
<span>{description}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ import { useFollowList } from '@/providers/FollowListProvider'
|
|||||||
|
|
||||||
interface MessageViewProps {
|
interface MessageViewProps {
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
|
hideHeader?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageView({ onBack }: MessageViewProps) {
|
export default function MessageView({ onBack, hideHeader }: MessageViewProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const {
|
const {
|
||||||
@@ -184,6 +185,20 @@ export default function MessageView({ onBack }: MessageViewProps) {
|
|||||||
lastMessageCountRef.current = 0
|
lastMessageCountRef.current = 0
|
||||||
}, [currentConversation])
|
}, [currentConversation])
|
||||||
|
|
||||||
|
// Scroll to bottom when conversation opens and messages are loaded
|
||||||
|
const hasMessages = messages.length > 0
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentConversation && hasMessages && scrollRef.current) {
|
||||||
|
// Use requestAnimationFrame to ensure DOM is ready
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||||
|
lastMessageCountRef.current = messages.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [currentConversation, hasMessages])
|
||||||
|
|
||||||
if (!currentConversation || !pubkey) {
|
if (!currentConversation || !pubkey) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -192,7 +207,8 @@ export default function MessageView({ onBack }: MessageViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header - show when not hidden, or when in selection mode */}
|
||||||
|
{(!hideHeader || isSelectionMode) && (
|
||||||
<div className="flex items-center gap-3 p-3 border-b">
|
<div className="flex items-center gap-3 p-3 border-b">
|
||||||
{isSelectionMode ? (
|
{isSelectionMode ? (
|
||||||
// Selection mode header
|
// Selection mode header
|
||||||
@@ -300,6 +316,7 @@ export default function MessageView({ onBack }: MessageViewProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="flex-1 relative overflow-hidden">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const NoteList = forwardRef<
|
|||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const { isEventDeleted } = useDeletedEvent()
|
const { isEventDeleted } = useDeletedEvent()
|
||||||
const { offsetSelection } = useKeyboardNavigation()
|
const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation()
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
const [initialLoading, setInitialLoading] = useState(false)
|
const [initialLoading, setInitialLoading] = useState(false)
|
||||||
@@ -370,6 +370,12 @@ const NoteList = forwardRef<
|
|||||||
initialLoading
|
initialLoading
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Register load more callback for keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
registerLoadMore(navColumn, handleLoadMore)
|
||||||
|
return () => unregisterLoadMore(navColumn)
|
||||||
|
}, [navColumn, handleLoadMore, registerLoadMore, unregisterLoadMore])
|
||||||
|
|
||||||
const showNewEvents = useCallback(() => {
|
const showNewEvents = useCallback(() => {
|
||||||
if (filteredNewEvents.length === 0) return
|
if (filteredNewEvents.length === 0) return
|
||||||
// Offset the selection by the number of new items being added at the top
|
// Offset the selection by the number of new items being added at the top
|
||||||
|
|||||||
71
src/components/QrScannerModal/index.tsx
Normal file
71
src/components/QrScannerModal/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import QrScanner from 'qr-scanner'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function QrScannerModal({
|
||||||
|
onScan,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
onScan: (result: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const scannerRef = useRef<QrScanner | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleScan = useCallback(
|
||||||
|
(result: QrScanner.ScanResult) => {
|
||||||
|
onScan(result.data)
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
[onScan, onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!videoRef.current) return
|
||||||
|
|
||||||
|
const scanner = new QrScanner(videoRef.current, handleScan, {
|
||||||
|
preferredCamera: 'environment',
|
||||||
|
highlightScanRegion: true,
|
||||||
|
highlightCodeOutline: true
|
||||||
|
})
|
||||||
|
|
||||||
|
scannerRef.current = scanner
|
||||||
|
|
||||||
|
scanner.start().catch(() => {
|
||||||
|
setError(t('Failed to access camera'))
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scanner.destroy()
|
||||||
|
}
|
||||||
|
}, [handleScan, t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center">
|
||||||
|
<div className="relative w-full max-w-sm mx-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute -top-12 right-0 text-white hover:bg-white/20"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
<div className="rounded-lg overflow-hidden bg-black">
|
||||||
|
{error ? (
|
||||||
|
<div className="p-8 text-center text-destructive">{error}</div>
|
||||||
|
) : (
|
||||||
|
<video ref={videoRef} className="w-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-white/70 text-sm mt-4">
|
||||||
|
{t('Point camera at QR code')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import AboutInfoDialog from '@/components/AboutInfoDialog'
|
import AboutInfoDialog from '@/components/AboutInfoDialog'
|
||||||
|
import QrScannerModal from '@/components/QrScannerModal'
|
||||||
import Donation from '@/components/Donation'
|
import Donation from '@/components/Donation'
|
||||||
import Emoji from '@/components/Emoji'
|
import Emoji from '@/components/Emoji'
|
||||||
import EmojiPackList from '@/components/EmojiPackList'
|
import EmojiPackList from '@/components/EmojiPackList'
|
||||||
@@ -54,7 +55,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider'
|
|||||||
import { useZap } from '@/providers/ZapProvider'
|
import { useZap } from '@/providers/ZapProvider'
|
||||||
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
|
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
|
||||||
import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types'
|
import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types'
|
||||||
import { disconnect, launchModal } from '@getalby/bitcoin-connect-react'
|
import { connectNWC, disconnect, launchModal } from '@getalby/bitcoin-connect-react'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Cog,
|
Cog,
|
||||||
@@ -71,6 +72,7 @@ import {
|
|||||||
PanelLeft,
|
PanelLeft,
|
||||||
PencilLine,
|
PencilLine,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
ScanLine,
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
Settings2,
|
||||||
Smile,
|
Smile,
|
||||||
@@ -80,7 +82,8 @@ import {
|
|||||||
import { kinds } from 'nostr-tools'
|
import { kinds } from 'nostr-tools'
|
||||||
import { forwardRef, HTMLProps, useCallback, useEffect, useRef, useState } from 'react'
|
import { forwardRef, HTMLProps, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
import { useKeyboardNavigation, useNavigationRegion, NavigationIntent } from '@/providers/KeyboardNavigationProvider'
|
||||||
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
|
||||||
type TEmojiTab = 'my-packs' | 'explore'
|
type TEmojiTab = 'my-packs' | 'explore'
|
||||||
|
|
||||||
@@ -114,57 +117,77 @@ export default function Settings() {
|
|||||||
const [selectedAccordionIndex, setSelectedAccordionIndex] = useState(-1)
|
const [selectedAccordionIndex, setSelectedAccordionIndex] = useState(-1)
|
||||||
const accordionRefs = useRef<(HTMLDivElement | null)[]>([])
|
const accordionRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
const { activeColumn, registerSettingsHandlers, unregisterSettingsHandlers } = useKeyboardNavigation()
|
const { activeColumn, scrollToCenter } = useKeyboardNavigation()
|
||||||
|
const { current: currentPage } = usePrimaryPage()
|
||||||
|
|
||||||
// Get the visible accordion items based on pubkey availability
|
// Get the visible accordion items based on pubkey availability
|
||||||
const visibleAccordionItems = pubkey
|
const visibleAccordionItems = pubkey
|
||||||
? ACCORDION_ITEMS
|
? ACCORDION_ITEMS
|
||||||
: ACCORDION_ITEMS.filter((item) => !['wallet', 'posts', 'emoji-packs', 'messaging'].includes(item))
|
: ACCORDION_ITEMS.filter((item) => !['wallet', 'posts', 'emoji-packs', 'messaging'].includes(item))
|
||||||
|
|
||||||
// Register keyboard handlers for settings page navigation
|
// Register as a navigation region - Settings decides what "up/down" means
|
||||||
useEffect(() => {
|
const handleSettingsIntent = useCallback(
|
||||||
if (activeColumn !== 1) {
|
(intent: NavigationIntent): boolean => {
|
||||||
setSelectedAccordionIndex(-1)
|
switch (intent) {
|
||||||
return
|
case 'up':
|
||||||
}
|
|
||||||
|
|
||||||
const handlers = {
|
|
||||||
onUp: () => {
|
|
||||||
setSelectedAccordionIndex((prev) => {
|
setSelectedAccordionIndex((prev) => {
|
||||||
const newIndex = prev <= 0 ? 0 : prev - 1
|
const newIndex = prev <= 0 ? 0 : prev - 1
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
accordionRefs.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
const el = accordionRefs.current[newIndex]
|
||||||
|
if (el) scrollToCenter(el)
|
||||||
}, 0)
|
}, 0)
|
||||||
return newIndex
|
return newIndex
|
||||||
})
|
})
|
||||||
},
|
return true
|
||||||
onDown: () => {
|
|
||||||
|
case 'down':
|
||||||
setSelectedAccordionIndex((prev) => {
|
setSelectedAccordionIndex((prev) => {
|
||||||
const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1)
|
const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
accordionRefs.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
const el = accordionRefs.current[newIndex]
|
||||||
|
if (el) scrollToCenter(el)
|
||||||
}, 0)
|
}, 0)
|
||||||
return newIndex
|
return newIndex
|
||||||
})
|
})
|
||||||
},
|
return true
|
||||||
onEnter: () => {
|
|
||||||
|
case 'activate':
|
||||||
if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) {
|
if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) {
|
||||||
const value = visibleAccordionItems[selectedAccordionIndex]
|
const value = visibleAccordionItems[selectedAccordionIndex]
|
||||||
setOpenSection((prev) => (prev === value ? '' : value))
|
setOpenSection((prev) => (prev === value ? '' : value))
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
},
|
return false
|
||||||
onEscape: () => {
|
|
||||||
|
case 'cancel':
|
||||||
if (openSection) {
|
if (openSection) {
|
||||||
setOpenSection('')
|
setOpenSection('')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerSettingsHandlers(handlers)
|
default:
|
||||||
return () => unregisterSettingsHandlers()
|
return false
|
||||||
}, [activeColumn, selectedAccordionIndex, openSection, visibleAccordionItems])
|
}
|
||||||
|
},
|
||||||
|
[selectedAccordionIndex, openSection, visibleAccordionItems, scrollToCenter]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register this component as a navigation region when it's active
|
||||||
|
useNavigationRegion(
|
||||||
|
'settings-accordion',
|
||||||
|
100, // High priority - handle intents before default handlers
|
||||||
|
() => activeColumn === 1 && currentPage === 'settings', // Only active when settings is displayed
|
||||||
|
handleSettingsIntent,
|
||||||
|
[handleSettingsIntent, activeColumn, currentPage]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset selection when column changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeColumn !== 1) {
|
||||||
|
setSelectedAccordionIndex(-1)
|
||||||
|
}
|
||||||
|
}, [activeColumn])
|
||||||
|
|
||||||
// Helper to get accordion index and check selection
|
// Helper to get accordion index and check selection
|
||||||
const getAccordionIndex = useCallback(
|
const getAccordionIndex = useCallback(
|
||||||
@@ -235,6 +258,16 @@ export default function Settings() {
|
|||||||
// Messaging settings
|
// Messaging settings
|
||||||
const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44())
|
const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44())
|
||||||
|
|
||||||
|
// Wallet QR scanner
|
||||||
|
const [showWalletScanner, setShowWalletScanner] = useState(false)
|
||||||
|
|
||||||
|
const handleWalletScan = useCallback((result: string) => {
|
||||||
|
// Check if it's a valid NWC URI
|
||||||
|
if (result.startsWith('nostr+walletconnect://')) {
|
||||||
|
connectNWC(result)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleLanguageChange = (value: TLanguage) => {
|
const handleLanguageChange = (value: TLanguage) => {
|
||||||
i18n.changeLanguage(value)
|
i18n.changeLanguage(value)
|
||||||
setLanguage(value)
|
setLanguage(value)
|
||||||
@@ -559,11 +592,27 @@ export default function Settings() {
|
|||||||
<LightningAddressInput />
|
<LightningAddressInput />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{showWalletScanner && (
|
||||||
|
<QrScannerModal
|
||||||
|
onScan={handleWalletScan}
|
||||||
|
onClose={() => setShowWalletScanner(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
|
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
|
||||||
{t('Connect Wallet')}
|
{t('Connect Wallet')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowWalletScanner(true)}
|
||||||
|
title={t('Scan NWC QR code')}
|
||||||
|
>
|
||||||
|
<ScanLine className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
36
src/components/Sidebar/KeyboardModeButton.tsx
Normal file
36
src/components/Sidebar/KeyboardModeButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
|
import { Keyboard } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function KeyboardModeButton({ collapse }: { collapse: boolean }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { isEnabled, toggleKeyboardMode } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'flex shadow-none items-center transition-colors duration-500 bg-transparent m-0 rounded-lg gap-2 text-sm font-semibold',
|
||||||
|
collapse
|
||||||
|
? 'w-12 h-12 p-3 [&_svg]:size-full'
|
||||||
|
: 'justify-start w-full h-auto py-2 px-3 [&_svg]:size-5',
|
||||||
|
isEnabled && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10'
|
||||||
|
)}
|
||||||
|
variant="ghost"
|
||||||
|
title={t('Toggle keyboard navigation (⇧K)')}
|
||||||
|
onClick={toggleKeyboardMode}
|
||||||
|
>
|
||||||
|
<Keyboard />
|
||||||
|
{!collapse && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t('Keyboard')}</span>
|
||||||
|
<kbd className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground border">⇧K</kbd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collapse && (
|
||||||
|
<span className="sr-only">{t('Toggle keyboard navigation')}</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import BookmarkButton from './BookmarkButton'
|
|||||||
import HelpButton from './HelpButton'
|
import HelpButton from './HelpButton'
|
||||||
import HomeButton from './HomeButton'
|
import HomeButton from './HomeButton'
|
||||||
import InboxButton from './InboxButton'
|
import InboxButton from './InboxButton'
|
||||||
|
import KeyboardModeButton from './KeyboardModeButton'
|
||||||
import LayoutSwitcher from './LayoutSwitcher'
|
import LayoutSwitcher from './LayoutSwitcher'
|
||||||
import NotificationsButton from './NotificationButton'
|
import NotificationsButton from './NotificationButton'
|
||||||
import PostButton from './PostButton'
|
import PostButton from './PostButton'
|
||||||
@@ -67,6 +68,7 @@ export default function PrimaryPageSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<HelpButton collapse={isCollapsed} navIndex={pubkey ? 8 : 6} />
|
<HelpButton collapse={isCollapsed} navIndex={pubkey ? 8 : 6} />
|
||||||
|
<KeyboardModeButton collapse={isCollapsed} />
|
||||||
<LayoutSwitcher collapse={isCollapsed} />
|
<LayoutSwitcher collapse={isCollapsed} />
|
||||||
<AccountButton collapse={isCollapsed} />
|
<AccountButton collapse={isCollapsed} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
|||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<button
|
<button
|
||||||
className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground"
|
className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground group"
|
||||||
title={t('Like')}
|
title={t('React (Shift+R)')}
|
||||||
disabled={liking}
|
disabled={liking}
|
||||||
data-action="react"
|
data-action="react"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -126,12 +126,18 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
|||||||
<Loader className="animate-spin" />
|
<Loader className="animate-spin" />
|
||||||
) : myLastEmoji ? (
|
) : myLastEmoji ? (
|
||||||
<>
|
<>
|
||||||
|
<span className="relative">
|
||||||
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
|
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
|
||||||
|
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">R</kbd>
|
||||||
|
</span>
|
||||||
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<span className="relative">
|
||||||
<SmilePlus />
|
<SmilePlus />
|
||||||
|
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">R</kbd>
|
||||||
|
</span>
|
||||||
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex gap-1 items-center enabled:hover:text-blue-400 pr-3 h-full',
|
'flex gap-1 items-center enabled:hover:text-blue-400 pr-3 h-full group',
|
||||||
hasReplied ? 'text-blue-400' : 'text-muted-foreground'
|
hasReplied ? 'text-blue-400' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -65,10 +65,13 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
|||||||
setOpen(true)
|
setOpen(true)
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
title={t('Reply')}
|
title={t('Reply (r)')}
|
||||||
data-action="reply"
|
data-action="reply"
|
||||||
>
|
>
|
||||||
|
<span className="relative">
|
||||||
<MessageCircle />
|
<MessageCircle />
|
||||||
|
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">r</kbd>
|
||||||
|
</span>
|
||||||
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
|
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
|
||||||
</button>
|
</button>
|
||||||
<PostEditor parentStuff={stuff} open={open} setOpen={setOpen} />
|
<PostEditor parentStuff={stuff} open={open} setOpen={setOpen} />
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
|||||||
const trigger = (
|
const trigger = (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40',
|
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40 group',
|
||||||
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
|
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
disabled={!event}
|
disabled={!event}
|
||||||
title={t('Repost')}
|
title={t('Repost (p) / Quote (q)')}
|
||||||
data-action="repost"
|
data-action="repost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!event) return
|
if (!event) return
|
||||||
@@ -91,7 +91,10 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<span className="relative">
|
||||||
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
|
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
|
||||||
|
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">p</kbd>
|
||||||
|
</span>
|
||||||
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
|
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -108,10 +111,25 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Hidden button for keyboard shortcut (q for quote)
|
||||||
|
const quoteButton = (
|
||||||
|
<button
|
||||||
|
className="hidden"
|
||||||
|
data-action="quote"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
checkLogin(() => {
|
||||||
|
setIsPostDialogOpen(true)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{trigger}
|
{trigger}
|
||||||
|
{quoteButton}
|
||||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||||
<DrawerContent hideOverlay>
|
<DrawerContent hideOverlay>
|
||||||
@@ -170,12 +188,12 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
|||||||
setIsPostDialogOpen(true)
|
setIsPostDialogOpen(true)
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
data-action="quote"
|
|
||||||
>
|
>
|
||||||
<PencilLine /> {t('Quote')}
|
<PencilLine /> {t('Quote')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
{quoteButton}
|
||||||
{postEditor}
|
{postEditor}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -135,10 +135,10 @@ export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1 select-none px-3 h-full cursor-pointer enabled:hover:text-yellow-400 disabled:text-muted-foreground/40 disabled:cursor-default',
|
'flex items-center gap-1 select-none px-3 h-full cursor-pointer enabled:hover:text-yellow-400 disabled:text-muted-foreground/40 disabled:cursor-default group',
|
||||||
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
|
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
title={t('Zap')}
|
title={t('Zap (z)')}
|
||||||
disabled={disable || zapping}
|
disabled={disable || zapping}
|
||||||
data-action="zap"
|
data-action="zap"
|
||||||
onMouseDown={handleClickStart}
|
onMouseDown={handleClickStart}
|
||||||
@@ -147,11 +147,14 @@ export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
|||||||
onTouchStart={handleClickStart}
|
onTouchStart={handleClickStart}
|
||||||
onTouchEnd={handleClickEnd}
|
onTouchEnd={handleClickEnd}
|
||||||
>
|
>
|
||||||
|
<span className="relative">
|
||||||
{zapping ? (
|
{zapping ? (
|
||||||
<Loader className="animate-spin" />
|
<Loader className="animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
|
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
|
||||||
)}
|
)}
|
||||||
|
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">z</kbd>
|
||||||
|
</span>
|
||||||
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
||||||
</button>
|
</button>
|
||||||
{event && (
|
{event && (
|
||||||
|
|||||||
@@ -1,22 +1,51 @@
|
|||||||
import MessageView from '@/components/Inbox/MessageView'
|
import MessageView from '@/components/Inbox/MessageView'
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Titlebar } from '@/components/Titlebar'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { useDM } from '@/providers/DMProvider'
|
import { useDM } from '@/providers/DMProvider'
|
||||||
import { TPageRef } from '@/types'
|
import { useFollowList } from '@/providers/FollowListProvider'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import { TPageRef, TProfile } from '@/types'
|
||||||
|
import { ChevronLeft, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import ConversationSettingsModal from '@/components/Inbox/ConversationSettingsModal'
|
||||||
|
import indexedDb from '@/services/indexed-db.service'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
|
||||||
interface DMConversationPageProps {
|
interface DMConversationPageProps {
|
||||||
pubkey?: string
|
pubkey?: string
|
||||||
index?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubkey, index }, ref) => {
|
const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubkey }, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const layoutRef = useRef<TPageRef>(null)
|
const layoutRef = useRef<TPageRef>(null)
|
||||||
const { selectConversation, currentConversation } = useDM()
|
const { pubkey: userPubkey } = useNostr()
|
||||||
|
const {
|
||||||
|
selectConversation,
|
||||||
|
currentConversation,
|
||||||
|
isLoadingConversation,
|
||||||
|
isNewConversation,
|
||||||
|
clearNewConversationFlag,
|
||||||
|
reloadConversation,
|
||||||
|
deleteAllInConversation,
|
||||||
|
undeleteAllInConversation
|
||||||
|
} = useDM()
|
||||||
const { pop } = useSecondaryPage()
|
const { pop } = useSecondaryPage()
|
||||||
|
const { followingSet } = useFollowList()
|
||||||
|
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const [selectedRelays, setSelectedRelays] = useState<string[]>([])
|
||||||
|
const [showPulse, setShowPulse] = useState(false)
|
||||||
|
|
||||||
// Decode npub to hex if needed
|
// Decode npub to hex if needed
|
||||||
const hexPubkey = useMemo(() => {
|
const hexPubkey = useMemo(() => {
|
||||||
@@ -32,6 +61,8 @@ const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubk
|
|||||||
return pubkey
|
return pubkey
|
||||||
}, [pubkey])
|
}, [pubkey])
|
||||||
|
|
||||||
|
const isFollowing = hexPubkey ? followingSet.has(hexPubkey) : false
|
||||||
|
|
||||||
useImperativeHandle(ref, () => layoutRef.current as TPageRef)
|
useImperativeHandle(ref, () => layoutRef.current as TPageRef)
|
||||||
|
|
||||||
// Select the conversation when this page mounts
|
// Select the conversation when this page mounts
|
||||||
@@ -48,17 +79,161 @@ const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubk
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Fetch profile
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hexPubkey) return
|
||||||
|
|
||||||
|
const fetchProfileData = async () => {
|
||||||
|
try {
|
||||||
|
const profileData = await client.fetchProfile(hexPubkey)
|
||||||
|
if (profileData) {
|
||||||
|
setProfile(profileData)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch profile:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchProfileData()
|
||||||
|
}, [hexPubkey])
|
||||||
|
|
||||||
|
// Handle pulsing animation for new conversations
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNewConversation) {
|
||||||
|
setShowPulse(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowPulse(false)
|
||||||
|
clearNewConversationFlag()
|
||||||
|
}, 10000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [isNewConversation, clearNewConversationFlag])
|
||||||
|
|
||||||
|
// Load saved relay settings when conversation changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hexPubkey || !userPubkey) return
|
||||||
|
|
||||||
|
const loadRelaySettings = async () => {
|
||||||
|
const saved = await indexedDb.getConversationRelaySettings(userPubkey, hexPubkey)
|
||||||
|
setSelectedRelays(saved || [])
|
||||||
|
}
|
||||||
|
loadRelaySettings()
|
||||||
|
}, [hexPubkey, userPubkey])
|
||||||
|
|
||||||
|
// Save relay settings when they change
|
||||||
|
const handleRelaysChange = async (relays: string[]) => {
|
||||||
|
setSelectedRelays(relays)
|
||||||
|
if (userPubkey && hexPubkey) {
|
||||||
|
await indexedDb.putConversationRelaySettings(userPubkey, hexPubkey, relays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
selectConversation(null)
|
selectConversation(null)
|
||||||
pop()
|
pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const displayName = profile?.username || (hexPubkey ? hexPubkey.slice(0, 8) + '...' : '')
|
||||||
<SecondaryPageLayout ref={layoutRef} index={index} title={t('Conversation')}>
|
|
||||||
<div className="h-full">
|
// Custom titlebar with user info
|
||||||
<MessageView onBack={handleBack} />
|
const titlebar = (
|
||||||
|
<div className="flex items-center gap-2 w-full px-1">
|
||||||
|
<Button
|
||||||
|
className="flex gap-1 items-center justify-start pl-2 pr-1"
|
||||||
|
variant="ghost"
|
||||||
|
size="titlebar-icon"
|
||||||
|
title={t('back')}
|
||||||
|
onClick={handleBack}
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Button>
|
||||||
|
{hexPubkey && (
|
||||||
|
<>
|
||||||
|
<UserAvatar userId={hexPubkey} className="size-7" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-semibold text-sm truncate">{displayName}</span>
|
||||||
|
{isFollowing && (
|
||||||
|
<span title="Following">
|
||||||
|
<Users className="size-3 text-primary" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SecondaryPageLayout>
|
{profile?.nip05 && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate block">{profile.nip05}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
title={t('Reload messages')}
|
||||||
|
onClick={reloadConversation}
|
||||||
|
disabled={isLoadingConversation}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
|
||||||
|
title={t('Conversation settings')}
|
||||||
|
onClick={() => {
|
||||||
|
setShowPulse(false)
|
||||||
|
clearNewConversationFlag()
|
||||||
|
setSettingsOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
|
<MoreVertical className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="size-4 mr-2" />
|
||||||
|
{t('Delete All')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={undeleteAllInConversation}>
|
||||||
|
<Undo2 className="size-4 mr-2" />
|
||||||
|
{t('Undelete All')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
title={t('Close conversation')}
|
||||||
|
onClick={handleBack}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Titlebar className="p-1" hideBottomBorder={false}>
|
||||||
|
{titlebar}
|
||||||
|
</Titlebar>
|
||||||
|
<div className="h-[calc(100%-3rem)]">
|
||||||
|
<MessageView hideHeader />
|
||||||
|
</div>
|
||||||
|
{hexPubkey && (
|
||||||
|
<ConversationSettingsModal
|
||||||
|
partnerPubkey={hexPubkey}
|
||||||
|
open={settingsOpen}
|
||||||
|
onOpenChange={setSettingsOpen}
|
||||||
|
selectedRelays={selectedRelays}
|
||||||
|
onSelectedRelaysChange={handleRelaysChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -127,18 +127,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
if (hasNostrLoginHash()) {
|
if (hasNostrLoginHash()) {
|
||||||
return await loginByNostrLoginHash()
|
await loginByNostrLoginHash()
|
||||||
|
setIsInitialized(true)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = storage.getAccounts()
|
const accounts = storage.getAccounts()
|
||||||
const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account
|
const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account
|
||||||
if (!act) return
|
if (!act) {
|
||||||
|
setIsInitialized(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set account immediately so feed can load based on pubkey
|
||||||
|
// while signer initializes in the background
|
||||||
|
setAccount({ pubkey: act.pubkey, signerType: act.signerType })
|
||||||
|
setIsInitialized(true)
|
||||||
|
|
||||||
|
// Initialize signer in background - feed doesn't need it to load
|
||||||
await loginWithAccountPointer(act)
|
await loginWithAccountPointer(act)
|
||||||
}
|
}
|
||||||
init().then(() => {
|
init()
|
||||||
setIsInitialized(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleHashChange = () => {
|
const handleHashChange = () => {
|
||||||
if (hasNostrLoginHash()) {
|
if (hasNostrLoginHash()) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ALLOWED_FILTER_KINDS,
|
ALLOWED_FILTER_KINDS,
|
||||||
DEFAULT_FAVICON_URL_TEMPLATE,
|
DEFAULT_FAVICON_URL_TEMPLATE,
|
||||||
DEFAULT_NIP_96_SERVICE,
|
|
||||||
ExtendedKind,
|
ExtendedKind,
|
||||||
MEDIA_AUTO_LOAD_POLICY,
|
MEDIA_AUTO_LOAD_POLICY,
|
||||||
NOTIFICATION_LIST_STYLE,
|
NOTIFICATION_LIST_STYLE,
|
||||||
@@ -40,7 +39,6 @@ class LocalStorageService {
|
|||||||
private defaultZapComment: string = 'Zap!'
|
private defaultZapComment: string = 'Zap!'
|
||||||
private quickZap: boolean = false
|
private quickZap: boolean = false
|
||||||
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
|
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
|
||||||
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
|
|
||||||
private autoplay: boolean = true
|
private autoplay: boolean = true
|
||||||
private hideUntrustedInteractions: boolean = false
|
private hideUntrustedInteractions: boolean = false
|
||||||
private hideUntrustedNotifications: boolean = false
|
private hideUntrustedNotifications: boolean = false
|
||||||
@@ -124,10 +122,6 @@ class LocalStorageService {
|
|||||||
window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}'
|
window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}'
|
||||||
this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr)
|
this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr)
|
||||||
|
|
||||||
// deprecated
|
|
||||||
this.mediaUploadService =
|
|
||||||
window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE
|
|
||||||
|
|
||||||
this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false'
|
this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false'
|
||||||
|
|
||||||
const hideUntrustedEvents =
|
const hideUntrustedEvents =
|
||||||
@@ -439,7 +433,7 @@ class LocalStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getMediaUploadServiceConfig(pubkey?: string | null): TMediaUploadServiceConfig {
|
getMediaUploadServiceConfig(pubkey?: string | null): TMediaUploadServiceConfig {
|
||||||
const defaultConfig = { type: 'nip96', service: this.mediaUploadService } as const
|
const defaultConfig = { type: 'blossom' } as const
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
return defaultConfig
|
return defaultConfig
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user