diff --git a/.claude/commands/deploy.md b/.claude/commands/deploy.md index d6d6a876..0974d15a 100644 --- a/.claude/commands/deploy.md +++ b/.claude/commands/deploy.md @@ -1,6 +1,6 @@ # Deploy Command -Deploy smesh to the VPS at 10.0.0.1, serving on port 3008 behind smesh.mleku.dev. +Deploy smesh to the VPS at mleku.dev, serving on port 3008 behind smesh.mleku.dev. ## Instructions @@ -13,17 +13,17 @@ Deploy smesh to the VPS at 10.0.0.1, serving on port 3008 behind smesh.mleku.dev 3. Sync the dist folder to the VPS: ```bash - rsync -avz --delete dist/ 10.0.0.1:~/smesh/dist/ + rsync -avz --delete dist/ mleku.dev:~/smesh/dist/ ``` 4. Restart the smesh service on the VPS: ```bash - ssh 10.0.0.1 "sudo systemctl restart smesh" + ssh mleku.dev "sudo systemctl restart smesh" ``` 5. Verify the service is running: ```bash - ssh 10.0.0.1 "sudo systemctl status smesh" + ssh mleku.dev "sudo systemctl status smesh" ``` 6. Report the deployment status and the URL: https://smesh.mleku.dev diff --git a/src/App.tsx b/src/App.tsx index c33b8243..202bf450 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { Toaster } from '@/components/ui/sonner' import { BookmarksProvider } from '@/providers/BookmarksProvider' import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider' import { DeletedEventProvider } from '@/providers/DeletedEventProvider' +import { DMProvider } from '@/providers/DMProvider' import { EmojiPackProvider } from '@/providers/EmojiPackProvider' import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider' import { FeedProvider } from '@/providers/FeedProvider' @@ -38,8 +39,9 @@ export default function App(): JSX.Element { - - + + + @@ -55,7 +57,8 @@ export default function App(): JSX.Element { - + + diff --git a/src/components/Inbox/ConversationItem.tsx b/src/components/Inbox/ConversationItem.tsx new file mode 100644 index 00000000..4aed7553 --- /dev/null +++ b/src/components/Inbox/ConversationItem.tsx @@ -0,0 +1,81 @@ +import UserAvatar from '@/components/UserAvatar' +import { formatTimestamp } from '@/lib/timestamp' +import { cn } from '@/lib/utils' +import client from '@/services/client.service' +import { TConversation, TProfile } from '@/types' +import { Lock, Users } from 'lucide-react' +import { useEffect, useState } from 'react' + +interface ConversationItemProps { + conversation: TConversation + isActive: boolean + isFollowing: boolean + onClick: () => void +} + +export default function ConversationItem({ + conversation, + isActive, + isFollowing, + onClick +}: ConversationItemProps) { + const [profile, setProfile] = useState(null) + + useEffect(() => { + const fetchProfileData = async () => { + try { + const profileData = await client.fetchProfile(conversation.partnerPubkey) + if (profileData) { + setProfile(profileData) + } + } catch (error) { + console.error('Failed to fetch profile:', error) + } + } + fetchProfileData() + }, [conversation.partnerPubkey]) + + const displayName = profile?.username || conversation.partnerPubkey.slice(0, 8) + '...' + const formattedTime = formatTimestamp(conversation.lastMessageAt) + + return ( + + ) +} diff --git a/src/components/Inbox/ConversationList.tsx b/src/components/Inbox/ConversationList.tsx new file mode 100644 index 00000000..5d6b75a4 --- /dev/null +++ b/src/components/Inbox/ConversationList.tsx @@ -0,0 +1,145 @@ +import { useDM } from '@/providers/DMProvider' +import { useFollowList } from '@/providers/FollowListProvider' +import { useMuteList } from '@/providers/MuteListProvider' +import storage from '@/services/local-storage.service' +import { Check, Loader2, MessageSquare, MoreVertical, RefreshCw } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '../ui/dropdown-menu' +import { ScrollArea } from '../ui/scroll-area' +import ConversationItem from './ConversationItem' + +export default function ConversationList() { + const { t } = useTranslation() + const { + conversations, + currentConversation, + selectConversation, + refreshConversations, + loadMoreConversations, + hasMoreConversations, + isLoading + } = useDM() + const { followingSet } = useFollowList() + const { mutePubkeySet } = useMuteList() + const loadMoreRef = useRef(null) + const [filterMode, setFilterMode] = useState<'all' | 'follows'>(() => + storage.getDMConversationFilter() + ) + + // Filter and sort conversations + const sortedConversations = useMemo(() => { + let filtered = [...conversations] + + if (filterMode === 'follows') { + // Only show conversations from follows, and hide muted users + filtered = filtered.filter( + (c) => followingSet.has(c.partnerPubkey) && !mutePubkeySet.has(c.partnerPubkey) + ) + } + + return filtered.sort((a, b) => b.lastMessageAt - a.lastMessageAt) + }, [conversations, filterMode, followingSet, mutePubkeySet]) + + const handleFilterChange = (mode: 'all' | 'follows') => { + setFilterMode(mode) + storage.setDMConversationFilter(mode) + } + + // Infinite scroll: load more when sentinel is visible + const handleIntersection = useCallback( + (entries: IntersectionObserverEntry[]) => { + const [entry] = entries + if (entry.isIntersecting && hasMoreConversations && !isLoading) { + loadMoreConversations() + } + }, + [hasMoreConversations, isLoading, loadMoreConversations] + ) + + useEffect(() => { + const observer = new IntersectionObserver(handleIntersection, { + root: null, + rootMargin: '100px', + threshold: 0 + }) + + if (loadMoreRef.current) { + observer.observe(loadMoreRef.current) + } + + return () => observer.disconnect() + }, [handleIntersection]) + + return ( +
+
+ {t('Conversations')} +
+ + + + + + + handleFilterChange('follows')}> + {filterMode === 'follows' && } + + {t('Only show follows')} + + + handleFilterChange('all')}> + {filterMode === 'all' && } + {t('Show all')} + + + +
+
+ + + {sortedConversations.length === 0 && !isLoading ? ( +
+ +

{t('No conversations yet')}

+

{t('Start a conversation by visiting a profile')}

+
+ ) : ( +
+ {sortedConversations.map((conversation) => ( + selectConversation(conversation.partnerPubkey)} + /> + ))} + {/* Sentinel element for infinite scroll */} + {hasMoreConversations && ( +
+ +
+ )} +
+ )} +
+
+ ) +} diff --git a/src/components/Inbox/ConversationSettingsModal.tsx b/src/components/Inbox/ConversationSettingsModal.tsx new file mode 100644 index 00000000..9b7fd14b --- /dev/null +++ b/src/components/Inbox/ConversationSettingsModal.tsx @@ -0,0 +1,313 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { useNostr } from '@/providers/NostrProvider' +import client from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' +import { TRelayList } from '@/types' +import { Check, Loader2, Lock, LockOpen, User, Users, Zap } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +type EncryptionPreference = 'auto' | 'nip04' | 'nip17' + +interface ConversationSettingsModalProps { + partnerPubkey: string | null + open: boolean + onOpenChange: (open: boolean) => void + selectedRelays: string[] + onSelectedRelaysChange: (relays: string[]) => void +} + +type RelayInfo = { + url: string + isYours: boolean + isTheirs: boolean + isShared: boolean +} + +export default function ConversationSettingsModal({ + partnerPubkey, + open, + onOpenChange, + selectedRelays, + onSelectedRelaysChange +}: ConversationSettingsModalProps) { + const { t } = useTranslation() + const { pubkey, relayList: myRelayList, hasNip44Support } = useNostr() + const [partnerRelayList, setPartnerRelayList] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [relays, setRelays] = useState([]) + const [encryptionPreference, setEncryptionPreference] = useState('auto') + + // Fetch partner's relay list when modal opens + useEffect(() => { + if (!open || !partnerPubkey) return + + const fetchPartnerRelays = async () => { + setIsLoading(true) + try { + const relayList = await client.fetchRelayList(partnerPubkey) + setPartnerRelayList(relayList) + } catch (error) { + console.error('Failed to fetch partner relay list:', error) + } finally { + setIsLoading(false) + } + } + + fetchPartnerRelays() + }, [open, partnerPubkey]) + + // Load encryption preference when modal opens + useEffect(() => { + if (!open || !partnerPubkey || !pubkey) return + + const loadEncryptionPreference = async () => { + const saved = await indexedDb.getConversationEncryptionPreference(pubkey, partnerPubkey) + setEncryptionPreference(saved || 'auto') + } + loadEncryptionPreference() + }, [open, partnerPubkey, pubkey]) + + // Save encryption preference when it changes + const handleEncryptionChange = async (value: EncryptionPreference) => { + setEncryptionPreference(value) + if (pubkey && partnerPubkey) { + await indexedDb.putConversationEncryptionPreference(pubkey, partnerPubkey, value) + } + } + + // Build relay list when data is available + useEffect(() => { + if (!myRelayList || !partnerRelayList) return + + const myWriteRelays = new Set(myRelayList.write.map((r) => r.replace(/\/$/, ''))) + const theirReadRelays = new Set(partnerRelayList.read.map((r) => r.replace(/\/$/, ''))) + + // Combine all relays + const allRelayUrls = new Set() + myRelayList.write.forEach((r) => allRelayUrls.add(r.replace(/\/$/, ''))) + partnerRelayList.read.forEach((r) => allRelayUrls.add(r.replace(/\/$/, ''))) + + const relayInfos: RelayInfo[] = Array.from(allRelayUrls).map((url) => { + const normalizedUrl = url.replace(/\/$/, '') + const isYours = myWriteRelays.has(normalizedUrl) + const isTheirs = theirReadRelays.has(normalizedUrl) + return { + url, + isYours, + isTheirs, + isShared: isYours && isTheirs + } + }) + + // Sort: shared first, then yours, then theirs + relayInfos.sort((a, b) => { + if (a.isShared && !b.isShared) return -1 + if (!a.isShared && b.isShared) return 1 + if (a.isYours && !b.isYours) return -1 + if (!a.isYours && b.isYours) return 1 + return a.url.localeCompare(b.url) + }) + + setRelays(relayInfos) + + // If no relays selected yet, default to shared relays + if (selectedRelays.length === 0) { + const sharedRelays = relayInfos.filter((r) => r.isShared).map((r) => r.url) + if (sharedRelays.length > 0) { + onSelectedRelaysChange(sharedRelays) + } + } + }, [myRelayList, partnerRelayList]) + + const toggleRelay = (url: string) => { + if (selectedRelays.includes(url)) { + onSelectedRelaysChange(selectedRelays.filter((r) => r !== url)) + } else { + onSelectedRelaysChange([...selectedRelays, url]) + } + } + + const selectAllShared = () => { + const sharedUrls = relays.filter((r) => r.isShared).map((r) => r.url) + onSelectedRelaysChange(sharedUrls) + } + + const selectAll = () => { + onSelectedRelaysChange(relays.map((r) => r.url)) + } + + const formatRelayUrl = (url: string) => { + try { + const parsed = new URL(url) + return parsed.hostname + (parsed.pathname !== '/' ? parsed.pathname : '') + } catch { + return url + } + } + + if (!partnerPubkey || !pubkey) return null + + return ( + + + + {t('Conversation Settings')} + + +
+ {/* Encryption Preference */} +
+ + handleEncryptionChange(value as EncryptionPreference)} + className="grid grid-cols-3 gap-2" + > +
+ + +
+
+ + +
+
+ + +
+
+

+ {encryptionPreference === 'auto' + ? t('Matches existing conversation encryption, or sends both on first message') + : encryptionPreference === 'nip04' + ? t('Classic encryption (NIP-04) - compatible with all clients') + : t('Modern encryption (NIP-17) - more private with metadata protection')} +

+
+ +
+ +
+ + {/* Legend */} +
+
+ + {t('You')} +
+
+ + {t('Them')} +
+
+
+ {t('Shared')} +
+
+ + {t('Selected for sending')} +
+
+ + {/* Quick actions */} +
+ + +
+ + {/* Relay list */} +
+ {isLoading ? ( +
+ +
+ ) : relays.length === 0 ? ( +

+ {t('No relay information available')} +

+ ) : ( + relays.map((relay) => ( +
toggleRelay(relay.url)} + > + toggleRelay(relay.url)} + /> +
+ + {formatRelayUrl(relay.url)} + +
+
+ {relay.isYours && ( + + + + )} + {relay.isTheirs && ( + + + + )} +
+
+ )) + )} +
+ + {/* Info text */} +

+ {t('Selected relays will be used when sending new messages in this conversation.')} +

+
+ +
+ ) +} diff --git a/src/components/Inbox/InboxContent.tsx b/src/components/Inbox/InboxContent.tsx new file mode 100644 index 00000000..4718f829 --- /dev/null +++ b/src/components/Inbox/InboxContent.tsx @@ -0,0 +1,80 @@ +import { useDM } from '@/providers/DMProvider' +import { Loader2, RefreshCw } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ConversationList from './ConversationList' +import MessageView from './MessageView' +import { Button } from '../ui/button' + +export default function InboxContent() { + const { t } = useTranslation() + const { isLoading, error, refreshConversations, currentConversation, selectConversation } = + useDM() + const [isMobileView, setIsMobileView] = useState(false) + + useEffect(() => { + const checkMobile = () => { + setIsMobileView(window.innerWidth < 768) + } + checkMobile() + window.addEventListener('resize', checkMobile) + return () => window.removeEventListener('resize', checkMobile) + }, []) + + if (isLoading && !currentConversation) { + return ( +
+
+ + {t('Loading messages...')} +
+
+ ) + } + + if (error) { + return ( +
+

{error}

+ +
+ ) + } + + // Mobile view: show either list or conversation + if (isMobileView) { + if (currentConversation) { + return ( +
+ selectConversation(null)} /> +
+ ) + } + return ( +
+ +
+ ) + } + + // Desktop view: split pane + return ( +
+
+ +
+
+ {currentConversation ? ( + + ) : ( +
+

{t('Select a conversation to view messages')}

+
+ )} +
+
+ ) +} diff --git a/src/components/Inbox/MessageComposer.tsx b/src/components/Inbox/MessageComposer.tsx new file mode 100644 index 00000000..9f06d74e --- /dev/null +++ b/src/components/Inbox/MessageComposer.tsx @@ -0,0 +1,77 @@ +import { useDM } from '@/providers/DMProvider' +import { AlertCircle, Loader2, Send } from 'lucide-react' +import { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '../ui/button' +import { Textarea } from '../ui/textarea' + +export default function MessageComposer() { + const { t } = useTranslation() + const { sendMessage, currentConversation } = useDM() + const [message, setMessage] = useState('') + const [isSending, setIsSending] = useState(false) + const [error, setError] = useState(null) + const textareaRef = useRef(null) + + const handleSend = async () => { + if (!message.trim() || !currentConversation || isSending) return + + setIsSending(true) + setError(null) + try { + await sendMessage(message.trim()) + setMessage('') + // Return focus to input after sending + textareaRef.current?.focus() + } catch (err) { + console.error('Failed to send message:', err) + setError(err instanceof Error ? err.message : t('Failed to send message')) + } finally { + setIsSending(false) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + return ( +
+ {error && ( +
+ + {error} +
+ )} +
+