servers.tsx 21 KB

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