servers.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  1. import { useQuery, useQueryClient } from "@tanstack/react-query"
  2. import { useState, useEffect, useRef } from "react"
  3. import { useRouter } from "next/router"
  4. import { FormattedMessage, FormattedDate, useIntl } from "react-intl"
  5. import classnames from "classnames"
  6. import { orderBy as _orderBy } from "lodash"
  7. import ServerCard from "../components/ServerCard"
  8. import { IconCard } from "../components/IconCard"
  9. import SelectMenu from "../components/SelectMenu"
  10. import Statistic from "../components/Statistic"
  11. import { categoriesMessages } from "../data/categories"
  12. import type { Server, Category, Language, Day, Region } from "../types/api"
  13. import Hero from "../components/Hero"
  14. import { withDefaultStaticProps } from "../utils/defaultStaticProps"
  15. import { formatNumber } from "../utils/numbers"
  16. import { fetchEndpoint } from "../utils/api"
  17. import serverHeroMobile from "../public/illustrations/servers_hero_mobile.png"
  18. import serverHeroDesktop from "../public/illustrations/servers_hero_desktop.png"
  19. import PersonIcon from "../public/ui/person.svg?inline"
  20. import FiltersIcon from "../public/ui/filters.svg?inline"
  21. import SkeletonText from "../components/SkeletonText"
  22. import Head from "next/head"
  23. import Layout from "../components/Layout"
  24. import Link from "next/link"
  25. const DUNBAR = Math.log(800)
  26. const Servers = () => {
  27. const intl = useIntl()
  28. const { locale } = useRouter()
  29. const [filters, setFilters] = useState({
  30. language: locale === "en" ? "en" : "",
  31. category: "general",
  32. region: "",
  33. ownership: "",
  34. registrations: "",
  35. })
  36. const params = new URLSearchParams(filters)
  37. const queryOptions = {
  38. cacheTime: 30 * 60 * 1000, // 30 minutes
  39. }
  40. const allCategories = useQuery<Category[]>(
  41. ["categories", ""],
  42. () => fetchEndpoint("categories"),
  43. { select: (data) => _orderBy(data, "servers_count", "desc") }
  44. )
  45. const apiCategories = useQuery<Category[]>(
  46. ["categories", filters.language],
  47. () => fetchEndpoint("categories", params),
  48. {
  49. ...queryOptions,
  50. keepPreviousData: true,
  51. select: (data) => {
  52. let updated = allCategories.data.map(({ category }) => {
  53. let match = data.find((el) => {
  54. return el.category === category
  55. })
  56. return { category, servers_count: match ? match.servers_count : 0 }
  57. })
  58. const totalServersCount =
  59. updated?.reduce((acc, el) => acc + el.servers_count, 0) ?? 0
  60. updated = [
  61. { category: "", servers_count: totalServersCount },
  62. ...updated,
  63. ]
  64. return updated
  65. },
  66. }
  67. )
  68. let defaultOption = {
  69. value: "",
  70. label: intl.formatMessage({
  71. id: "wizard.filter.all_languages",
  72. defaultMessage: "All languages",
  73. }),
  74. }
  75. const registrationsOptions = [
  76. {
  77. value: "",
  78. label: intl.formatMessage({
  79. id: "wizard.filter.sign_up.all",
  80. defaultMessage: "All",
  81. }),
  82. },
  83. {
  84. value: "instant",
  85. label: intl.formatMessage({
  86. id: "wizard.filter.sign_up.instant",
  87. defaultMessage: "Instant",
  88. }),
  89. },
  90. {
  91. value: "manual",
  92. label: intl.formatMessage({
  93. id: "wizard.filter.sign_up.manual",
  94. defaultMessage: "Manual review",
  95. }),
  96. },
  97. ]
  98. const ownershipOptions = [
  99. {
  100. value: "",
  101. label: intl.formatMessage({
  102. id: "wizard.filter.ownership.all",
  103. defaultMessage: "All",
  104. }),
  105. },
  106. {
  107. value: "juridicial",
  108. label: intl.formatMessage({
  109. id: "wizard.filter.ownership.juridicial",
  110. defaultMessage: "Public organization",
  111. }),
  112. },
  113. {
  114. value: "natural",
  115. label: intl.formatMessage({
  116. id: "wizard.filter.ownership.natural",
  117. defaultMessage: "Private individual",
  118. }),
  119. },
  120. ]
  121. const apiLanguages = useQuery<any[]>(
  122. ["languages", filters.category],
  123. () => fetchEndpoint("languages", params),
  124. {
  125. ...queryOptions,
  126. select: (data) => {
  127. let updated = data
  128. .filter((language) => language.language && language.locale)
  129. .map((language) => ({
  130. label: language.language,
  131. value: language.locale,
  132. }))
  133. updated = [defaultOption, ...updated]
  134. return updated
  135. },
  136. }
  137. )
  138. const servers = useQuery<Server[]>(
  139. [
  140. "servers",
  141. filters.language,
  142. filters.category,
  143. filters.ownership,
  144. filters.registrations,
  145. filters.region,
  146. ],
  147. () => fetchEndpoint("servers", params),
  148. queryOptions
  149. )
  150. const days = useQuery<Day[]>(
  151. ["statistics"],
  152. () => fetchEndpoint("statistics"),
  153. queryOptions
  154. )
  155. const regions = [
  156. {
  157. value: "",
  158. label: intl.formatMessage({
  159. id: "server.regions.all",
  160. defaultMessage: "All regions",
  161. }),
  162. },
  163. {
  164. value: "europe",
  165. label: intl.formatMessage({
  166. id: "server.regions.europe",
  167. defaultMessage: "Europe",
  168. }),
  169. },
  170. {
  171. value: "north_america",
  172. label: intl.formatMessage({
  173. id: "server.regions.north_america",
  174. defaultMessage: "North America",
  175. }),
  176. },
  177. {
  178. value: "south_america",
  179. label: intl.formatMessage({
  180. id: "server.regions.south_america",
  181. defaultMessage: "South America",
  182. }),
  183. },
  184. {
  185. value: "africa",
  186. label: intl.formatMessage({
  187. id: "server.regions.africa",
  188. defaultMessage: "Africa",
  189. }),
  190. },
  191. {
  192. value: "asia",
  193. label: intl.formatMessage({
  194. id: "server.regions.asia",
  195. defaultMessage: "Asia",
  196. }),
  197. },
  198. {
  199. value: "oceania",
  200. label: intl.formatMessage({
  201. id: "server.regions.oceania",
  202. defaultMessage: "Oceania",
  203. }),
  204. },
  205. ]
  206. return (
  207. <Layout>
  208. <Hero mobileImage={serverHeroMobile} desktopImage={serverHeroDesktop}>
  209. <h1 className="h2 mb-5">
  210. <FormattedMessage id="servers" defaultMessage="Servers" />
  211. </h1>
  212. <p className="sh1 mb-14 max-w-[36ch]">
  213. <FormattedMessage
  214. id="servers.hero.body"
  215. defaultMessage="Mastodon is not a single website. To use it, you need to make an account with a provider—we call them <b>servers</b>—that lets you connect with other people across Mastodon."
  216. values={{
  217. b: (text) => <b>{text}</b>,
  218. }}
  219. />
  220. </p>
  221. </Hero>
  222. <div className="grid gap-20 pb-40">
  223. <GettingStartedCards />
  224. <div className="grid grid-cols-4 gap-gutter md:grid-cols-12">
  225. <div className="col-span-full mb-4 flex flex-wrap gap-gutter md:mb-2 md:justify-end">
  226. <SelectMenu
  227. label={
  228. <FormattedMessage
  229. id="wizard.filter_by_language"
  230. defaultMessage="Language"
  231. />
  232. }
  233. onChange={(v) => {
  234. setFilters({ ...filters, language: v })
  235. }}
  236. value={filters.language}
  237. options={apiLanguages.data || [defaultOption]}
  238. />
  239. <SelectMenu
  240. label={
  241. <FormattedMessage
  242. id="wizard.filter_by_registrations"
  243. defaultMessage="Sign-up process"
  244. />
  245. }
  246. onChange={(v) => {
  247. setFilters({ ...filters, registrations: v })
  248. }}
  249. value={filters.registrations}
  250. options={registrationsOptions}
  251. />
  252. <SelectMenu
  253. label={
  254. <FormattedMessage
  255. id="wizard.filter_by_structure"
  256. defaultMessage="Legal structure"
  257. />
  258. }
  259. onChange={(v) => {
  260. setFilters({ ...filters, ownership: v })
  261. }}
  262. value={filters.ownership}
  263. options={ownershipOptions}
  264. />
  265. </div>
  266. <div className="col-span-4 mb-8 md:col-span-3 md:mb-0">
  267. <h3 className="h5 mb-4">
  268. <FormattedMessage id="server.safety" defaultMessage="Safety" />
  269. </h3>
  270. <p className="b2 mb-8 text-gray-1">
  271. <FormattedMessage
  272. id="covenant.learn_more"
  273. defaultMessage="All servers listed here have committed to the <link>Mastodon Server Covenant</link>."
  274. values={{
  275. link: (chunks) => (
  276. <Link href="/covenant" className="underline">
  277. {chunks}
  278. </Link>
  279. ),
  280. }}
  281. />
  282. </p>
  283. <ServerFilters
  284. initialCategories={allCategories.data}
  285. regions={regions}
  286. categories={apiCategories.data}
  287. filters={filters}
  288. setFilters={setFilters}
  289. />
  290. <ServerStats days={days} />
  291. </div>
  292. <div className="col-span-4 md:col-start-4 md:col-end-13">
  293. <ServerList servers={servers} />
  294. </div>
  295. </div>
  296. </div>
  297. <Head>
  298. <title>
  299. {`${intl.formatMessage({
  300. id: "servers.page_title",
  301. defaultMessage: "Servers",
  302. })} - Mastodon`}
  303. </title>
  304. <meta
  305. property="og:title"
  306. content={intl.formatMessage({
  307. id: "servers.page_title",
  308. defaultMessage: "Servers",
  309. })}
  310. />
  311. <meta
  312. name="description"
  313. content={intl.formatMessage({
  314. id: "servers.page_description",
  315. defaultMessage:
  316. "Find where to sign up for the decentralized social network Mastodon.",
  317. })}
  318. />
  319. <meta
  320. property="og:description"
  321. content={intl.formatMessage({
  322. id: "servers.page_description",
  323. defaultMessage:
  324. "Find where to sign up for the decentralized social network Mastodon.",
  325. })}
  326. />
  327. </Head>
  328. </Layout>
  329. )
  330. }
  331. const GettingStartedCards = () => {
  332. const [visited, setVisited] = useState(false)
  333. useEffect(function checkVisited() {
  334. let visits = localStorage.getItem("visited")
  335. // on first visit, set localStorage.visited = true
  336. if (!visits) {
  337. localStorage.setItem("visited", "true")
  338. } else {
  339. setVisited(true) // on subsequent visits
  340. }
  341. }, [])
  342. return (
  343. <section className={classnames("mb-8", visited ? "order-1" : "order-0")}>
  344. <h2 className="h3 mb-8 text-center">
  345. <FormattedMessage
  346. id="servers.getting_started.headline"
  347. defaultMessage="Getting started with Mastodon is easy"
  348. />
  349. </h2>
  350. <div className="grid gap-gutter sm:grid-cols-2 xl:grid-cols-4">
  351. <IconCard
  352. title={<FormattedMessage id="servers" defaultMessage="Servers" />}
  353. icon="servers"
  354. className="md:border md:border-gray-3"
  355. copy={
  356. <FormattedMessage
  357. id="servers.getting_started.servers"
  358. defaultMessage="The first step is deciding which server you’d like to make your account on. Every server is operated by an independent organization or individual and may differ in moderation policies."
  359. />
  360. }
  361. />
  362. <IconCard
  363. title={
  364. <FormattedMessage
  365. id="servers.getting_started.feed.title"
  366. defaultMessage="Your feed"
  367. />
  368. }
  369. icon="feed"
  370. className="md:border md:border-gray-3"
  371. copy={
  372. <FormattedMessage
  373. id="servers.getting_started.feed.body"
  374. defaultMessage="With an account on your server, you can follow any other person on the network, regardless of where their account is hosted. You will see their posts in your home feed, and if they follow you, they will see yours in theirs."
  375. />
  376. }
  377. />
  378. <IconCard
  379. title={
  380. <FormattedMessage
  381. id="servers.getting_started.flexible.title"
  382. defaultMessage="Flexible"
  383. />
  384. }
  385. icon="move-servers"
  386. className="md:border md:border-gray-3"
  387. copy={
  388. <FormattedMessage
  389. id="servers.getting_started.flexible.body"
  390. defaultMessage="Find a different server you'd prefer? With Mastodon, you can easily move your profile to a different server at any time without losing any followers. To be in complete control, you can create your own server."
  391. />
  392. }
  393. />
  394. <IconCard
  395. title={
  396. <FormattedMessage
  397. id="servers.getting_started.safe_for_all.title"
  398. defaultMessage="Safe for all"
  399. />
  400. }
  401. icon="safety-1"
  402. className="md:border md:border-gray-3"
  403. copy={
  404. <FormattedMessage
  405. id="servers.getting_started.safe_for_all.body"
  406. defaultMessage="We can't control the servers, but we can control what we promote on this page. Our organization will only point you to servers that are consistently committed to moderation against racism, sexism, and transphobia."
  407. />
  408. }
  409. />
  410. </div>
  411. </section>
  412. )
  413. }
  414. const ServerList = ({ servers }) => {
  415. if (servers.isError) {
  416. return (
  417. <p>
  418. <FormattedMessage
  419. id="wizard.error"
  420. defaultMessage="Oops, something went wrong. Try refreshing the page."
  421. />
  422. </p>
  423. )
  424. }
  425. return (
  426. <div className="col-span-4 md:col-start-4 md:col-end-13">
  427. {servers.data?.length === 0 ? (
  428. <div className="b2 flex justify-center rounded bg-gray-5 p-4 text-gray-1 md:p-8 md:py-20">
  429. <p className="max-w-[48ch] text-center">
  430. <FormattedMessage
  431. id="wizard.no_results"
  432. defaultMessage="Seems like there are currently no servers that fit your search criteria. Mind that we only display a curated set of servers that currently accept new sign-ups."
  433. />
  434. </p>
  435. </div>
  436. ) : (
  437. <div className="grid gap-gutter sm:grid-cols-2 xl:grid-cols-3">
  438. {servers.isLoading
  439. ? Array(8)
  440. .fill(null)
  441. .map((_el, i) => <ServerCard key={i} />)
  442. : servers.data
  443. .sort((a, b) => {
  444. if (a.approval_required === b.approval_required) {
  445. return b.last_week_users - a.last_week_users
  446. } else if (a.approval_required) {
  447. return 1
  448. } else if (b.approval_required) {
  449. return -1
  450. } else {
  451. return b.last_week_users - a.last_week_users
  452. }
  453. })
  454. .map((server) => (
  455. <ServerCard key={server.domain} server={server} />
  456. ))}
  457. </div>
  458. )}
  459. </div>
  460. )
  461. }
  462. const ServerStats = ({ days }) => {
  463. const intl = useIntl()
  464. if (days.isError) {
  465. return null
  466. }
  467. if (days.isLoading) {
  468. return (
  469. <div>
  470. <h3 className="h5 mb-4">
  471. <FormattedMessage
  472. id="stats.network"
  473. defaultMessage="Network health"
  474. />
  475. </h3>
  476. <div className="space-y-4">
  477. <Statistic key="mau" />
  478. <Statistic key="servers" />
  479. </div>
  480. <p className="b3 mt-4 text-gray-2">
  481. <SkeletonText className="w-[20ch]" />
  482. <br />
  483. <SkeletonText className="w-[16ch]" />
  484. </p>
  485. </div>
  486. )
  487. }
  488. if (days.data.length < 3) {
  489. return null
  490. }
  491. const currentDay = days.data[days.data.length - 2]
  492. const compareDay = days.data[0]
  493. return (
  494. <div>
  495. <h3 className="h5 mb-4">
  496. <FormattedMessage id="stats.network" defaultMessage="Network health" />
  497. </h3>
  498. <div className="space-y-4">
  499. <Statistic
  500. key="mau"
  501. Icon={PersonIcon}
  502. label={
  503. <FormattedMessage
  504. id="stats.monthly_active_users"
  505. defaultMessage="Monthly Active Users"
  506. />
  507. }
  508. currentValue={parseInt(currentDay.active_user_count)}
  509. prevValue={parseInt(compareDay.active_user_count)}
  510. />
  511. <Statistic
  512. key="servers"
  513. Icon={FiltersIcon}
  514. label={
  515. <FormattedMessage id="stats.servers" defaultMessage="Servers Up" />
  516. }
  517. currentValue={parseInt(currentDay.server_count)}
  518. prevValue={parseInt(compareDay.server_count)}
  519. />
  520. </div>
  521. <p className="b3 mt-4 text-gray-2">
  522. <FormattedMessage
  523. id="stats.disclaimer"
  524. defaultMessage="Data collected by crawling all accessible Mastodon servers on {date}."
  525. values={{
  526. date: (
  527. <FormattedDate
  528. value={currentDay.period}
  529. year="numeric"
  530. month="short"
  531. day="2-digit"
  532. />
  533. ),
  534. }}
  535. />
  536. </p>
  537. </div>
  538. )
  539. }
  540. const ServerFilters = ({
  541. filters,
  542. setFilters,
  543. categories,
  544. initialCategories,
  545. regions,
  546. }: {
  547. filters: any
  548. setFilters: any
  549. categories: Category[]
  550. initialCategories: Category[]
  551. regions: Region[]
  552. }) => {
  553. const intl = useIntl()
  554. return (
  555. <div className="mb-8">
  556. <h3 className="h5 mb-4" id="category-group-label">
  557. <FormattedMessage
  558. id="server.filter_by.region"
  559. defaultMessage="Region"
  560. />
  561. </h3>
  562. <p className="b3 mb-4 text-gray-2">
  563. <FormattedMessage
  564. id="server.filter_by.region.lead"
  565. defaultMessage="Where the provider is legally based."
  566. />
  567. </p>
  568. <ul className="mb-8 grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-1 md:-ml-3 md:grid-cols-1 md:gap-x-3">
  569. {regions?.map((item, i) => {
  570. const isActive = filters.region === item.value
  571. return (
  572. <li key={i}>
  573. <label
  574. className={classnames(
  575. "b2 flex cursor-pointer gap-1 rounded p-3 focus-visible-within:outline focus-visible-within:outline-2 focus-visible-within:outline-blurple-500",
  576. isActive && "bg-nightshade-50 !font-extrabold"
  577. )}
  578. >
  579. <input
  580. className="sr-only"
  581. type="checkbox"
  582. name="filters-region"
  583. onChange={() => {
  584. setFilters({
  585. ...filters,
  586. region: isActive ? "" : item.value,
  587. })
  588. }}
  589. />
  590. {item.label}
  591. </label>
  592. </li>
  593. )
  594. })}
  595. </ul>
  596. <h3 className="h5 mb-4" id="category-group-label">
  597. <FormattedMessage
  598. id="server.filter_by.category"
  599. defaultMessage="Topic"
  600. />
  601. </h3>
  602. <p className="b3 mb-4 text-gray-2">
  603. <FormattedMessage
  604. id="server.filter_by.category.lead"
  605. defaultMessage="Some providers specialize in hosting accounts from specific communities."
  606. />
  607. </p>
  608. <ul className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-1 md:-ml-3 md:grid-cols-1 md:gap-x-3">
  609. {!initialCategories
  610. ? new Array(11).fill(null).map((_, i) => (
  611. <li className="h-8 p-3" key={i}>
  612. <SkeletonText className="!h-full" />
  613. </li>
  614. ))
  615. : categories?.map((item, i) => {
  616. const isActive = filters.category === item.category
  617. return (
  618. <li key={i}>
  619. <label
  620. className={classnames(
  621. "b2 flex cursor-pointer gap-1 rounded p-3 focus-visible-within:outline focus-visible-within:outline-2 focus-visible-within:outline-blurple-500",
  622. isActive && "bg-nightshade-50 !font-extrabold",
  623. item.servers_count === 0 && "text-gray-2"
  624. )}
  625. >
  626. <input
  627. className="sr-only"
  628. type="checkbox"
  629. name="filters-category"
  630. onChange={() => {
  631. setFilters({
  632. ...filters,
  633. category: isActive ? "" : item.category,
  634. })
  635. }}
  636. />
  637. {item.category === ""
  638. ? intl.formatMessage({
  639. id: "wizard.filter.all_categories",
  640. defaultMessage: "All topics",
  641. })
  642. : categoriesMessages[item.category]
  643. ? intl.formatMessage(categoriesMessages[item.category])
  644. : item.category}
  645. <span
  646. className={
  647. isActive ? "text-nightshade-100" : "text-gray-2"
  648. }
  649. >
  650. ({item.servers_count})
  651. </span>
  652. </label>
  653. </li>
  654. )
  655. })}
  656. </ul>
  657. </div>
  658. )
  659. }
  660. export const getStaticProps = withDefaultStaticProps()
  661. export default Servers