From a90ac34508641fb17059404a52c193321dab5c3b Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 16 May 2026 00:28:08 -0500 Subject: [PATCH] Add destination dropdown to the home-feed compose box Lets users post either to the global Nostr feed (kind 1) or to one of the country communities they follow (kind 1111 rooted on the country ISO 3166 identifier, mirroring the country page's compose flow). Layout choices that ended up sticking after iteration: - Dropdown lives on its own row above the toolbar so the 'Post to' label can anchor its semantic meaning without competing with the attach / emoji / mic / poll icons below. - Trigger renders only the flag emoji to stay compact on mobile; the open list shows flag + country name so options remain distinguishable. - A small help popover next to 'Post to' explains Global vs. country community for users new to the concept, with the second item's flag swapping to the currently-selected (or first followed) country so the explanation feels tangible. - Toggle only renders when the user is logged in, followed at least one country, isn't replying, and isn't in poll / customPublish mode. Resets to Global after each publish so country-mode never sticks silently across posts. --- src/components/ComposeBox.tsx | 151 +++++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 2 deletions(-) diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx index b2d4aa4f..8fecae0e 100644 --- a/src/components/ComposeBox.tsx +++ b/src/components/ComposeBox.tsx @@ -1,6 +1,6 @@ import { lazy, Suspense, useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft } from 'lucide-react'; +import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, HelpCircle } from 'lucide-react'; import { nip19 } from 'nostr-tools'; import { encode as blurhashEncode } from 'blurhash'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; import { useCustomEmojis } from '@/hooks/useCustomEmojis'; import { useFeedSettings } from '@/hooks/useFeedSettings'; import { GifPicker } from '@/components/GifPicker'; @@ -27,6 +28,9 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { usePostComment } from '@/hooks/usePostComment'; import { useUploadFile } from '@/hooks/useUploadFile'; +import { useCountryFollows } from '@/hooks/useCountryFollows'; +import { getCountryInfo } from '@/lib/countries'; +import { createCountryIdentifier } from '@/lib/countryIdentifiers'; import { useQueryClient } from '@tanstack/react-query'; import { useToast } from '@/hooks/useToast'; import { useAppContext } from '@/hooks/useAppContext'; @@ -249,6 +253,28 @@ export function ComposeBox({ // Poll mode state const [mode, setMode] = useState<'post' | 'poll'>(initialMode); + + // Country-destination toggle. Only meaningful for top-level new posts + // from the home feed (no replyTo, not a custom-kind publish). When a + // country code is selected, the post is published as a NIP-22 kind + // 1111 comment rooted on that country instead of a plain kind 1 note. + // Dropdown lists only the countries the user follows, with "Global" + // always at the top. + const { followedCountries } = useCountryFollows(); + const canChooseDestination = + !replyTo && !customPublish && mode === 'post' && !!user && followedCountries.length > 0; + /** `'world'` for a regular kind-1 note, or an ISO 3166 country code for a kind-1111 community post. */ + const [destination, setDestination] = useState<'world' | string>('world'); + const selectedCountryCode = destination !== 'world' ? destination : null; + const selectedCountryInfo = selectedCountryCode ? getCountryInfo(selectedCountryCode) : null; + // If the user unfollows the currently-selected country mid-session, + // snap back to world so we don't try to publish a kind 1111 with + // a root the user no longer cares about. + useEffect(() => { + if (selectedCountryCode && !followedCountries.includes(selectedCountryCode)) { + setDestination('world'); + } + }, [selectedCountryCode, followedCountries]); const [pollOptions, setPollOptions] = useState([ { id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }, @@ -286,6 +312,7 @@ export function ComposeBox({ setUploadedFileGroups(new Map()); setWebxdcUuids(new Map()); setWebxdcMetas(new Map()); + setDestination('world'); // Clear the auto-saved draft try { localStorage.removeItem(draftKey); } catch { /* ignore */ } }, [initialMode, draftKey]); @@ -1069,6 +1096,12 @@ export function ComposeBox({ } await postComment({ root, reply, content: finalContent, tags }); + } else if (canChooseDestination && selectedCountryCode) { + // No replyTo, but the user picked a followed country as the + // destination — publish as a NIP-22 kind 1111 rooted on the + // country identifier (mirrors the country page's compose flow). + const countryRoot = new URL(createCountryIdentifier(selectedCountryCode)); + await postComment({ root: countryRoot, reply: undefined, content: finalContent, tags }); } else { await createEvent({ kind: 1, @@ -1245,7 +1278,13 @@ export function ComposeBox({ onPointerDown={expand} onFocus={expand} onPaste={handlePaste} - placeholder={mode === 'poll' ? 'Ask a question…' : placeholder} + placeholder={ + mode === 'poll' + ? 'Ask a question…' + : selectedCountryInfo + ? `What's happening in ${selectedCountryInfo.name}?` + : placeholder + } className={cn( 'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out', isExpanded ? 'min-h-[100px]' : 'min-h-[44px]', @@ -1419,6 +1458,114 @@ export function ComposeBox({ {/* end flex-1 content column */} {/* end avatar + content row */} + {/* Destination row — its own line above the toolbar so the + "Post to" label gives it semantic context. The dropdown lists + "Global" first, then every country the user follows. The + help icon opens a popover that explains the difference + between a global post and a country community post. Shown + only for new top-level posts from a logged-in user with + at least one followed country. */} + {canChooseDestination && isExpanded && ( +
+ + Post to + + + + + + + {(() => { + // Use the currently-selected country (falling back to + // the first followed one) only as the *flag emoji* in + // the example. The text stays generic so the popover + // reads as an explanation of the feature, not a + // description of the user's current pick. + const exampleCode = selectedCountryCode ?? followedCountries[0]; + const exampleFlag = exampleCode ? getCountryInfo(exampleCode)?.flag : null; + return ( +
+
+ +
+

Global

+

+ A regular post visible to everyone on Nostr. Anyone, anywhere can see it. +

+
+
+
+ +
+

Your country community

+

+ Shown in that country's local feed alongside posts from neighbors. Best for community-relevant updates. +

+
+
+
+ ); + })()} +
+
+ +
+ )} + {/* Toolbar + post button — full width, not indented by avatar */} {isExpanded && ( voiceRecorder.isRecording || isPublishingVoice ? (