diff --git a/src/components/NewMessageDialog.tsx b/src/components/NewMessageDialog.tsx new file mode 100644 index 0000000..ff61712 --- /dev/null +++ b/src/components/NewMessageDialog.tsx @@ -0,0 +1,258 @@ +import { useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useCrewmates } from '@/hooks/useCrewmates'; +import { useFollowing } from '@/hooks/useFollowing'; +import { genUserName } from '@/lib/genUserName'; +import { cn } from '@/lib/utils'; +import { MessageCircle, Search, Users, UserCheck, ArrowRight } from 'lucide-react'; + +interface NewMessageDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +interface Candidate { + pubkey: string; + /** Short context line, e.g. shared crews or "Following". */ + hint?: string; + /** Grouping bucket for section headers. */ + group: 'crew' | 'following'; +} + +/** A single selectable person row. Resolves its own profile for name/avatar. */ +function PersonRow({ + candidate, + query, + onPick, +}: { + candidate: Candidate; + query: string; + onPick: (pubkey: string) => void; +}) { + const author = useAuthor(candidate.pubkey); + const metadata = author.data?.metadata; + const name = metadata?.name ?? genUserName(candidate.pubkey); + const nip05 = metadata?.nip05; + + // Name-based filtering happens here (names are resolved async per row). + const q = query.trim().toLowerCase(); + if (q) { + const haystack = `${name} ${nip05 ?? ''} ${candidate.hint ?? ''}`.toLowerCase(); + if (!haystack.includes(q)) return null; + } + + return ( + + ); +} + +/** + * A best-in-class "New Message" picker: search your crew-mates and the people + * you follow by name, or paste a raw npub / hex key. Replaces the old + * paste-only flow so starting a DM feels like a real contacts screen. + */ +export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps) { + const navigate = useNavigate(); + const { user } = useCurrentUser(); + const { crewmates, isLoading: crewmatesLoading } = useCrewmates(); + const { data: following, isLoading: followingLoading } = useFollowing(user?.pubkey); + + const [query, setQuery] = useState(''); + const [rawError, setRawError] = useState(''); + + // Merge crew-mates (with shared-crew hints) and followed accounts, de-duped. + const candidates = useMemo(() => { + const seen = new Set(); + const list: Candidate[] = []; + + for (const cm of crewmates) { + if (seen.has(cm.pubkey)) continue; + seen.add(cm.pubkey); + list.push({ + pubkey: cm.pubkey, + group: 'crew', + hint: + cm.sharedCrews.length > 0 + ? `In your crew${cm.sharedCrews.length > 1 ? 's' : ''}: ${cm.sharedCrews.join(', ')}` + : 'Crew-mate', + }); + } + + for (const pk of following ?? []) { + if (pk === user?.pubkey || seen.has(pk)) continue; + seen.add(pk); + list.push({ pubkey: pk, group: 'following', hint: 'Following' }); + } + + return list; + }, [crewmates, following, user?.pubkey]); + + const crewCandidates = candidates.filter((c) => c.group === 'crew'); + const followingCandidates = candidates.filter((c) => c.group === 'following'); + + const goToConversation = (pubkey: string) => { + onOpenChange(false); + setQuery(''); + setRawError(''); + navigate(`/messages/${nip19.npubEncode(pubkey)}`); + }; + + // If the query looks like a raw key, offer to open it directly. + const trimmed = query.trim(); + const looksLikeNpub = trimmed.startsWith('npub1'); + const looksLikeHex = /^[a-f0-9]{64}$/i.test(trimmed); + + const handleOpenRaw = () => { + setRawError(''); + try { + let pubkey = trimmed; + if (looksLikeNpub) { + const decoded = nip19.decode(trimmed); + if (decoded.type !== 'npub') throw new Error('Invalid npub'); + pubkey = decoded.data; + } else if (!looksLikeHex) { + throw new Error('Invalid key'); + } + goToConversation(pubkey); + } catch { + setRawError('That doesn\u2019t look like a valid npub or public key.'); + } + }; + + const isLoading = crewmatesLoading || followingLoading; + const hasAnyCandidates = candidates.length > 0; + + return ( + + + + + + New Message + + + Search your people by name, or paste an npub to message anyone. + + + +
+
+ + { + setQuery(e.target.value); + setRawError(''); + }} + placeholder="Search name or paste npub1..." + className="bg-gray-900 border-gray-700 text-white pl-9 h-11" + autoFocus + /> +
+ + {/* Direct-open affordance when a raw key is pasted */} + {(looksLikeNpub || looksLikeHex) && ( + + )} + {rawError &&

{rawError}

} + + {/* People list */} + {!looksLikeNpub && !looksLikeHex && ( + + {isLoading ? ( +
+ Loading your people... +
+ ) : !hasAnyCandidates ? ( +
+ +

+ No crew-mates or follows yet. +

+

+ Paste someone's npub above to start a conversation. +

+
+ ) : ( +
+ {crewCandidates.length > 0 && ( +
+

+ Your crews +

+
+ {crewCandidates.map((c) => ( + + ))} +
+
+ )} + {followingCandidates.length > 0 && ( +
+

+ Following +

+
+ {followingCandidates.map((c) => ( + + ))} +
+
+ )} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/hooks/useCrews.ts b/src/hooks/useCrews.ts index b54dcab..324fd27 100644 --- a/src/hooks/useCrews.ts +++ b/src/hooks/useCrews.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { SKATE_CREW_KIND, parseSkateCrew, @@ -116,3 +117,36 @@ export function useCrew(identifier: string, pubkey?: string) { refetchOnMount: true, }); } + +export interface UserCrewMembership { + crew: SkateCrew; + /** How the user is attached to this crew. */ + role: 'founder' | 'member'; +} + +/** + * The crews a given pubkey belongs to — as founder or listed member. + * + * Powers the "Crews" section on a user's public profile. Founder crews sort + * first, then member crews, newest first within each group. + */ +export function useUserCrews(pubkey?: string) { + const { data: crews, isLoading, error } = useCrews(200); + + const memberships = useMemo(() => { + if (!pubkey || !crews) return []; + + return crews + .filter((c) => c.pubkey === pubkey || c.members.includes(pubkey)) + .map((crew) => ({ + crew, + role: crew.pubkey === pubkey ? ('founder' as const) : ('member' as const), + })) + .sort((a, b) => { + if (a.role !== b.role) return a.role === 'founder' ? -1 : 1; + return b.crew.createdAt - a.crew.createdAt; + }); + }, [pubkey, crews]); + + return { memberships, isLoading, error }; +} diff --git a/src/pages/DirectMessagesPage.tsx b/src/pages/DirectMessagesPage.tsx index a650dee..3fe6a38 100644 --- a/src/pages/DirectMessagesPage.tsx +++ b/src/pages/DirectMessagesPage.tsx @@ -5,21 +5,13 @@ import { nip19 } from 'nostr-tools'; import { Layout } from '@/components/Layout'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Skeleton } from '@/components/ui/skeleton'; -import { Badge } from '@/components/ui/badge'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; +import { NewMessageDialog } from '@/components/NewMessageDialog'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrLogin } from '@nostrify/react/login'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; -import { useConversations, formatPubkeyShort } from '@/hooks/useDirectMessages'; +import { useConversations } from '@/hooks/useDirectMessages'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { useLocalStorage } from '@/hooks/useLocalStorage'; @@ -27,7 +19,6 @@ import { formatDistanceToNow } from 'date-fns'; import { MessageCircle, Plus, - Search, Lock, AlertCircle, ShieldCheck, @@ -140,39 +131,12 @@ export default function DirectMessagesPage() { const { logins } = useNostrLogin(); const { data: conversations, isLoading, error } = useConversations(); const [showNewMessage, setShowNewMessage] = useState(false); - const [newRecipient, setNewRecipient] = useState(''); - const [recipientError, setRecipientError] = useState(''); useSeoMeta({ title: 'Messages - SkateSpots', description: 'Private encrypted messages with the skateboarding community', }); - const handleStartConversation = () => { - setRecipientError(''); - - // Try to parse the input as npub or hex pubkey - let pubkey = newRecipient.trim(); - - try { - if (pubkey.startsWith('npub1')) { - const decoded = nip19.decode(pubkey); - if (decoded.type === 'npub') { - pubkey = decoded.data; - } else { - throw new Error('Invalid npub'); - } - } else if (!/^[a-f0-9]{64}$/i.test(pubkey)) { - throw new Error('Invalid pubkey format'); - } - - // Navigate to conversation - window.location.href = `/messages/${nip19.npubEncode(pubkey)}`; - } catch { - setRecipientError('Please enter a valid npub or pubkey'); - } - }; - // Not logged in if (!user) { return ( @@ -317,52 +281,8 @@ export default function DirectMessagesPage() { - {/* New Message Dialog */} - - - - - - New Message - - - Enter the npub or pubkey of the person you want to message - - -
-
- { - setNewRecipient(e.target.value); - setRecipientError(''); - }} - placeholder="npub1... or hex pubkey" - className="bg-black border-gray-700 text-white font-mono text-sm h-11" - /> - {recipientError && ( -

{recipientError}

- )} -
-
- - -
-
-
-
+ {/* New Message — searchable people picker */} + ); } diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index f0cfb36..568bc18 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -77,6 +77,7 @@ import { Layers, Calendar, MessageCircle, + ChevronDown, } from 'lucide-react'; import { SpotChat } from '@/components/SpotChat'; import { useToast } from '@/hooks/useToast'; @@ -1478,30 +1479,38 @@ export default function MapPage() { {/* Add buttons - icons only on mobile */} {user ? ( <> + {/* Primary action: add a spot (this is a map, after all) */} - - - - - - - - - + {/* Secondary add actions consolidated into one menu to de-clutter */} + + + + + + + {t('shops.addShop')} + + + Buy/Sell Gear + + + Offer Tour + + + ) : ( diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index 926f55b..a484719 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -12,6 +12,7 @@ import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUserSkateSpots } from '@/hooks/useSkateSpots'; import { useUserShops } from '@/hooks/useShops'; +import { useUserCrews } from '@/hooks/useCrews'; import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard'; import { genUserName } from '@/lib/genUserName'; import NotFound from './NotFound'; @@ -26,6 +27,9 @@ import { ArrowLeft, Copy, Check, + Users, + Crown, + Bot, } from 'lucide-react'; import { useState } from 'react'; import { UserStatusIndicator } from '@/components/UserStatusIndicator'; @@ -35,6 +39,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) { const { user } = useCurrentUser(); const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey); const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey); + const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey); const [copied, setCopied] = useState(false); const metadata = author.data?.metadata; @@ -45,6 +50,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) { const website = metadata?.website; const nip05 = metadata?.nip05; const lud16 = metadata?.lud16; + const isBot = metadata?.bot === true; // Custom CLINK offer code stored in the kind 0 content JSON. const noffer = (metadata as Record | undefined)?.noffer; const nofferCode = typeof noffer === 'string' ? noffer : undefined; @@ -121,10 +127,15 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
-
+

{displayName}

+ {isBot && ( + + Bot + + )}
{nip05 && ( @@ -133,6 +144,31 @@ function ProfilePage({ pubkey }: { pubkey: string }) { {nip05}

)} + {/* Crew membership summary — links straight to the crews */} + {crews.length > 0 && ( +

+ + + {crews.slice(0, 2).map((m, i) => ( + + {i > 0 && ', '} + + {m.crew.name} + + {m.role === 'founder' && ( + + )} + + ))} + {crews.length > 2 && ( + +{crews.length - 2} more + )} + +

+ )}
{/* Actions */} @@ -301,11 +337,61 @@ function ProfilePage({ pubkey }: { pubkey: string }) { + {/* Crews Card */} + {(crewsLoading || crews.length > 0) && ( + + + + + Crews + {crews.length > 0 && ( + + {crews.length} + + )} + + + + {crewsLoading ? ( + <> + + + + ) : ( + crews.map((m) => ( + + + + + {m.crew.name.slice(0, 2).toUpperCase()} + + +
+

{m.crew.name}

+

+ {m.role === 'founder' ? ( + <> Founder + ) : ( + <>Member + )} +

+
+ + )) + )} +
+
+ )} + {/* Stats Card */}

Contributions

-
+

{spots?.length || 0}

Spots

@@ -314,6 +400,10 @@ function ProfilePage({ pubkey }: { pubkey: string }) {

{shops?.length || 0}

Shops

+
+

{crews.length}

+

Crews

+