From f173b975b7b0c7db0cb6cf6135b9fcee1a72ad2a Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Tue, 26 May 2026 19:53:42 -0500 Subject: [PATCH] Add Kosovo and Western Sahara, surface Tibet in country pickers Add Kosovo (XK) and Western Sahara (EH) to the country list. Kosovo has no Unicode emoji flag, so it follows the Tibet pattern with a bundled SVG asset that CountryFlag swaps in. Surface Tibet (CN-XZ) as a search-list entry so it can be picked from country autocompletes and pickers. The on-wire identifier stays iso3166:CN-XZ; only the picker pretends. Route every remaining raw country.flag span through CountryFlag so bundled SVGs render in autocomplete dropdowns, organizer selects, the ComposeBox destination switcher, and the world stats dialog. --- public/flag-kosovo.svg | 92 +++++++++++++++++++++ src/components/ComposeBox.tsx | 28 +++++-- src/components/CountryPickerButton.tsx | 19 ++++- src/components/CountrySelect.tsx | 14 +++- src/components/OrganizersManager.tsx | 10 ++- src/components/ProfileSearchDropdown.tsx | 10 ++- src/components/world/CountryStatsDialog.tsx | 20 ++--- src/lib/countries.ts | 41 ++++++--- src/lib/customFlags.ts | 13 ++- src/pages/CreateActionPage.tsx | 21 +++-- src/pages/CreateCampaignPage.tsx | 21 +++-- src/pages/CreateCommunityPage.tsx | 21 +++-- src/pages/MyDashboardPage.tsx | 6 +- 13 files changed, 252 insertions(+), 64 deletions(-) create mode 100644 public/flag-kosovo.svg diff --git a/public/flag-kosovo.svg b/public/flag-kosovo.svg new file mode 100644 index 00000000..12731238 --- /dev/null +++ b/public/flag-kosovo.svg @@ -0,0 +1,92 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx index 11f3262e..295be2a8 100644 --- a/src/components/ComposeBox.tsx +++ b/src/components/ComposeBox.tsx @@ -38,6 +38,7 @@ import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocompl import { StickerPicker } from '@/components/StickerPicker'; import { NoteContent } from '@/components/NoteContent'; +import { CountryFlag } from '@/components/CountryFlag'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; @@ -1552,9 +1553,15 @@ export function ComposeBox({ {/* Show just the flag in the trigger to keep the row compact on mobile. The list items below carry the country name so users can still tell them apart. */} - + {selectedCountryCode && selectedCountryInfo ? ( + + ) : ( + + )} - - {info.name} + + {info.subdivisionName ?? info.name} {destination === code && ( @@ -1683,7 +1694,12 @@ export function ComposeBox({ setCountryPickerOpen(false); }} > - + {country.name} {country.code} {destination === country.code && ( diff --git a/src/components/CountryPickerButton.tsx b/src/components/CountryPickerButton.tsx index d51e0c36..33e0576a 100644 --- a/src/components/CountryPickerButton.tsx +++ b/src/components/CountryPickerButton.tsx @@ -12,6 +12,7 @@ import { CommandList, } from '@/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { CountryFlag } from '@/components/CountryFlag'; import { countryCodeToFlag, getAllCountries } from '@/lib/countries'; import { cn } from '@/lib/utils'; @@ -71,7 +72,12 @@ export function CountryPickerButton({ value, onChange, className }: CountryPicke aria-label={t('common.countryFilterAriaLabel')} > {value ? ( - {countryCodeToFlag(value)} + ) : ( )} @@ -93,7 +99,16 @@ export function CountryPickerButton({ value, onChange, className }: CountryPicke }} className="gap-2" > - {option.flag} + {option.value === 'global' ? ( + {option.flag} + ) : ( + + )} {option.label} searchCountries(query), [query]); const showResults = open && results.length > 0; const resultsId = `${id}-results`; @@ -105,8 +106,13 @@ export function CountrySelect({ index === selectedIndex && 'bg-secondary/60', )} > - - {country.flag} + + {country.name} diff --git a/src/components/OrganizersManager.tsx b/src/components/OrganizersManager.tsx index 4f4ecf6e..b66be0c8 100644 --- a/src/components/OrganizersManager.tsx +++ b/src/components/OrganizersManager.tsx @@ -23,6 +23,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { CountryFlag } from '@/components/CountryFlag'; import { useToast } from '@/hooks/useToast'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -254,7 +255,14 @@ export function OrganizersManager() { {countries.map((country) => ( - {country.flag} {country.name} + + + {country.name} + ))} diff --git a/src/components/ProfileSearchDropdown.tsx b/src/components/ProfileSearchDropdown.tsx index 32aa6759..415d44e3 100644 --- a/src/components/ProfileSearchDropdown.tsx +++ b/src/components/ProfileSearchDropdown.tsx @@ -5,6 +5,7 @@ import { nip19 } from 'nostr-tools'; import { Input } from '@/components/ui/input'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { EmojifiedText } from '@/components/CustomEmoji'; +import { CountryFlag } from '@/components/CountryFlag'; import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles'; import { genUserName } from '@/lib/genUserName'; import { useNip05Verify } from '@/hooks/useNip05Verify'; @@ -818,9 +819,12 @@ function CountryItem({ onMouseDown={(e) => e.preventDefault()} >
- - {country.flag} - +
{country.name} diff --git a/src/components/world/CountryStatsDialog.tsx b/src/components/world/CountryStatsDialog.tsx index c61612ce..64098e13 100644 --- a/src/components/world/CountryStatsDialog.tsx +++ b/src/components/world/CountryStatsDialog.tsx @@ -7,7 +7,8 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { CommunityStatsPanel } from '@/components/CommunityStatsPanel'; -import { COUNTRIES } from '@/lib/countries'; +import { CountryFlag } from '@/components/CountryFlag'; +import { getCountryInfo } from '@/lib/countries'; interface CountryStatsDialogProps { /** ISO 3166-1 alpha-2 country code (e.g. `VE`). */ @@ -31,8 +32,8 @@ interface CountryStatsDialogProps { * trigger inside a dropdown menu without rendering two visible affordances. */ export function CountryStatsDialog({ countryCode, open, onOpenChange }: CountryStatsDialogProps) { - const country = COUNTRIES[countryCode.toUpperCase()]; - const countryName = country?.name ?? countryCode; + const country = getCountryInfo(countryCode); + const countryName = country?.subdivisionName ?? country?.name ?? countryCode; const flag = country?.flag ?? ''; return ( @@ -43,13 +44,12 @@ export function CountryStatsDialog({ countryCode, open, onOpenChange }: CountryS {flag ? ( - - {flag} - + ) : ( )} diff --git a/src/lib/countries.ts b/src/lib/countries.ts index 8fbfc4fe..c743d75e 100644 --- a/src/lib/countries.ts +++ b/src/lib/countries.ts @@ -95,6 +95,7 @@ export const COUNTRIES: Record = { KZ: { name: 'Kazakhstan', flag: '🇰🇿' }, KE: { name: 'Kenya', flag: '🇰🇪' }, KI: { name: 'Kiribati', flag: '🇰🇮' }, + XK: { name: 'Kosovo', flag: '🌍' }, KP: { name: 'North Korea', flag: '🇰🇵' }, KR: { name: 'South Korea', flag: '🇰🇷' }, KW: { name: 'Kuwait', flag: '🇰🇼' }, @@ -199,15 +200,30 @@ export const COUNTRIES: Record = { VA: { name: 'Vatican City', flag: '🇻🇦' }, VE: { name: 'Venezuela', flag: '🇻🇪' }, VN: { name: 'Vietnam', flag: '🇻🇳' }, + EH: { name: 'Western Sahara', flag: '🇪🇭' }, YE: { name: 'Yemen', flag: '🇾🇪' }, ZM: { name: 'Zambia', flag: '🇿🇲' }, ZW: { name: 'Zimbabwe', flag: '🇿🇼' }, }; /** Pre-sorted array of country entries for searching. */ -export const COUNTRY_LIST = Object.entries(COUNTRIES) - .map(([code, { name, flag }]) => ({ code, name, flag })) - .sort((a, b) => a.name.localeCompare(b.name)); +export const COUNTRY_LIST = (() => { + const base = Object.entries(COUNTRIES).map(([code, { name, flag }]) => ({ code, name, flag })); + + // Promote a handful of ISO 3166-2 subdivisions to country-level entries + // in the search list. These are editorial choices to surface places that + // are commonly thought of as countries but lack their own ISO 3166-1 + // code. The on-wire identifier stays `iso3166:CC-XX` so we don't fork + // a parallel addressing scheme — only the picker pretends. + const promoted: { code: string; name: string; flag: string }[] = [ + // Tibet (CN-XZ) — bundled Snow Lion SVG renders via CountryFlag; the + // `flag` field here is the text fallback for raw-text consumers, so + // we use the parent-country emoji rather than nothing. + { code: 'CN-XZ', name: 'Tibet', flag: '🇨🇳' }, + ]; + + return [...base, ...promoted].sort((a, b) => a.name.localeCompare(b.name)); +})(); export type CountryEntry = typeof COUNTRY_LIST[number]; @@ -390,15 +406,15 @@ export function isValidGeoCode(code: string): boolean { // --------------------------------------------------------------------------- /** - * Return the list of ISO 3166-1 countries Agora knows about, sorted - * alphabetically by English name. Pathos exposes a localized variant — Agora - * is currently English-only so the `lang` argument is ignored. Kept for - * call-site compatibility with ports. + * Return the list of countries Agora surfaces for picker UIs, sorted + * alphabetically by English name. Mirrors {@link COUNTRY_LIST} (which + * also includes editorially promoted ISO 3166-2 entries like Tibet). + * Pathos exposes a localized variant — Agora is currently English-only + * so the `lang` argument is ignored. Kept for call-site compatibility + * with ports. */ export function getAllCountries(_lang?: string): { code: string; name: string; flag: string }[] { - return Object.entries(COUNTRIES) - .map(([code, info]) => ({ code, name: info.name, flag: info.flag })) - .sort((a, b) => a.name.localeCompare(b.name)); + return COUNTRY_LIST.map(({ code, name, flag }) => ({ code, name, flag })); } /** @@ -428,6 +444,11 @@ export function countryCodeToFlag(code: string): string { const upper = code.toUpperCase(); const parentCode = upper.includes('-') ? upper.split('-')[0] : upper; if (!/^[A-Z]{2}$/.test(parentCode)) return ''; + // Honour explicit overrides first — covers user-assigned codes like + // Kosovo (`XK`) whose regional-indicator sequence has no associated + // Unicode flag glyph and would otherwise render as raw letters. + const explicit = COUNTRIES[parentCode]?.flag; + if (explicit) return explicit; // Regional indicator symbols start at U+1F1E6 (🇦); A=0x41. return parentCode .split('') diff --git a/src/lib/customFlags.ts b/src/lib/customFlags.ts index 9f4c8258..3bd0eea8 100644 --- a/src/lib/customFlags.ts +++ b/src/lib/customFlags.ts @@ -4,13 +4,18 @@ * these as `` elements; callers that need the raw URL (e.g. a * card backdrop, a CSS background) use {@link customFlagAsset}. * - * Editorial choice: Tibet (ISO 3166-2 `CN-XZ`) is surfaced as a - * country-level entity with the Snow Lion flag, matching the older - * Agora codebase. Add additional entries here as new bundled assets - * land in `/public`. + * Editorial choices: + * - Tibet (ISO 3166-2 `CN-XZ`) is surfaced as a country-level entity + * with the Snow Lion flag, matching the older Agora codebase. + * - Kosovo (`XK`) is a user-assigned ISO 3166-1 code with no Unicode + * emoji flag, so we bundle its SVG to render alongside the rest of + * the country list. + * + * Add additional entries here as new bundled assets land in `/public`. */ const CUSTOM_FLAG_ASSETS: Record = { 'CN-XZ': '/flag-tibet.svg', + 'XK': '/flag-kosovo.svg', }; /** diff --git a/src/pages/CreateActionPage.tsx b/src/pages/CreateActionPage.tsx index 524648fd..0d8ede58 100644 --- a/src/pages/CreateActionPage.tsx +++ b/src/pages/CreateActionPage.tsx @@ -15,6 +15,7 @@ import { } from 'lucide-react'; import { CoverImageField } from '@/components/CoverImageField'; +import { CountryFlag } from '@/components/CountryFlag'; import { FormSection } from '@/components/FormSection'; import { OrganizationContextChip } from '@/components/OrganizationContextChip'; import { TimezoneSwitcher } from '@/components/TimezoneSwitcher'; @@ -31,7 +32,7 @@ import { useManageableOrganizations } from '@/hooks/useManageableOrganizations'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { usdToSats } from '@/lib/bitcoin'; -import { COUNTRIES, searchCountries, type CountryEntry } from '@/lib/countries'; +import { getCountryInfo, searchCountries, type CountryEntry } from '@/lib/countries'; import { parseContentTagInput } from '@/lib/contentTags'; import { createCountryIdentifier } from '@/lib/countryIdentifiers'; import { getTodayDateInput } from '@/lib/dateInput'; @@ -87,7 +88,7 @@ export function CreateActionPage() { const [coverImage, setCoverImage] = useState(''); const [coverUploading, setCoverUploading] = useState(false); const [countryCode, setCountryCode] = useState(pageCountryCode); - const [countryQuery, setCountryQuery] = useState(pageCountryCode ? (COUNTRIES[pageCountryCode]?.name ?? pageCountryCode) : ''); + const [countryQuery, setCountryQuery] = useState(pageCountryCode ? (getCountryInfo(pageCountryCode)?.subdivisionName ?? getCountryInfo(pageCountryCode)?.name ?? pageCountryCode) : ''); // Effective org coordinate to attach on publish. Sourced only from the // URL — never editable inside the form. Drops to '' when the user // isn't authorized for the param's org. @@ -293,8 +294,9 @@ export function CreateActionPage() { selectedCode={countryCode} onQueryChange={(value) => { setCountryQuery(value); - const selectedCountry = countryCode ? COUNTRIES[countryCode] : undefined; - if (selectedCountry && value !== selectedCountry.name && value.toUpperCase() !== countryCode) { + const selectedCountry = countryCode ? getCountryInfo(countryCode) : undefined; + const selectedName = selectedCountry?.subdivisionName ?? selectedCountry?.name; + if (selectedCountry && value !== selectedName && value.toUpperCase() !== countryCode) { setCountryCode(''); } }} @@ -447,7 +449,7 @@ function CountrySelect({ const { t } = useTranslation(); const [open, setOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); - const selectedCountry = selectedCode ? COUNTRIES[selectedCode] : undefined; + const selectedCountry = selectedCode ? getCountryInfo(selectedCode) : undefined; const results = useMemo(() => searchCountries(query), [query]); const showResults = open && results.length > 0; @@ -523,8 +525,13 @@ function CountrySelect({ index === selectedIndex && 'bg-secondary/60', )} > - - {country.flag} + + {country.name} diff --git a/src/pages/CreateCampaignPage.tsx b/src/pages/CreateCampaignPage.tsx index 2690a08d..0aa989ac 100644 --- a/src/pages/CreateCampaignPage.tsx +++ b/src/pages/CreateCampaignPage.tsx @@ -17,6 +17,7 @@ import { } from 'lucide-react'; import { CoverImageField } from '@/components/CoverImageField'; +import { CountryFlag } from '@/components/CountryFlag'; import { FormSection } from '@/components/FormSection'; import { OrganizationContextChip } from '@/components/OrganizationContextChip'; import { LoginArea } from '@/components/auth/LoginArea'; @@ -54,7 +55,7 @@ import { genUserName } from '@/lib/genUserName'; import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/lib/organizationContext'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { withAgoraTag } from '@/lib/agoraNoteTags'; -import { COUNTRIES, searchCountries, type CountryEntry } from '@/lib/countries'; +import { getCountryInfo, searchCountries, type CountryEntry } from '@/lib/countries'; import { getEditableContentTags, parseContentTagInput } from '@/lib/contentTags'; import { createCountryIdentifier } from '@/lib/countryIdentifiers'; import { cn } from '@/lib/utils'; @@ -281,7 +282,7 @@ export function CreateCampaignPage() { setDeadline(formatDateInput(editCampaign.deadline)); const editCountryCode = editCampaign.countryCode ?? ''; setCountryCode(editCountryCode); - setCountryQuery(editCountryCode ? COUNTRIES[editCountryCode]?.name ?? editCountryCode : ''); + setCountryQuery(editCountryCode ? (getCountryInfo(editCountryCode)?.subdivisionName ?? getCountryInfo(editCountryCode)?.name ?? editCountryCode) : ''); setTagInput(getEditableContentTags(editCampaign.event.tags).join(', ')); const existingOrgATag = editCampaign.event.tags.find( ([n, v]) => n === 'A' && typeof v === 'string' && v.startsWith('34550:'), @@ -692,8 +693,9 @@ export function CreateCampaignPage() { selectedCode={countryCode} onQueryChange={(value) => { setCountryQuery(value); - const selectedCountry = countryCode ? COUNTRIES[countryCode] : undefined; - if (selectedCountry && value !== selectedCountry.name && value.toUpperCase() !== countryCode) { + const selectedCountry = countryCode ? getCountryInfo(countryCode) : undefined; + const selectedName = selectedCountry?.subdivisionName ?? selectedCountry?.name; + if (selectedCountry && value !== selectedName && value.toUpperCase() !== countryCode) { setCountryCode(''); } }} @@ -1058,7 +1060,7 @@ function CountrySelect({ const { t } = useTranslation(); const [open, setOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); - const selectedCountry = selectedCode ? COUNTRIES[selectedCode] : undefined; + const selectedCountry = selectedCode ? getCountryInfo(selectedCode) : undefined; const results = useMemo(() => searchCountries(query), [query]); const showResults = open && results.length > 0; @@ -1134,8 +1136,13 @@ function CountrySelect({ index === selectedIndex && 'bg-secondary/60', )} > - - {country.flag} + + {country.name} diff --git a/src/pages/CreateCommunityPage.tsx b/src/pages/CreateCommunityPage.tsx index 8768e45e..ffeaaa15 100644 --- a/src/pages/CreateCommunityPage.tsx +++ b/src/pages/CreateCommunityPage.tsx @@ -17,6 +17,7 @@ import { import { PersonSearch } from '@/components/PersonSearch'; import { CoverImageField } from '@/components/CoverImageField'; +import { CountryFlag } from '@/components/CountryFlag'; import { FormSection } from '@/components/FormSection'; import { LoginArea } from '@/components/auth/LoginArea'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -38,7 +39,7 @@ import { type ParsedCommunity, } from '@/lib/communityUtils'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; -import { COUNTRIES, searchCountries, type CountryEntry } from '@/lib/countries'; +import { getCountryInfo, searchCountries, type CountryEntry } from '@/lib/countries'; import { parseContentTagInput } from '@/lib/contentTags'; import { createCountryIdentifier } from '@/lib/countryIdentifiers'; import { genUserName } from '@/lib/genUserName'; @@ -257,7 +258,7 @@ export function CreateCommunityPage() { setImageUrl(editCommunity.community.image ?? ''); const editCountryCode = editCommunity.community.countryCode ?? ''; setCountryCode(editCountryCode); - setCountryQuery(editCountryCode ? COUNTRIES[editCountryCode]?.name ?? editCountryCode : ''); + setCountryQuery(editCountryCode ? (getCountryInfo(editCountryCode)?.subdivisionName ?? getCountryInfo(editCountryCode)?.name ?? editCountryCode) : ''); setTagInput(editCommunity.community.topicTags.join(', ')); setModerators(editCommunity.community.moderatorPubkeys.map(makeProfileFromPubkey)); setPrepopulatedEventId(editCommunity.event.id); @@ -608,8 +609,9 @@ export function CreateCommunityPage() { selectedCode={countryCode} onQueryChange={(value) => { setCountryQuery(value); - const selectedCountry = countryCode ? COUNTRIES[countryCode] : undefined; - if (selectedCountry && value !== selectedCountry.name && value.toUpperCase() !== countryCode) { + const selectedCountry = countryCode ? getCountryInfo(countryCode) : undefined; + const selectedName = selectedCountry?.subdivisionName ?? selectedCountry?.name; + if (selectedCountry && value !== selectedName && value.toUpperCase() !== countryCode) { setCountryCode(''); } }} @@ -729,7 +731,7 @@ function CountrySelect({ const { t } = useTranslation(); const [open, setOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); - const selectedCountry = selectedCode ? COUNTRIES[selectedCode] : undefined; + const selectedCountry = selectedCode ? getCountryInfo(selectedCode) : undefined; const results = useMemo(() => searchCountries(query), [query]); const showResults = open && results.length > 0; @@ -805,8 +807,13 @@ function CountrySelect({ index === selectedIndex && 'bg-secondary/60', )} > - - {country.flag} + + {country.name} diff --git a/src/pages/MyDashboardPage.tsx b/src/pages/MyDashboardPage.tsx index a914ae62..29dc1be0 100644 --- a/src/pages/MyDashboardPage.tsx +++ b/src/pages/MyDashboardPage.tsx @@ -41,7 +41,7 @@ import { useNotificationPreview } from '@/hooks/useNotificationPreview'; import { useUserOrganizations, type UserOrganization } from '@/hooks/useUserOrganizations'; import { satsToUSD, formatBTC } from '@/lib/bitcoin'; -import { COUNTRIES } from '@/lib/countries'; +import { getCountryInfo } from '@/lib/countries'; import { getDisplayName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; @@ -533,8 +533,8 @@ function CountriesSection({ ) : followedCountries.length > 0 ? (
{followedCountries.map((code) => { - const info = COUNTRIES[code]; - const name = info?.name ?? code; + const info = getCountryInfo(code); + const name = info?.subdivisionName ?? info?.name ?? code; const flag = info?.flag ?? ''; return (