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 (