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

534 lines
12 KiB
TypeScript

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames"
import React, { useCallback, useMemo, useRef, useState } from "react"
import { ReactComponent as Check } from "src/assets/icons/check.svg"
import { ReactComponent as ChevronDown } from "src/assets/icons/chevron-down.svg"
import useExitNodes, {
ExitNode,
noExitNode,
runAsExitNode,
trimDNSSuffix,
} from "src/hooks/exit-nodes"
import { NodeData, NodeUpdaters } from "src/hooks/node-data"
import Popover from "src/ui/popover"
import SearchInput from "src/ui/search-input"
export default function ExitNodeSelector({
className,
node,
nodeUpdaters,
disabled,
}: {
className?: string
node: NodeData
nodeUpdaters: NodeUpdaters
disabled?: boolean
}) {
const [open, setOpen] = useState<boolean>(false)
const [selected, setSelected] = useState<ExitNode>(toSelectedExitNode(node))
const handleSelect = useCallback(
(n: ExitNode) => {
setOpen(false)
if (n.ID === selected.ID) {
return // no update
}
const old = selected
setSelected(n) // optimistic UI update
nodeUpdaters.postExitNode(n).catch(() => setSelected(old))
},
[nodeUpdaters, selected]
)
const [
none, // not using exit nodes
advertising, // advertising as exit node
using, // using another exit node
] = useMemo(
() => [
selected.ID === noExitNode.ID,
selected.ID === runAsExitNode.ID,
selected.ID !== noExitNode.ID && selected.ID !== runAsExitNode.ID,
],
[selected]
)
return (
<Popover
open={disabled ? false : open}
onOpenChange={setOpen}
side="bottom"
sideOffset={5}
align="start"
alignOffset={8}
content={
<ExitNodeSelectorInner
node={node}
selected={selected}
onSelect={handleSelect}
/>
}
asChild
>
<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-blue-500 border-blue-500": using,
},
className
)}
>
<button
className={cx("flex-1 px-2 py-1.5 rounded-[1px]", {
"bg-white hover:bg-stone-100": none,
"bg-amber-600 hover:bg-orange-400": advertising,
"bg-blue-500 hover:bg-blue-400": using,
"cursor-not-allowed": disabled,
})}
onClick={() => setOpen(!open)}
disabled={disabled}
>
<p
className={cx(
"text-gray-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-gray-800", {
"text-white": advertising || using,
})}
>
{selected.Location && (
<>
<CountryFlag code={selected.Location.CountryCode} />{" "}
</>
)}
{selected === runAsExitNode
? "Running as exit node"
: selected.Name}
</p>
<ChevronDown
className={cx("ml-1", {
"stroke-neutral-800": none,
"stroke-white": advertising || using,
})}
/>
</div>
</button>
{(advertising || using) && (
<button
className={cx("px-3 py-2 rounded-sm text-white", {
"bg-orange-400": advertising,
"bg-blue-400": using,
"cursor-not-allowed": disabled,
})}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleSelect(noExitNode)
}}
disabled={disabled}
>
Disable
</button>
)}
</div>
</Popover>
)
}
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-[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) => {
// 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-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-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="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: "๐Ÿ‡ฟ๐Ÿ‡ผ",
}