2023-11-07 11:16:23 -05:00
|
|
|
import cx from "classnames"
|
2023-11-16 14:27:01 -05:00
|
|
|
import React, { useCallback, useMemo, useState } from "react"
|
|
|
|
import { ReactComponent as Check } from "src/assets/icons/check.svg"
|
|
|
|
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
|
2023-11-15 15:23:15 -05:00
|
|
|
import useExitNodes, {
|
|
|
|
ExitNode,
|
|
|
|
noExitNode,
|
|
|
|
runAsExitNode,
|
|
|
|
trimDNSSuffix,
|
|
|
|
} from "src/hooks/exit-nodes"
|
|
|
|
import { NodeData, NodeUpdate, PrefsUpdate } from "src/hooks/node-data"
|
|
|
|
import Popover from "src/ui/popover"
|
|
|
|
import SearchInput from "src/ui/search-input"
|
2023-11-07 11:16:23 -05:00
|
|
|
|
|
|
|
export default function ExitNodeSelector({
|
|
|
|
className,
|
|
|
|
node,
|
|
|
|
updateNode,
|
2023-11-15 15:23:15 -05:00
|
|
|
updatePrefs,
|
2023-11-09 16:19:22 -05:00
|
|
|
disabled,
|
2023-11-07 11:16:23 -05:00
|
|
|
}: {
|
|
|
|
className?: string
|
|
|
|
node: NodeData
|
|
|
|
updateNode: (update: NodeUpdate) => Promise<void> | undefined
|
2023-11-15 15:23:15 -05:00
|
|
|
updatePrefs: (p: PrefsUpdate) => Promise<void>
|
2023-11-09 16:19:22 -05:00
|
|
|
disabled?: boolean
|
2023-11-07 11:16:23 -05:00
|
|
|
}) {
|
|
|
|
const [open, setOpen] = useState<boolean>(false)
|
2023-11-15 15:23:15 -05:00
|
|
|
const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
|
2023-11-07 11:16:23 -05:00
|
|
|
|
|
|
|
const handleSelect = useCallback(
|
2023-11-15 15:23:15 -05:00
|
|
|
(n: ExitNode) => {
|
2023-11-07 11:16:23 -05:00
|
|
|
setOpen(false)
|
2023-11-15 15:23:15 -05:00
|
|
|
if (n.ID === selected.ID) {
|
2023-11-07 11:16:23 -05:00
|
|
|
return // no update
|
|
|
|
}
|
2023-11-15 15:23:15 -05:00
|
|
|
|
2023-11-07 11:16:23 -05:00
|
|
|
const old = selected
|
2023-11-15 15:23:15 -05:00
|
|
|
setSelected(n) // optimistic UI update
|
|
|
|
const reset = () => setSelected(old)
|
|
|
|
|
|
|
|
switch (n.ID) {
|
|
|
|
case noExitNode.ID: {
|
|
|
|
if (old === runAsExitNode) {
|
|
|
|
// stop advertising as exit node
|
|
|
|
updateNode({ AdvertiseExitNode: false })?.catch(reset)
|
|
|
|
} else {
|
|
|
|
// stop using exit node
|
|
|
|
updatePrefs({ ExitNodeIDSet: true, ExitNodeID: "" }).catch(reset)
|
|
|
|
}
|
2023-11-07 11:16:23 -05:00
|
|
|
break
|
2023-11-15 15:23:15 -05:00
|
|
|
}
|
|
|
|
case runAsExitNode.ID: {
|
|
|
|
const update = () =>
|
|
|
|
updateNode({ AdvertiseExitNode: true })?.catch(reset)
|
|
|
|
if (old !== noExitNode) {
|
|
|
|
// stop using exit node first
|
|
|
|
updatePrefs({ ExitNodeIDSet: true, ExitNodeID: "" })
|
|
|
|
.catch(reset)
|
|
|
|
.then(update)
|
|
|
|
} else {
|
|
|
|
update()
|
|
|
|
}
|
2023-11-07 11:16:23 -05:00
|
|
|
break
|
2023-11-15 15:23:15 -05:00
|
|
|
}
|
|
|
|
default: {
|
|
|
|
const update = () =>
|
|
|
|
updatePrefs({ ExitNodeIDSet: true, ExitNodeID: n.ID }).catch(reset)
|
|
|
|
if (old === runAsExitNode) {
|
|
|
|
// stop advertising as exit node first
|
|
|
|
updateNode({ AdvertiseExitNode: false })?.catch(reset).then(update)
|
|
|
|
} else {
|
|
|
|
update()
|
|
|
|
}
|
|
|
|
}
|
2023-11-07 11:16:23 -05:00
|
|
|
}
|
|
|
|
},
|
|
|
|
[setOpen, selected, setSelected]
|
|
|
|
)
|
|
|
|
|
|
|
|
const [
|
|
|
|
none, // not using exit nodes
|
|
|
|
advertising, // advertising as exit node
|
|
|
|
using, // using another exit node
|
|
|
|
] = useMemo(
|
|
|
|
() => [
|
2023-11-15 15:23:15 -05:00
|
|
|
selected.ID === noExitNode.ID,
|
|
|
|
selected.ID === runAsExitNode.ID,
|
|
|
|
selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID,
|
2023-11-07 11:16:23 -05:00
|
|
|
],
|
|
|
|
[selected]
|
|
|
|
)
|
|
|
|
|
|
|
|
return (
|
2023-11-15 15:23:15 -05:00
|
|
|
<Popover
|
|
|
|
open={disabled ? false : open}
|
|
|
|
onOpenChange={setOpen}
|
|
|
|
side="bottom"
|
|
|
|
sideOffset={5}
|
|
|
|
align="start"
|
|
|
|
alignOffset={8}
|
|
|
|
content={
|
|
|
|
<ExitNodeSelectorInner
|
|
|
|
node={node}
|
|
|
|
selected={selected}
|
|
|
|
onSelect={handleSelect}
|
|
|
|
/>
|
|
|
|
}
|
|
|
|
asChild
|
|
|
|
>
|
2023-11-07 11:16:23 -05:00
|
|
|
<div
|
|
|
|
className={cx(
|
|
|
|
"p-1.5 rounded-md border flex items-stretch gap-1.5",
|
|
|
|
{
|
|
|
|
"border-gray-200": none,
|
|
|
|
"bg-amber-600 border-amber-600": advertising,
|
|
|
|
"bg-indigo-500 border-indigo-500": using,
|
|
|
|
},
|
|
|
|
className
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
<button
|
2023-11-09 16:19:22 -05:00
|
|
|
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
|
2023-11-07 11:16:23 -05:00
|
|
|
"bg-white hover:bg-stone-100": none,
|
|
|
|
"bg-amber-600 hover:bg-orange-400": advertising,
|
|
|
|
"bg-indigo-500 hover:bg-indigo-400": using,
|
2023-11-09 16:19:22 -05:00
|
|
|
"cursor-not-allowed": disabled,
|
2023-11-07 11:16:23 -05:00
|
|
|
})}
|
|
|
|
onClick={() => setOpen(!open)}
|
2023-11-09 16:19:22 -05:00
|
|
|
disabled={disabled}
|
2023-11-07 11:16:23 -05:00
|
|
|
>
|
|
|
|
<p
|
|
|
|
className={cx(
|
|
|
|
"text-neutral-500 text-xs text-left font-medium uppercase tracking-wide mb-1",
|
|
|
|
{ "bg-opacity-70 text-white": advertising || using }
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
Exit node
|
|
|
|
</p>
|
|
|
|
<div className="flex items-center">
|
|
|
|
<p
|
|
|
|
className={cx("text-neutral-800", {
|
|
|
|
"text-white": advertising || using,
|
|
|
|
})}
|
|
|
|
>
|
2023-11-15 15:23:15 -05:00
|
|
|
{selected.Location && (
|
|
|
|
<>
|
|
|
|
<CountryFlag code={selected.Location.CountryCode} />{" "}
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
{selected === runAsExitNode
|
|
|
|
? "Running as exit node"
|
|
|
|
: selected.Name}
|
2023-11-07 11:16:23 -05:00
|
|
|
</p>
|
|
|
|
<ChevronDown
|
|
|
|
className={cx("ml-1", {
|
|
|
|
"stroke-neutral-800": none,
|
|
|
|
"stroke-white": advertising || using,
|
|
|
|
})}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</button>
|
|
|
|
{(advertising || using) && (
|
|
|
|
<button
|
2023-11-14 14:02:10 -08:00
|
|
|
className={cx("px-3 py-2 rounded-sm text-white", {
|
2023-11-07 11:16:23 -05:00
|
|
|
"bg-orange-400": advertising,
|
|
|
|
"bg-indigo-400": using,
|
2023-11-14 14:02:10 -08:00
|
|
|
"cursor-not-allowed": disabled,
|
2023-11-07 11:16:23 -05:00
|
|
|
})}
|
|
|
|
onClick={(e) => {
|
|
|
|
e.preventDefault()
|
|
|
|
e.stopPropagation()
|
|
|
|
handleSelect(noExitNode)
|
|
|
|
}}
|
2023-11-14 14:02:10 -08:00
|
|
|
disabled={disabled}
|
2023-11-07 11:16:23 -05:00
|
|
|
>
|
|
|
|
Disable
|
|
|
|
</button>
|
|
|
|
)}
|
|
|
|
</div>
|
2023-11-15 15:23:15 -05:00
|
|
|
</Popover>
|
2023-11-07 11:16:23 -05:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2023-11-15 15:23:15 -05:00
|
|
|
function toSelectedExitNode(data: NodeData): ExitNode {
|
|
|
|
if (data.AdvertiseExitNode) {
|
|
|
|
return runAsExitNode
|
|
|
|
}
|
|
|
|
if (data.ExitNodeStatus) {
|
|
|
|
// TODO(sonia): also use online status
|
|
|
|
const node = { ...data.ExitNodeStatus }
|
|
|
|
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,
|
2023-11-07 11:16:23 -05:00
|
|
|
selected,
|
|
|
|
onSelect,
|
|
|
|
}: {
|
2023-11-15 15:23:15 -05:00
|
|
|
node: NodeData
|
|
|
|
selected: ExitNode
|
|
|
|
onSelect: (node: ExitNode) => void
|
2023-11-07 11:16:23 -05:00
|
|
|
}) {
|
2023-11-15 15:23:15 -05:00
|
|
|
const [filter, setFilter] = useState<string>("")
|
|
|
|
const { data: exitNodes } = useExitNodes(node.TailnetName, filter)
|
|
|
|
|
|
|
|
const hasNodes = useMemo(
|
|
|
|
() => exitNodes.find((n) => n.nodes.length > 0),
|
|
|
|
[exitNodes]
|
|
|
|
)
|
|
|
|
|
2023-11-07 11:16:23 -05:00
|
|
|
return (
|
2023-11-15 15:23:15 -05:00
|
|
|
<div className="w-[calc(var(--radix-popover-trigger-width)-16px)] py-1 rounded-lg shadow">
|
|
|
|
<SearchInput
|
|
|
|
name="exit-node-search"
|
|
|
|
inputClassName="w-full px-4 py-2"
|
|
|
|
autoCorrect="off"
|
|
|
|
autoComplete="off"
|
|
|
|
autoCapitalize="off"
|
|
|
|
placeholder="Search exit nodes…"
|
|
|
|
value={filter}
|
|
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
|
|
/>
|
|
|
|
{/* TODO(sonia): use loading spinner when loading useExitNodes */}
|
|
|
|
<div className="pt-1 border-t border-gray-200 max-h-64 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 last:mb-0"
|
|
|
|
>
|
|
|
|
{group.name && (
|
|
|
|
<div className="px-4 py-2 text-neutral-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>
|
2023-11-07 11:16:23 -05:00
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
2023-11-15 15:23:15 -05:00
|
|
|
|
|
|
|
function ExitNodeSelectorItem({
|
|
|
|
node,
|
|
|
|
isSelected,
|
|
|
|
onSelect,
|
|
|
|
}: {
|
|
|
|
node: ExitNode
|
|
|
|
isSelected: boolean
|
|
|
|
onSelect: () => void
|
|
|
|
}) {
|
|
|
|
return (
|
|
|
|
<button
|
|
|
|
key={node.ID}
|
|
|
|
className="w-full px-4 py-2 flex justify-between items-center cursor-pointer hover:bg-stone-100"
|
|
|
|
onClick={onSelect}
|
|
|
|
>
|
|
|
|
<div>
|
|
|
|
{node.Location && (
|
|
|
|
<>
|
|
|
|
<CountryFlag code={node.Location.CountryCode} />{" "}
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
<span className="leading-snug">{node.Name}</span>
|
|
|
|
</div>
|
|
|
|
{isSelected && <Check />}
|
|
|
|
</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: "🇿🇼",
|
|
|
|
}
|