You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailscale/client/web/src/components/exit-node-selector.tsx

585 lines
14 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useAPI } from "src/api"
import Check from "src/assets/icons/check.svg?react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import useExitNodes, {
noExitNode,
runAsExitNode,
trimDNSSuffix,
} from "src/hooks/exit-nodes"
import { ExitNode, NodeData } from "src/types"
import Popover from "src/ui/popover"
import SearchInput from "src/ui/search-input"
import { useSWRConfig } from "swr"
export default function ExitNodeSelector({
className,
node,
disabled,
}: {
className?: string
node: NodeData
disabled?: boolean
}) {
const api = useAPI()
const [open, setOpen] = useState<boolean>(false)
const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
const [pending, setPending] = useState<boolean>(false)
const { mutate } = useSWRConfig() // allows for global mutation
useEffect(() => setSelected(toSelectedExitNode(node)), [node])
useEffect(() => {
setPending(
node.AdvertisingExitNode && node.AdvertisingExitNodeApproved === false
)
}, [node])
const handleSelect = useCallback(
(n: ExitNode) => {
setOpen(false)
if (n.ID === selected.ID) {
return // no update
}
// Eager clear of pending state to avoid UI oddities
if (n.ID !== runAsExitNode.ID) {
setPending(false)
}
api({ action: "update-exit-node", data: n })
// refresh data after short timeout to pick up any pending approval updates
setTimeout(() => {
mutate("/data")
}, 1000)
},
[api, mutate, selected.ID]
)
const [
none, // not using exit nodes
advertising, // advertising as exit node
using, // using another exit node
offline, // selected exit node node is offline
] = useMemo(
() => [
selected.ID === noExitNode.ID,
selected.ID === runAsExitNode.ID,
selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID,
!selected.Online,
],
[selected.ID, selected.Online]
)
return (
<div
className={cx(
"rounded-md",
{
"bg-red-600": offline,
"bg-yellow-400": pending,
},
className
)}
>
<div
className={cx("p-1.5 rounded-md border flex items-stretch gap-1.5", {
"border-gray-200": none,
"bg-yellow-300 border-yellow-300": advertising && !offline,
"bg-blue-500 border-blue-500": using && !offline,
"bg-red-500 border-red-500": offline,
})}
>
<Popover
open={disabled ? false : open}
onOpenChange={setOpen}
className="overflow-hidden"
side="bottom"
sideOffset={0}
align="start"
content={
<ExitNodeSelectorInner
node={node}
selected={selected}
onSelect={handleSelect}
/>
}
asChild
>
<button
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
"bg-white": none,
"hover:bg-gray-100": none && !disabled,
"bg-yellow-300": advertising && !offline,
"hover:bg-yellow-200": advertising && !offline && !disabled,
"bg-blue-500": using && !offline,
"hover:bg-blue-400": using && !offline && !disabled,
"bg-red-500": offline,
"hover:bg-red-400": offline && !disabled,
})}
onClick={() => setOpen(!open)}
disabled={disabled}
>
<p
className={cx(
"text-gray-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
{ "opacity-70 text-white": advertising || using }
)}
>
Exit node{offline && " offline"}
</p>
<div className="flex items-center">
<p
className={cx("text-gray-800", {
"text-white": advertising || using,
})}
>
{selected.Location && (
<>
<CountryFlag code={selected.Location.CountryCode} />{" "}
</>
)}
{selected === runAsExitNode
? "Running as exit node"
: selected.Name}
</p>
{!disabled && (
<ChevronDown
className={cx("ml-1", {
"stroke-gray-800": none,
"stroke-white": advertising || using,
})}
/>
)}
</div>
</button>
</Popover>
{!disabled && (advertising || using) && (
<button
className={cx("px-3 py-2 rounded-sm text-white", {
"hover:bg-yellow-200": advertising && !offline,
"hover:bg-blue-400": using && !offline,
"hover:bg-red-400": offline,
})}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleSelect(noExitNode)
}}
>
Disable
</button>
)}
</div>
{offline && (
<p className="text-white p-3">
The selected exit node is currently offline. Your internet traffic is
blocked until you disable the exit node or select a different one.
</p>
)}
{pending && (
<p className="text-white p-3">
Pending approval to run as exit node. This device wonโ€™t be usable as
an exit node until then.
</p>
)}
</div>
)
}
function toSelectedExitNode(data: NodeData): ExitNode {
if (data.AdvertisingExitNode) {
return runAsExitNode
}
if (data.UsingExitNode) {
// TODO(sonia): also use online status
const node = { ...data.UsingExitNode }
if (node.Location) {
// For mullvad nodes, use location as name.
node.Name = `${node.Location.Country}: ${node.Location.City}`
} else {
// Otherwise use node name w/o DNS suffix.
node.Name = trimDNSSuffix(node.Name, data.TailnetName)
}
return node
}
return noExitNode
}
function ExitNodeSelectorInner({
node,
selected,
onSelect,
}: {
node: NodeData
selected: ExitNode
onSelect: (node: ExitNode) => void
}) {
const [filter, setFilter] = useState<string>("")
const { data: exitNodes } = useExitNodes(node, filter)
const listRef = useRef<HTMLDivElement>(null)
const hasNodes = useMemo(
() => exitNodes.find((n) => n.nodes.length > 0),
[exitNodes]
)
return (
<div className="w-[var(--radix-popover-trigger-width)]">
<SearchInput
name="exit-node-search"
className="px-2"
inputClassName="w-full py-3 !h-auto border-none rounded-b-none !ring-0"
autoFocus
autoCorrect="off"
autoComplete="off"
autoCapitalize="off"
placeholder="Search exit nodesโ€ฆ"
value={filter}
onChange={(e) => {
// Jump list to top when search value changes.
listRef.current?.scrollTo(0, 0)
setFilter(e.target.value)
}}
/>
{/* TODO(sonia): use loading spinner when loading useExitNodes */}
<div
ref={listRef}
className="pt-1 border-t border-gray-200 max-h-60 overflow-y-scroll"
>
{hasNodes ? (
exitNodes.map(
(group) =>
group.nodes.length > 0 && (
<div
key={group.id}
className="pb-1 mb-1 border-b last:border-b-0 border-gray-200 last:mb-0"
>
{group.name && (
<div className="px-4 py-2 text-gray-500 text-xs font-medium uppercase tracking-wide">
{group.name}
</div>
)}
{group.nodes.map((n) => (
<ExitNodeSelectorItem
key={`${n.ID}-${n.Name}`}
node={n}
onSelect={() => onSelect(n)}
isSelected={selected.ID === n.ID}
/>
))}
</div>
)
)
) : (
<div className="text-center truncate text-gray-500 p-5">
{filter
? `No exit nodes matching โ€œ${filter}โ€`
: "No exit nodes available"}
</div>
)}
</div>
</div>
)
}
function ExitNodeSelectorItem({
node,
isSelected,
onSelect,
}: {
node: ExitNode
isSelected: boolean
onSelect: () => void
}) {
return (
<button
key={node.ID}
className={cx(
"w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-gray-100",
{
"text-gray-400 cursor-not-allowed": !node.Online,
}
)}
onClick={onSelect}
disabled={!node.Online}
>
<div className="w-full">
{node.Location && (
<>
<CountryFlag code={node.Location.CountryCode} />{" "}
</>
)}
<span className="leading-snug">{node.Name}</span>
</div>
{node.Online || <span className="leading-snug">Offline</span>}
{isSelected && <Check className="ml-1" />}
</button>
)
}
function CountryFlag({ code }: { code: string }) {
return (
<>{countryFlags[code.toLowerCase()]}</> || (
<span className="font-medium text-gray-500 text-xs">
{code.toUpperCase()}
</span>
)
)
}
const countryFlags: { [countryCode: string]: string } = {
ad: "๐Ÿ‡ฆ๐Ÿ‡ฉ",
ae: "๐Ÿ‡ฆ๐Ÿ‡ช",
af: "๐Ÿ‡ฆ๐Ÿ‡ซ",
ag: "๐Ÿ‡ฆ๐Ÿ‡ฌ",
ai: "๐Ÿ‡ฆ๐Ÿ‡ฎ",
al: "๐Ÿ‡ฆ๐Ÿ‡ฑ",
am: "๐Ÿ‡ฆ๐Ÿ‡ฒ",
ao: "๐Ÿ‡ฆ๐Ÿ‡ด",
aq: "๐Ÿ‡ฆ๐Ÿ‡ถ",
ar: "๐Ÿ‡ฆ๐Ÿ‡ท",
as: "๐Ÿ‡ฆ๐Ÿ‡ธ",
at: "๐Ÿ‡ฆ๐Ÿ‡น",
au: "๐Ÿ‡ฆ๐Ÿ‡บ",
aw: "๐Ÿ‡ฆ๐Ÿ‡ผ",
ax: "๐Ÿ‡ฆ๐Ÿ‡ฝ",
az: "๐Ÿ‡ฆ๐Ÿ‡ฟ",
ba: "๐Ÿ‡ง๐Ÿ‡ฆ",
bb: "๐Ÿ‡ง๐Ÿ‡ง",
bd: "๐Ÿ‡ง๐Ÿ‡ฉ",
be: "๐Ÿ‡ง๐Ÿ‡ช",
bf: "๐Ÿ‡ง๐Ÿ‡ซ",
bg: "๐Ÿ‡ง๐Ÿ‡ฌ",
bh: "๐Ÿ‡ง๐Ÿ‡ญ",
bi: "๐Ÿ‡ง๐Ÿ‡ฎ",
bj: "๐Ÿ‡ง๐Ÿ‡ฏ",
bl: "๐Ÿ‡ง๐Ÿ‡ฑ",
bm: "๐Ÿ‡ง๐Ÿ‡ฒ",
bn: "๐Ÿ‡ง๐Ÿ‡ณ",
bo: "๐Ÿ‡ง๐Ÿ‡ด",
bq: "๐Ÿ‡ง๐Ÿ‡ถ",
br: "๐Ÿ‡ง๐Ÿ‡ท",
bs: "๐Ÿ‡ง๐Ÿ‡ธ",
bt: "๐Ÿ‡ง๐Ÿ‡น",
bv: "๐Ÿ‡ง๐Ÿ‡ป",
bw: "๐Ÿ‡ง๐Ÿ‡ผ",
by: "๐Ÿ‡ง๐Ÿ‡พ",
bz: "๐Ÿ‡ง๐Ÿ‡ฟ",
ca: "๐Ÿ‡จ๐Ÿ‡ฆ",
cc: "๐Ÿ‡จ๐Ÿ‡จ",
cd: "๐Ÿ‡จ๐Ÿ‡ฉ",
cf: "๐Ÿ‡จ๐Ÿ‡ซ",
cg: "๐Ÿ‡จ๐Ÿ‡ฌ",
ch: "๐Ÿ‡จ๐Ÿ‡ญ",
ci: "๐Ÿ‡จ๐Ÿ‡ฎ",
ck: "๐Ÿ‡จ๐Ÿ‡ฐ",
cl: "๐Ÿ‡จ๐Ÿ‡ฑ",
cm: "๐Ÿ‡จ๐Ÿ‡ฒ",
cn: "๐Ÿ‡จ๐Ÿ‡ณ",
co: "๐Ÿ‡จ๐Ÿ‡ด",
cr: "๐Ÿ‡จ๐Ÿ‡ท",
cu: "๐Ÿ‡จ๐Ÿ‡บ",
cv: "๐Ÿ‡จ๐Ÿ‡ป",
cw: "๐Ÿ‡จ๐Ÿ‡ผ",
cx: "๐Ÿ‡จ๐Ÿ‡ฝ",
cy: "๐Ÿ‡จ๐Ÿ‡พ",
cz: "๐Ÿ‡จ๐Ÿ‡ฟ",
de: "๐Ÿ‡ฉ๐Ÿ‡ช",
dj: "๐Ÿ‡ฉ๐Ÿ‡ฏ",
dk: "๐Ÿ‡ฉ๐Ÿ‡ฐ",
dm: "๐Ÿ‡ฉ๐Ÿ‡ฒ",
do: "๐Ÿ‡ฉ๐Ÿ‡ด",
dz: "๐Ÿ‡ฉ๐Ÿ‡ฟ",
ec: "๐Ÿ‡ช๐Ÿ‡จ",
ee: "๐Ÿ‡ช๐Ÿ‡ช",
eg: "๐Ÿ‡ช๐Ÿ‡ฌ",
eh: "๐Ÿ‡ช๐Ÿ‡ญ",
er: "๐Ÿ‡ช๐Ÿ‡ท",
es: "๐Ÿ‡ช๐Ÿ‡ธ",
et: "๐Ÿ‡ช๐Ÿ‡น",
eu: "๐Ÿ‡ช๐Ÿ‡บ",
fi: "๐Ÿ‡ซ๐Ÿ‡ฎ",
fj: "๐Ÿ‡ซ๐Ÿ‡ฏ",
fk: "๐Ÿ‡ซ๐Ÿ‡ฐ",
fm: "๐Ÿ‡ซ๐Ÿ‡ฒ",
fo: "๐Ÿ‡ซ๐Ÿ‡ด",
fr: "๐Ÿ‡ซ๐Ÿ‡ท",
ga: "๐Ÿ‡ฌ๐Ÿ‡ฆ",
gb: "๐Ÿ‡ฌ๐Ÿ‡ง",
gd: "๐Ÿ‡ฌ๐Ÿ‡ฉ",
ge: "๐Ÿ‡ฌ๐Ÿ‡ช",
gf: "๐Ÿ‡ฌ๐Ÿ‡ซ",
gg: "๐Ÿ‡ฌ๐Ÿ‡ฌ",
gh: "๐Ÿ‡ฌ๐Ÿ‡ญ",
gi: "๐Ÿ‡ฌ๐Ÿ‡ฎ",
gl: "๐Ÿ‡ฌ๐Ÿ‡ฑ",
gm: "๐Ÿ‡ฌ๐Ÿ‡ฒ",
gn: "๐Ÿ‡ฌ๐Ÿ‡ณ",
gp: "๐Ÿ‡ฌ๐Ÿ‡ต",
gq: "๐Ÿ‡ฌ๐Ÿ‡ถ",
gr: "๐Ÿ‡ฌ๐Ÿ‡ท",
gs: "๐Ÿ‡ฌ๐Ÿ‡ธ",
gt: "๐Ÿ‡ฌ๐Ÿ‡น",
gu: "๐Ÿ‡ฌ๐Ÿ‡บ",
gw: "๐Ÿ‡ฌ๐Ÿ‡ผ",
gy: "๐Ÿ‡ฌ๐Ÿ‡พ",
hk: "๐Ÿ‡ญ๐Ÿ‡ฐ",
hm: "๐Ÿ‡ญ๐Ÿ‡ฒ",
hn: "๐Ÿ‡ญ๐Ÿ‡ณ",
hr: "๐Ÿ‡ญ๐Ÿ‡ท",
ht: "๐Ÿ‡ญ๐Ÿ‡น",
hu: "๐Ÿ‡ญ๐Ÿ‡บ",
id: "๐Ÿ‡ฎ๐Ÿ‡ฉ",
ie: "๐Ÿ‡ฎ๐Ÿ‡ช",
il: "๐Ÿ‡ฎ๐Ÿ‡ฑ",
im: "๐Ÿ‡ฎ๐Ÿ‡ฒ",
in: "๐Ÿ‡ฎ๐Ÿ‡ณ",
io: "๐Ÿ‡ฎ๐Ÿ‡ด",
iq: "๐Ÿ‡ฎ๐Ÿ‡ถ",
ir: "๐Ÿ‡ฎ๐Ÿ‡ท",
is: "๐Ÿ‡ฎ๐Ÿ‡ธ",
it: "๐Ÿ‡ฎ๐Ÿ‡น",
je: "๐Ÿ‡ฏ๐Ÿ‡ช",
jm: "๐Ÿ‡ฏ๐Ÿ‡ฒ",
jo: "๐Ÿ‡ฏ๐Ÿ‡ด",
jp: "๐Ÿ‡ฏ๐Ÿ‡ต",
ke: "๐Ÿ‡ฐ๐Ÿ‡ช",
kg: "๐Ÿ‡ฐ๐Ÿ‡ฌ",
kh: "๐Ÿ‡ฐ๐Ÿ‡ญ",
ki: "๐Ÿ‡ฐ๐Ÿ‡ฎ",
km: "๐Ÿ‡ฐ๐Ÿ‡ฒ",
kn: "๐Ÿ‡ฐ๐Ÿ‡ณ",
kp: "๐Ÿ‡ฐ๐Ÿ‡ต",
kr: "๐Ÿ‡ฐ๐Ÿ‡ท",
kw: "๐Ÿ‡ฐ๐Ÿ‡ผ",
ky: "๐Ÿ‡ฐ๐Ÿ‡พ",
kz: "๐Ÿ‡ฐ๐Ÿ‡ฟ",
la: "๐Ÿ‡ฑ๐Ÿ‡ฆ",
lb: "๐Ÿ‡ฑ๐Ÿ‡ง",
lc: "๐Ÿ‡ฑ๐Ÿ‡จ",
li: "๐Ÿ‡ฑ๐Ÿ‡ฎ",
lk: "๐Ÿ‡ฑ๐Ÿ‡ฐ",
lr: "๐Ÿ‡ฑ๐Ÿ‡ท",
ls: "๐Ÿ‡ฑ๐Ÿ‡ธ",
lt: "๐Ÿ‡ฑ๐Ÿ‡น",
lu: "๐Ÿ‡ฑ๐Ÿ‡บ",
lv: "๐Ÿ‡ฑ๐Ÿ‡ป",
ly: "๐Ÿ‡ฑ๐Ÿ‡พ",
ma: "๐Ÿ‡ฒ๐Ÿ‡ฆ",
mc: "๐Ÿ‡ฒ๐Ÿ‡จ",
md: "๐Ÿ‡ฒ๐Ÿ‡ฉ",
me: "๐Ÿ‡ฒ๐Ÿ‡ช",
mf: "๐Ÿ‡ฒ๐Ÿ‡ซ",
mg: "๐Ÿ‡ฒ๐Ÿ‡ฌ",
mh: "๐Ÿ‡ฒ๐Ÿ‡ญ",
mk: "๐Ÿ‡ฒ๐Ÿ‡ฐ",
ml: "๐Ÿ‡ฒ๐Ÿ‡ฑ",
mm: "๐Ÿ‡ฒ๐Ÿ‡ฒ",
mn: "๐Ÿ‡ฒ๐Ÿ‡ณ",
mo: "๐Ÿ‡ฒ๐Ÿ‡ด",
mp: "๐Ÿ‡ฒ๐Ÿ‡ต",
mq: "๐Ÿ‡ฒ๐Ÿ‡ถ",
mr: "๐Ÿ‡ฒ๐Ÿ‡ท",
ms: "๐Ÿ‡ฒ๐Ÿ‡ธ",
mt: "๐Ÿ‡ฒ๐Ÿ‡น",
mu: "๐Ÿ‡ฒ๐Ÿ‡บ",
mv: "๐Ÿ‡ฒ๐Ÿ‡ป",
mw: "๐Ÿ‡ฒ๐Ÿ‡ผ",
mx: "๐Ÿ‡ฒ๐Ÿ‡ฝ",
my: "๐Ÿ‡ฒ๐Ÿ‡พ",
mz: "๐Ÿ‡ฒ๐Ÿ‡ฟ",
na: "๐Ÿ‡ณ๐Ÿ‡ฆ",
nc: "๐Ÿ‡ณ๐Ÿ‡จ",
ne: "๐Ÿ‡ณ๐Ÿ‡ช",
nf: "๐Ÿ‡ณ๐Ÿ‡ซ",
ng: "๐Ÿ‡ณ๐Ÿ‡ฌ",
ni: "๐Ÿ‡ณ๐Ÿ‡ฎ",
nl: "๐Ÿ‡ณ๐Ÿ‡ฑ",
no: "๐Ÿ‡ณ๐Ÿ‡ด",
np: "๐Ÿ‡ณ๐Ÿ‡ต",
nr: "๐Ÿ‡ณ๐Ÿ‡ท",
nu: "๐Ÿ‡ณ๐Ÿ‡บ",
nz: "๐Ÿ‡ณ๐Ÿ‡ฟ",
om: "๐Ÿ‡ด๐Ÿ‡ฒ",
pa: "๐Ÿ‡ต๐Ÿ‡ฆ",
pe: "๐Ÿ‡ต๐Ÿ‡ช",
pf: "๐Ÿ‡ต๐Ÿ‡ซ",
pg: "๐Ÿ‡ต๐Ÿ‡ฌ",
ph: "๐Ÿ‡ต๐Ÿ‡ญ",
pk: "๐Ÿ‡ต๐Ÿ‡ฐ",
pl: "๐Ÿ‡ต๐Ÿ‡ฑ",
pm: "๐Ÿ‡ต๐Ÿ‡ฒ",
pn: "๐Ÿ‡ต๐Ÿ‡ณ",
pr: "๐Ÿ‡ต๐Ÿ‡ท",
ps: "๐Ÿ‡ต๐Ÿ‡ธ",
pt: "๐Ÿ‡ต๐Ÿ‡น",
pw: "๐Ÿ‡ต๐Ÿ‡ผ",
py: "๐Ÿ‡ต๐Ÿ‡พ",
qa: "๐Ÿ‡ถ๐Ÿ‡ฆ",
re: "๐Ÿ‡ท๐Ÿ‡ช",
ro: "๐Ÿ‡ท๐Ÿ‡ด",
rs: "๐Ÿ‡ท๐Ÿ‡ธ",
ru: "๐Ÿ‡ท๐Ÿ‡บ",
rw: "๐Ÿ‡ท๐Ÿ‡ผ",
sa: "๐Ÿ‡ธ๐Ÿ‡ฆ",
sb: "๐Ÿ‡ธ๐Ÿ‡ง",
sc: "๐Ÿ‡ธ๐Ÿ‡จ",
sd: "๐Ÿ‡ธ๐Ÿ‡ฉ",
se: "๐Ÿ‡ธ๐Ÿ‡ช",
sg: "๐Ÿ‡ธ๐Ÿ‡ฌ",
sh: "๐Ÿ‡ธ๐Ÿ‡ญ",
si: "๐Ÿ‡ธ๐Ÿ‡ฎ",
sj: "๐Ÿ‡ธ๐Ÿ‡ฏ",
sk: "๐Ÿ‡ธ๐Ÿ‡ฐ",
sl: "๐Ÿ‡ธ๐Ÿ‡ฑ",
sm: "๐Ÿ‡ธ๐Ÿ‡ฒ",
sn: "๐Ÿ‡ธ๐Ÿ‡ณ",
so: "๐Ÿ‡ธ๐Ÿ‡ด",
sr: "๐Ÿ‡ธ๐Ÿ‡ท",
ss: "๐Ÿ‡ธ๐Ÿ‡ธ",
st: "๐Ÿ‡ธ๐Ÿ‡น",
sv: "๐Ÿ‡ธ๐Ÿ‡ป",
sx: "๐Ÿ‡ธ๐Ÿ‡ฝ",
sy: "๐Ÿ‡ธ๐Ÿ‡พ",
sz: "๐Ÿ‡ธ๐Ÿ‡ฟ",
tc: "๐Ÿ‡น๐Ÿ‡จ",
td: "๐Ÿ‡น๐Ÿ‡ฉ",
tf: "๐Ÿ‡น๐Ÿ‡ซ",
tg: "๐Ÿ‡น๐Ÿ‡ฌ",
th: "๐Ÿ‡น๐Ÿ‡ญ",
tj: "๐Ÿ‡น๐Ÿ‡ฏ",
tk: "๐Ÿ‡น๐Ÿ‡ฐ",
tl: "๐Ÿ‡น๐Ÿ‡ฑ",
tm: "๐Ÿ‡น๐Ÿ‡ฒ",
tn: "๐Ÿ‡น๐Ÿ‡ณ",
to: "๐Ÿ‡น๐Ÿ‡ด",
tr: "๐Ÿ‡น๐Ÿ‡ท",
tt: "๐Ÿ‡น๐Ÿ‡น",
tv: "๐Ÿ‡น๐Ÿ‡ป",
tw: "๐Ÿ‡น๐Ÿ‡ผ",
tz: "๐Ÿ‡น๐Ÿ‡ฟ",
ua: "๐Ÿ‡บ๐Ÿ‡ฆ",
ug: "๐Ÿ‡บ๐Ÿ‡ฌ",
um: "๐Ÿ‡บ๐Ÿ‡ฒ",
us: "๐Ÿ‡บ๐Ÿ‡ธ",
uy: "๐Ÿ‡บ๐Ÿ‡พ",
uz: "๐Ÿ‡บ๐Ÿ‡ฟ",
va: "๐Ÿ‡ป๐Ÿ‡ฆ",
vc: "๐Ÿ‡ป๐Ÿ‡จ",
ve: "๐Ÿ‡ป๐Ÿ‡ช",
vg: "๐Ÿ‡ป๐Ÿ‡ฌ",
vi: "๐Ÿ‡ป๐Ÿ‡ฎ",
vn: "๐Ÿ‡ป๐Ÿ‡ณ",
vu: "๐Ÿ‡ป๐Ÿ‡บ",
wf: "๐Ÿ‡ผ๐Ÿ‡ซ",
ws: "๐Ÿ‡ผ๐Ÿ‡ธ",
xk: "๐Ÿ‡ฝ๐Ÿ‡ฐ",
ye: "๐Ÿ‡พ๐Ÿ‡ช",
yt: "๐Ÿ‡พ๐Ÿ‡น",
za: "๐Ÿ‡ฟ๐Ÿ‡ฆ",
zm: "๐Ÿ‡ฟ๐Ÿ‡ฒ",
zw: "๐Ÿ‡ฟ๐Ÿ‡ผ",
}