123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716 |
- import {
- useQuery,
- useQueryClient,
- keepPreviousData,
- } from "@tanstack/react-query"
- import { useState, useEffect, useRef } from "react"
- import { useRouter } from "next/router"
- import { FormattedMessage, FormattedDate, useIntl } from "react-intl"
- import classnames from "classnames"
- import { orderBy as _orderBy } from "lodash"
- import ServerCard from "../components/ServerCard"
- import { IconCard } from "../components/IconCard"
- import SelectMenu from "../components/SelectMenu"
- import Statistic from "../components/Statistic"
- import { categoriesMessages } from "../data/categories"
- import type { Server, Category, Language, Day, Region } from "../types/api"
- import Hero from "../components/Hero"
- import { withDefaultStaticProps } from "../utils/defaultStaticProps"
- import { formatNumber } from "../utils/numbers"
- import { fetchEndpoint } from "../utils/api"
- import serverHeroMobile from "../public/illustrations/servers_hero_mobile.png"
- import serverHeroDesktop from "../public/illustrations/servers_hero_desktop.png"
- import PersonIcon from "../public/ui/person.svg?inline"
- import FiltersIcon from "../public/ui/filters.svg?inline"
- import SkeletonText from "../components/SkeletonText"
- import Head from "next/head"
- import Layout from "../components/Layout"
- import Link from "next/link"
- const DUNBAR = Math.log(800)
- const Servers = () => {
- const intl = useIntl()
- const { locale } = useRouter()
- const [filters, setFilters] = useState({
- language: locale === "en" ? "en" : "",
- category: "",
- region: "",
- ownership: "",
- registrations: "",
- })
- const params = new URLSearchParams(filters)
- const queryOptions = {
- gcTime: 30 * 60 * 1000, // 30 minutes
- }
- const allCategories = useQuery({
- queryKey: ["categories", ""],
- queryFn: () => fetchEndpoint("categories"),
- select: (data) => _orderBy(data, "servers_count", "desc"),
- })
- const apiCategories = useQuery({
- queryKey: ["categories", filters.language],
- queryFn: () => fetchEndpoint("categories", params),
- ...queryOptions,
- placeholderData: keepPreviousData,
- select: (data) => {
- let updated = allCategories.data.map(({ category }) => {
- let match = data.find((el) => {
- return el.category === category
- })
- return { category, servers_count: match ? match.servers_count : 0 }
- })
- const totalServersCount =
- updated?.reduce((acc, el) => acc + el.servers_count, 0) ?? 0
- updated = [{ category: "", servers_count: totalServersCount }, ...updated]
- return updated
- },
- })
- let defaultOption = {
- value: "",
- label: intl.formatMessage({
- id: "wizard.filter.all_languages",
- defaultMessage: "All languages",
- }),
- }
- const registrationsOptions = [
- {
- value: "",
- label: intl.formatMessage({
- id: "wizard.filter.sign_up.all",
- defaultMessage: "All",
- }),
- },
- {
- value: "instant",
- label: intl.formatMessage({
- id: "wizard.filter.sign_up.instant",
- defaultMessage: "Instant",
- }),
- },
- {
- value: "manual",
- label: intl.formatMessage({
- id: "wizard.filter.sign_up.manual",
- defaultMessage: "Manual review",
- }),
- },
- ]
- const ownershipOptions = [
- {
- value: "",
- label: intl.formatMessage({
- id: "wizard.filter.ownership.all",
- defaultMessage: "All",
- }),
- },
- {
- value: "juridicial",
- label: intl.formatMessage({
- id: "wizard.filter.ownership.juridicial",
- defaultMessage: "Public organization",
- }),
- },
- {
- value: "natural",
- label: intl.formatMessage({
- id: "wizard.filter.ownership.natural",
- defaultMessage: "Private individual",
- }),
- },
- ]
- const apiLanguages = useQuery({
- queryKey: ["languages", filters.category],
- queryFn: () => fetchEndpoint("languages", params),
- ...queryOptions,
- select: (data) => {
- let updated = data
- .filter((language) => language.language && language.locale)
- .map((language) => ({
- label: language.language,
- value: language.locale,
- }))
- updated = [defaultOption, ...updated]
- return updated
- },
- })
- const servers = useQuery({
- queryKey: [
- "servers",
- filters.language,
- filters.category,
- filters.ownership,
- filters.registrations,
- filters.region,
- ],
- queryFn: () => fetchEndpoint("servers", params),
- ...queryOptions,
- })
- const days = useQuery({
- queryKey: ["statistics"],
- queryFn: () => fetchEndpoint("statistics"),
- ...queryOptions,
- })
- const regions = [
- {
- value: "",
- label: intl.formatMessage({
- id: "server.regions.all",
- defaultMessage: "All regions",
- }),
- },
- {
- value: "europe",
- label: intl.formatMessage({
- id: "server.regions.europe",
- defaultMessage: "Europe",
- }),
- },
- {
- value: "north_america",
- label: intl.formatMessage({
- id: "server.regions.north_america",
- defaultMessage: "North America",
- }),
- },
- {
- value: "south_america",
- label: intl.formatMessage({
- id: "server.regions.south_america",
- defaultMessage: "South America",
- }),
- },
- {
- value: "africa",
- label: intl.formatMessage({
- id: "server.regions.africa",
- defaultMessage: "Africa",
- }),
- },
- {
- value: "asia",
- label: intl.formatMessage({
- id: "server.regions.asia",
- defaultMessage: "Asia",
- }),
- },
- {
- value: "oceania",
- label: intl.formatMessage({
- id: "server.regions.oceania",
- defaultMessage: "Oceania",
- }),
- },
- ]
- return (
- <Layout>
- <Hero mobileImage={serverHeroMobile} desktopImage={serverHeroDesktop}>
- <h1 className="h2 mb-5">
- <FormattedMessage id="servers" defaultMessage="Servers" />
- </h1>
- <p className="sh1 mb-14 max-w-[36ch]">
- <FormattedMessage
- id="servers.hero.body"
- 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."
- values={{
- b: (text) => <b>{text}</b>,
- }}
- />
- </p>
- </Hero>
- <div className="grid gap-20 pb-40">
- <GettingStartedCards />
- <div className="grid grid-cols-4 gap-gutter md:grid-cols-12">
- <div className="col-span-full mb-4 flex flex-wrap gap-gutter md:mb-2 md:justify-end">
- <SelectMenu
- label={
- <FormattedMessage
- id="wizard.filter_by_language"
- defaultMessage="Language"
- />
- }
- onChange={(v) => {
- setFilters({ ...filters, language: v })
- }}
- value={filters.language}
- options={apiLanguages.data || [defaultOption]}
- />
- <SelectMenu
- label={
- <FormattedMessage
- id="wizard.filter_by_registrations"
- defaultMessage="Sign-up process"
- />
- }
- onChange={(v) => {
- setFilters({ ...filters, registrations: v })
- }}
- value={filters.registrations}
- options={registrationsOptions}
- />
- <SelectMenu
- label={
- <FormattedMessage
- id="wizard.filter_by_structure"
- defaultMessage="Legal structure"
- />
- }
- onChange={(v) => {
- setFilters({ ...filters, ownership: v })
- }}
- value={filters.ownership}
- options={ownershipOptions}
- />
- </div>
- <div className="col-span-4 mb-8 md:col-span-3 md:mb-0">
- <h3 className="h5 mb-4">
- <FormattedMessage id="server.safety" defaultMessage="Safety" />
- </h3>
- <p className="b2 mb-8 text-gray-1">
- <FormattedMessage
- id="covenant.learn_more"
- defaultMessage="All servers listed here have committed to the <link>Mastodon Server Covenant</link>."
- values={{
- link: (chunks) => (
- <Link href="/covenant" className="underline">
- {chunks}
- </Link>
- ),
- }}
- />
- </p>
- <ServerFilters
- initialCategories={allCategories.data}
- regions={regions}
- categories={apiCategories.data}
- filters={filters}
- setFilters={setFilters}
- />
- <ServerStats days={days} />
- </div>
- <div className="col-span-4 md:col-start-4 md:col-end-13">
- <ServerList servers={servers} />
- </div>
- </div>
- </div>
- <Head>
- <title>
- {`${intl.formatMessage({
- id: "servers.page_title",
- defaultMessage: "Servers",
- })} - Mastodon`}
- </title>
- <meta
- property="og:title"
- content={intl.formatMessage({
- id: "servers.page_title",
- defaultMessage: "Servers",
- })}
- />
- <meta
- name="description"
- content={intl.formatMessage({
- id: "servers.page_description",
- defaultMessage:
- "Find where to sign up for the decentralized social network Mastodon.",
- })}
- />
- <meta
- property="og:description"
- content={intl.formatMessage({
- id: "servers.page_description",
- defaultMessage:
- "Find where to sign up for the decentralized social network Mastodon.",
- })}
- />
- </Head>
- </Layout>
- )
- }
- const GettingStartedCards = () => {
- const [visited, setVisited] = useState(false)
- useEffect(function checkVisited() {
- let visits = localStorage.getItem("visited")
- // on first visit, set localStorage.visited = true
- if (!visits) {
- localStorage.setItem("visited", "true")
- } else {
- setVisited(true) // on subsequent visits
- }
- }, [])
- return (
- <section className={classnames("mb-8", visited ? "order-1" : "order-0")}>
- <h2 className="h3 mb-8 text-center">
- <FormattedMessage
- id="servers.getting_started.headline"
- defaultMessage="Getting started with Mastodon is easy"
- />
- </h2>
- <div className="grid gap-gutter sm:grid-cols-2 xl:grid-cols-4">
- <IconCard
- title={<FormattedMessage id="servers" defaultMessage="Servers" />}
- icon="servers"
- className="md:border md:border-gray-3"
- copy={
- <FormattedMessage
- id="servers.getting_started.servers"
- 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."
- />
- }
- />
- <IconCard
- title={
- <FormattedMessage
- id="servers.getting_started.feed.title"
- defaultMessage="Your feed"
- />
- }
- icon="feed"
- className="md:border md:border-gray-3"
- copy={
- <FormattedMessage
- id="servers.getting_started.feed.body"
- 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."
- />
- }
- />
- <IconCard
- title={
- <FormattedMessage
- id="servers.getting_started.flexible.title"
- defaultMessage="Flexible"
- />
- }
- icon="move-servers"
- className="md:border md:border-gray-3"
- copy={
- <FormattedMessage
- id="servers.getting_started.flexible.body"
- 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."
- />
- }
- />
- <IconCard
- title={
- <FormattedMessage
- id="servers.getting_started.safe_for_all.title"
- defaultMessage="Safe for all"
- />
- }
- icon="safety-1"
- className="md:border md:border-gray-3"
- copy={
- <FormattedMessage
- id="servers.getting_started.safe_for_all.body"
- 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."
- />
- }
- />
- </div>
- </section>
- )
- }
- const ServerList = ({ servers }) => {
- if (servers.isError) {
- return (
- <p>
- <FormattedMessage
- id="wizard.error"
- defaultMessage="Oops, something went wrong. Try refreshing the page."
- />
- </p>
- )
- }
- return (
- <div className="col-span-4 md:col-start-4 md:col-end-13">
- {servers.data?.length === 0 ? (
- <div className="b2 flex justify-center rounded bg-gray-5 p-4 text-gray-1 md:p-8 md:py-20">
- <p className="max-w-[48ch] text-center">
- <FormattedMessage
- id="wizard.no_results"
- 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."
- />
- </p>
- </div>
- ) : (
- <div className="grid gap-gutter sm:grid-cols-2 xl:grid-cols-3">
- {servers.isLoading
- ? Array(8)
- .fill(null)
- .map((_el, i) => <ServerCard key={i} />)
- : servers.data
- .sort((a, b) => {
- if (a.approval_required === b.approval_required) {
- return b.last_week_users - a.last_week_users
- } else if (a.approval_required) {
- return 1
- } else if (b.approval_required) {
- return -1
- } else {
- return b.last_week_users - a.last_week_users
- }
- })
- .map((server) => (
- <ServerCard key={server.domain} server={server} />
- ))}
- </div>
- )}
- </div>
- )
- }
- const ServerStats = ({ days }) => {
- const intl = useIntl()
- if (days.isError) {
- return null
- }
- if (days.isLoading) {
- return (
- <div>
- <h3 className="h5 mb-4">
- <FormattedMessage
- id="stats.network"
- defaultMessage="Network health"
- />
- </h3>
- <div className="space-y-4">
- <Statistic key="mau" />
- <Statistic key="servers" />
- </div>
- <p className="b3 mt-4 text-gray-2">
- <SkeletonText className="w-[20ch]" />
- <br />
- <SkeletonText className="w-[16ch]" />
- </p>
- </div>
- )
- }
- if (days.data.length < 3) {
- return null
- }
- const currentDay = days.data[days.data.length - 2]
- const compareDay = days.data[0]
- return (
- <div>
- <h3 className="h5 mb-4">
- <FormattedMessage id="stats.network" defaultMessage="Network health" />
- </h3>
- <div className="space-y-4">
- <Statistic
- key="mau"
- Icon={PersonIcon}
- label={
- <FormattedMessage
- id="stats.monthly_active_users"
- defaultMessage="Monthly Active Users"
- />
- }
- currentValue={parseInt(currentDay.active_user_count)}
- prevValue={parseInt(compareDay.active_user_count)}
- />
- <Statistic
- key="servers"
- Icon={FiltersIcon}
- label={
- <FormattedMessage id="stats.servers" defaultMessage="Servers Up" />
- }
- currentValue={parseInt(currentDay.server_count)}
- prevValue={parseInt(compareDay.server_count)}
- />
- </div>
- <p className="b3 mt-4 text-gray-2">
- <FormattedMessage
- id="stats.disclaimer"
- defaultMessage="Data collected by crawling all accessible Mastodon servers on {date}."
- values={{
- date: (
- <FormattedDate
- value={currentDay.period}
- year="numeric"
- month="short"
- day="2-digit"
- />
- ),
- }}
- />
- </p>
- </div>
- )
- }
- const ServerFilters = ({
- filters,
- setFilters,
- categories,
- initialCategories,
- regions,
- }: {
- filters: any
- setFilters: any
- categories: Category[]
- initialCategories: Category[]
- regions: Region[]
- }) => {
- const intl = useIntl()
- return (
- <div className="mb-8">
- <h3 className="h5 mb-4" id="category-group-label">
- <FormattedMessage
- id="server.filter_by.region"
- defaultMessage="Region"
- />
- </h3>
- <p className="b3 mb-4 text-gray-2">
- <FormattedMessage
- id="server.filter_by.region.lead"
- defaultMessage="Where the provider is legally based."
- />
- </p>
- <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">
- {regions?.map((item, i) => {
- const isActive = filters.region === item.value
- return (
- <li key={i}>
- <label
- className={classnames(
- "b2 flex cursor-pointer gap-1 rounded p-3 focus-visible-within:outline focus-visible-within:outline-2 focus-visible-within:outline-blurple-500",
- isActive && "bg-nightshade-50 !font-extrabold"
- )}
- >
- <input
- className="sr-only"
- type="checkbox"
- name="filters-region"
- onChange={() => {
- setFilters({
- ...filters,
- region: isActive ? "" : item.value,
- })
- }}
- />
- {item.label}
- </label>
- </li>
- )
- })}
- </ul>
- <h3 className="h5 mb-4" id="category-group-label">
- <FormattedMessage
- id="server.filter_by.category"
- defaultMessage="Topic"
- />
- </h3>
- <p className="b3 mb-4 text-gray-2">
- <FormattedMessage
- id="server.filter_by.category.lead"
- defaultMessage="Some providers specialize in hosting accounts from specific communities."
- />
- </p>
- <ul className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-1 md:-ml-3 md:grid-cols-1 md:gap-x-3">
- {!initialCategories
- ? new Array(11).fill(null).map((_, i) => (
- <li className="h-8 p-3" key={i}>
- <SkeletonText className="!h-full" />
- </li>
- ))
- : categories?.map((item, i) => {
- const isActive = filters.category === item.category
- return (
- <li key={i}>
- <label
- className={classnames(
- "b2 flex cursor-pointer gap-1 rounded p-3 focus-visible-within:outline focus-visible-within:outline-2 focus-visible-within:outline-blurple-500",
- isActive && "bg-nightshade-50 !font-extrabold",
- item.servers_count === 0 && "text-gray-2"
- )}
- >
- <input
- className="sr-only"
- type="checkbox"
- name="filters-category"
- onChange={() => {
- setFilters({
- ...filters,
- category: isActive ? "" : item.category,
- })
- }}
- />
- {item.category === ""
- ? intl.formatMessage({
- id: "wizard.filter.all_categories",
- defaultMessage: "All topics",
- })
- : categoriesMessages[item.category]
- ? intl.formatMessage(categoriesMessages[item.category])
- : item.category}
- <span
- className={
- isActive ? "text-nightshade-100" : "text-gray-2"
- }
- >
- ({item.servers_count})
- </span>
- </label>
- </li>
- )
- })}
- </ul>
- </div>
- )
- }
- export const getStaticProps = withDefaultStaticProps()
- export default Servers
|