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