[feature] Parse instance descriptors as markdown, show T&C on /about (#2481)

* [feature] Parse instance descriptors as markdown, show T&C on /about

* lint

* remove unnecessary nullzero tags
This commit is contained in:
tobi 2024-01-05 13:39:31 +01:00 committed by GitHub
commit d5e3996a18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 885 additions and 515 deletions

View file

@ -27,12 +27,8 @@
border: $boxshadow-border;
border-radius: $br;
.about-section {
ul, ol {
margin-top: 0;
}
h3, h4 {
.about-section {
h1, h2, h3, h4, h5 {
margin-top: 0;
}
}

View file

@ -16,7 +16,12 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/***************************************
***** SECTION 0: IMPORTS AND FONTS *****
****************************************/
@import "modern-normalize/modern-normalize.css";
@import "./prism.css";
/* noto-sans-regular - latin */
@font-face {
@ -261,6 +266,77 @@ label {
cursor: pointer;
}
/*
Set our own nice background for
monospace code and pre blocks.
*/
pre, pre[class*="language-"],
code, code[class*="language-"] {
background-color: $gray2;
}
/*
Just code on its own inside status
content, ie, `here is some code`.
*/
code {
padding: 0.25rem;
border-radius: $br-inner;
white-space: pre-wrap;
}
/*
Restyle Prism code highlighting toolbar
plugin buttons to our own button style.
We have to use really specific selectors
because of how specific prism.css is.
*/
div.code-toolbar > div.toolbar {
margin-right: 0.5rem;
display: flex;
gap: 0.25rem;
> div.toolbar-item {
> span, > button {
color: $button-fg;
background: $button-bg;
font-weight: bold;
box-shadow: $boxshadow;
&:hover, &:focus {
color: $button-fg;
}
}
.copy-to-clipboard-button:hover {
background: $button-hover-bg;
}
}
}
pre, pre[class*="language-"] {
border-radius: $br;
padding: 0.5rem;
white-space: pre;
overflow-x: auto;
/*
Code inside a pre block, ie.,
```
here is some code
```
*/
code {
width: 100%;
padding: 0;
white-space: pre;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
/*************************************
***** SECTION 3: UTILITY CLASSES *****
**************************************/

View file

@ -19,7 +19,6 @@
@import "photoswipe/dist/photoswipe.css";
@import "photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css";
@import "plyr/dist/plyr.css";
@import "./prism.css";
main {
background: transparent;
@ -194,68 +193,6 @@ main {
line-height: initial;
}
pre, code {
background-color: $gray2;
}
/*
Just code on its own inside status
content, ie, `here is some code`.
*/
code {
padding: 0.25rem;
border-radius: $br-inner;
white-space: pre-wrap;
}
/*
Restyle Prism code highlighting toolbar
plugin buttons to our own button style.
*/
.code-toolbar .toolbar {
margin-right: 0.5rem;
display: flex;
gap: 0.25rem;
.toolbar-item {
span, button {
color: $button-fg;
background: $button-bg;
font-weight: bold;
}
.copy-to-clipboard-button, span {
box-shadow: $boxshadow;
}
.copy-to-clipboard-button:hover, .copy-to-clipboard-button:hover span {
background: $button-hover-bg;
}
}
}
pre, pre[class*="language-"] {
border-radius: $br;
padding: 0.5rem;
white-space: pre;
overflow-x: auto;
/*
Code inside a pre block, ie.,
```
here is some code
```
*/
code {
width: 100%;
padding: 0;
white-space: pre;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
img {
max-width: 100%;
margin: 5px auto;

View file

@ -1,126 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
const {
useTextInput,
useFileInput
} = require("../../lib/form");
const useFormSubmit = require("../../lib/form/submit").default;
const {
TextInput,
TextArea,
FileInput
} = require("../../components/form/inputs");
const FormWithData = require("../../lib/form/form-with-data").default;
const MutationButton = require("../../components/form/mutation-button");
const { useInstanceV1Query } = require("../../lib/query");
const { useUpdateInstanceMutation } = require("../../lib/query/admin");
module.exports = function AdminSettings() {
return (
<FormWithData
dataQuery={useInstanceV1Query}
DataForm={AdminSettingsForm}
/>
);
};
function AdminSettingsForm({ data: instance }) {
const form = {
title: useTextInput("title", {
source: instance,
validator: (val) => val.length <= 40 ? "" : "Instance title must be 40 characters or less"
}),
thumbnail: useFileInput("thumbnail", { withPreview: true }),
thumbnailDesc: useTextInput("thumbnail_description", { source: instance }),
shortDesc: useTextInput("short_description", { source: instance }),
description: useTextInput("description", { source: instance }),
contactUser: useTextInput("contact_username", { source: instance, valueSelector: (s) => s.contact_account?.username }),
contactEmail: useTextInput("contact_email", { source: instance, valueSelector: (s) => s.email }),
terms: useTextInput("terms", { source: instance })
};
const [submitForm, result] = useFormSubmit(form, useUpdateInstanceMutation());
return (
<form onSubmit={submitForm}>
<h1>Instance Settings</h1>
<TextInput
field={form.title}
label="Title"
placeholder="My GoToSocial instance"
/>
<div className="file-upload">
<h3>Instance thumbnail</h3>
<div>
<img className="preview avatar" src={form.thumbnail.previewValue ?? instance.thumbnail} alt={form.thumbnailDesc.value ?? (instance.thumbnail ? `Thumbnail image for the instance` : "No instance thumbnail image set")} />
<FileInput
field={form.thumbnail}
accept="image/*"
/>
</div>
</div>
<TextInput
field={form.thumbnailDesc}
label="Instance thumbnail description"
placeholder="A cute drawing of a smiling sloth."
/>
<TextArea
field={form.shortDesc}
label="Short description"
placeholder="A small testing instance for the GoToSocial alpha software."
/>
<TextArea
field={form.description}
label="Full description"
placeholder="A small testing instance for the GoToSocial alpha software. Just trying it out, my main instance is https://example.com"
/>
<TextInput
field={form.contactUser}
label="Contact user (local account username)"
placeholder="admin"
/>
<TextInput
field={form.contactEmail}
label="Contact email"
placeholder="admin@example.com"
/>
<TextArea
field={form.terms}
label="Terms & Conditions"
placeholder=""
/>
<MutationButton label="Save" result={result} />
</form>
);
}

View file

@ -0,0 +1,191 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { useTextInput, useFileInput } from "../../lib/form";
const useFormSubmit = require("../../lib/form/submit").default;
import { TextInput, TextArea, FileInput } from "../../components/form/inputs";
const FormWithData = require("../../lib/form/form-with-data").default;
import MutationButton from "../../components/form/mutation-button";
import { useInstanceV1Query } from "../../lib/query";
import { useUpdateInstanceMutation } from "../../lib/query/admin";
import { InstanceV1 } from "../../lib/types/instance";
export default function AdminSettings() {
return (
<FormWithData
dataQuery={useInstanceV1Query}
DataForm={AdminSettingsForm}
/>
);
}
interface AdminSettingsFormProps{
data: InstanceV1;
}
function AdminSettingsForm({ data: instance }: AdminSettingsFormProps) {
const titleLimit = 40;
const shortDescLimit = 500;
const descLimit = 5000;
const termsLimit = 5000;
const form = {
title: useTextInput("title", {
source: instance,
validator: (val: string) => val.length <= titleLimit ? "" : `Instance title is ${val.length} characters; must be ${titleLimit} characters or less`
}),
thumbnail: useFileInput("thumbnail", { withPreview: true }),
thumbnailDesc: useTextInput("thumbnail_description", { source: instance }),
shortDesc: useTextInput("short_description", {
source: instance,
// Select "raw" text version of parsed field for editing.
valueSelector: (s: InstanceV1) => s.short_description_text,
validator: (val: string) => val.length <= shortDescLimit ? "" : `Instance short description is ${val.length} characters; must be ${shortDescLimit} characters or less`
}),
description: useTextInput("description", {
source: instance,
// Select "raw" text version of parsed field for editing.
valueSelector: (s: InstanceV1) => s.description_text,
validator: (val: string) => val.length <= descLimit ? "" : `Instance description is ${val.length} characters; must be ${descLimit} characters or less`
}),
terms: useTextInput("terms", {
source: instance,
// Select "raw" text version of parsed field for editing.
valueSelector: (s: InstanceV1) => s.terms_text,
validator: (val: string) => val.length <= termsLimit ? "" : `Instance terms and conditions is ${val.length} characters; must be ${termsLimit} characters or less`
}),
contactUser: useTextInput("contact_username", { source: instance, valueSelector: (s) => s.contact_account?.username }),
contactEmail: useTextInput("contact_email", { source: instance, valueSelector: (s) => s.email })
};
const [submitForm, result] = useFormSubmit(form, useUpdateInstanceMutation());
return (
<form onSubmit={submitForm}>
<h1>Instance Settings</h1>
<div className="form-section-docs">
<h3>Appearance</h3>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-appearance"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<TextInput
field={form.title}
label={`Instance title (max ${titleLimit} characters)`}
placeholder="My GoToSocial instance"
/>
<div className="file-upload" aria-labelledby="avatar">
<strong id="avatar">Instance avatar</strong>
<div>
<img
className="preview avatar"
src={form.thumbnail.previewValue ?? instance?.thumbnail}
alt={form.thumbnailDesc.value ?? (instance?.thumbnail ? `Thumbnail image for the instance` : "No instance thumbnail image set")}
/>
<div>
<FileInput
field={form.thumbnail}
accept="image/png, image/jpeg, image/webp, image/gif"
/>
<br/>
<TextInput
field={form.thumbnailDesc}
label="Avatar image description"
placeholder="A cute drawing of a smiling sloth."
/>
</div>
</div>
</div>
<div className="form-section-docs">
<h3>Descriptors</h3>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-descriptors"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<TextArea
field={form.shortDesc}
label={`Short description (markdown accepted, max ${shortDescLimit} characters)`}
placeholder="A small testing instance for the GoToSocial alpha software."
rows={6}
/>
<TextArea
field={form.description}
label={`Full description (markdown accepted, max ${descLimit} characters)`}
placeholder="A small testing instance for the GoToSocial alpha software. Just trying it out, my main instance is https://example.com"
rows={6}
/>
<TextArea
field={form.terms}
label={`Terms & Conditions (markdown accepted, max ${termsLimit} characters)`}
placeholder="Terms and conditions of using this instance, data policy, imprint, GDPR stuff, yadda yadda."
rows={6}
/>
<div className="form-section-docs">
<h3>Contact info</h3>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#instance-contact-info"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<TextInput
field={form.contactUser}
label="Contact user (local account username)"
placeholder="admin"
/>
<TextInput
field={form.contactEmail}
label="Contact email"
placeholder="admin@example.com"
/>
<MutationButton label="Save" result={result} disabled={false} />
</form>
);
}

View file

@ -33,6 +33,8 @@ const { RoleContext } = require("./lib/navigation/util");
const DomainPerms = require("./admin/domain-permissions").default;
const DomainPermsImportExport = require("./admin/domain-permissions/import-export").default;
const InstanceSettings = require("./admin/settings").default;
require("./style.css");
const { Sidebar, ViewRouter } = createNavigation("/settings", [
@ -66,7 +68,7 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [
Item("Remote", { icon: "fa-cloud" }, require("./admin/emoji/remote"))
]),
Menu("Settings", { icon: "fa-sliders" }, [
Item("Settings", { icon: "fa-sliders", url: "" }, require("./admin/settings")),
Item("Settings", { icon: "fa-sliders", url: "" }, InstanceSettings),
Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, require("./admin/settings/rules"))
]),
])

View file

@ -18,24 +18,28 @@
*/
export interface InstanceV1 {
uri: string;
account_domain: string;
title: string;
description: string;
short_description: string;
email: string;
version: string;
languages: any[]; // TODO: define this
registrations: boolean;
approval_required: boolean;
invites_enabled: boolean;
configuration: InstanceConfiguration;
urls: InstanceUrls;
stats: InstanceStats;
thumbnail: string;
contact_account: Object; // TODO: define this.
max_toot_chars: number;
rules: any[]; // TODO: define this
uri: string;
account_domain: string;
title: string;
description: string;
description_text?: string;
short_description: string;
short_description_text?: string;
email: string;
version: string;
languages: any[]; // TODO: define this
registrations: boolean;
approval_required: boolean;
invites_enabled: boolean;
configuration: InstanceConfiguration;
urls: InstanceUrls;
stats: InstanceStats;
thumbnail: string;
contact_account: Object; // TODO: define this.
max_toot_chars: number;
rules: any[]; // TODO: define this
terms?: string;
terms_text?: string;
}
export interface InstanceConfiguration {

View file

@ -21,7 +21,15 @@
{{- if .instance.Description }}
{{ .instance.Description | noescape }}
{{- else }}
<p>No description has yet been set for this instance.<p>
<p>No description has yet been set for this instance.</p>
{{- end }}
{{- end -}}
{{- define "termsAndConditions" -}}
{{- if .instance.Terms }}
{{ .instance.Terms | noescape }}
{{- else }}
<p>No terms and conditions have yet been set for this instance.</p>
{{- end }}
{{- end -}}
@ -60,90 +68,124 @@ Polls can have up to&nbsp;
{{- with . }}
<main class="about">
<nav class="about-section" aria-labelledby="toc">
<h3 id="toc">Table of Contents</h3>
<div class="about-section-contents">
<ol>
<li><a href="#about">About {{ .instance.Title -}}</a></li>
<li><a href="#contact">Contact</a></li>
<li><a href="#features">Features</a></li>
<li><a href="#languages">Languages</a></li>
<li><a href="#rules">Rules</a></li>
<li><a href="#terms">Terms and Conditions</a></li>
<li><a href="#moderated-servers">Moderated Servers</a></li>
</ol>
</div>
</nav>
<section class="about-section" role="region" aria-labelledby="about">
<h3 id="about">About {{ .instance.Title -}}</h3>
{{- with . }}
{{- include "description" . | indent 2 }}
{{- end }}
<div class="about-section-contents">
{{- with . }}
{{- include "description" . | indent 3 }}
{{- end }}
</div>
</section>
<section class="about-section" role="region" aria-labelledby="contact">
<h3 id="contact">Admin Contact</h3>
{{- if .instance.ContactAccount }}
<a href="{{- .instance.ContactAccount.URL -}}" class="account-card">
<img class="avatar" src="{{- .instance.ContactAccount.Avatar -}}" alt=""/>
<h3>
{{- if .instance.ContactAccount.DisplayName -}}
{{- emojify .instance.ContactAccount.Emojis (escape .instance.ContactAccount.DisplayName) -}}
{{- else -}}
{{- .instance.ContactAccount.Username -}}
{{- end -}}
</h3>
<span>@{{- .instance.ContactAccount.Username -}}</span>
</a>
{{- else }}
<p>This instance has not yet set a contact account.</p>
{{- end }}
{{- if .instance.Email }}
<p>Email: <a href="mailto:{{- .instance.Email -}}">{{- .instance.Email -}}</a></p>
{{- else }}
<p>This instance has not yet set a contact email address.</p>
{{- end }}
</section>
<section class="about-section" role="region" aria-labelledby="languages">
<h3 id="languages">Languages</h3>
{{- if .languages }}
<p>This instance prefers the following languages:</p>
<ol>
{{- range .languages }}
<li>{{- . -}}</li>
<div class="about-section-contents">
{{- if .instance.ContactAccount }}
<a href="{{- .instance.ContactAccount.URL -}}" class="account-card">
<img class="avatar" src="{{- .instance.ContactAccount.Avatar -}}" alt=""/>
<h3>
{{- if .instance.ContactAccount.DisplayName -}}
{{- emojify .instance.ContactAccount.Emojis (escape .instance.ContactAccount.DisplayName) -}}
{{- else -}}
{{- .instance.ContactAccount.Username -}}
{{- end -}}
</h3>
<span>@{{- .instance.ContactAccount.Username -}}</span>
</a>
{{- else }}
<p>This instance has not yet set a contact account.</p>
{{- end }}
</ol>
{{- else }}
<p>This instance does not have any preferred languages.</p>
{{- end }}
</section>
<section class="about-section" role="region" aria-labelledby="rules">
<h3 id="rules">Instance Rules</h3>
<p>This instance has the following rules:</p>
{{- if .instance.Rules }}
<ol>
{{- range .instance.Rules }}
<li>{{- .Text -}}</li>
{{- if .instance.Email }}
<p>Email: <a href="mailto:{{- .instance.Email -}}">{{- .instance.Email -}}</a></p>
{{- else }}
<p>This instance has not yet set a contact email address.</p>
{{- end }}
</ol>
{{- else }}
<p>This instance has not yet set any rules.</p>
{{- end }}
</div>
</section>
<section class="about-section" role="region" aria-labelledby="features">
<h3 id="features">Instance Features</h3>
<ul>
<li>{{- template "registrationLimits" . -}}</li>
<li>{{- template "customCSSLimits" . -}}</li>
<li>{{- template "statusLimits" . -}}</li>
<li>{{- template "pollLimits" . -}}</li>
</ul>
<div class="about-section-contents">
<ul>
<li>{{- template "registrationLimits" . -}}</li>
<li>{{- template "customCSSLimits" . -}}</li>
<li>{{- template "statusLimits" . -}}</li>
<li>{{- template "pollLimits" . -}}</li>
</ul>
</div>
</section>
<section class="about-section" role="region" aria-labelledby="languages">
<h3 id="languages">Languages</h3>
<div class="about-section-contents">
{{- if .languages }}
<p>This instance prefers the following languages:</p>
<ol>
{{- range .languages }}
<li>{{- . -}}</li>
{{- end }}
</ol>
{{- else }}
<p>This instance does not have any preferred languages.</p>
{{- end }}
</div>
</section>
<section class="about-section" role="region" aria-labelledby="rules">
<h3 id="rules">Instance Rules</h3>
<div class="about-section-contents">
<p>This instance has the following rules:</p>
{{- if .instance.Rules }}
<ol>
{{- range .instance.Rules }}
<li>{{- .Text -}}</li>
{{- end }}
</ol>
{{- else }}
<p>This instance has not yet set any rules.</p>
{{- end }}
</div>
</section>
<section class="about-section" role="region" aria-labelledby="terms">
<h3 id="terms">Terms and Conditions</h3>
<div class="about-section-contents">
{{- with . }}
{{- include "termsAndConditions" . | indent 3 }}
{{- end }}
</div>
</section>
<section class="about-section" role="region" aria-labelledby="moderated-servers">
<h3 id="moderated-servers">Moderated servers</h3>
<p>
ActivityPub instances federate with other instances by exchanging data with them over the network.
Exchanged data includes things like accounts, statuses, likes, boosts, and media attachments.
This exchange of data can prevented for instances on specific domains via a domain block created
by an instance admin. When an instance is domain blocked by another instance:
</p>
<ul>
<li>Any existing data from the blocked instance is deleted from the storage of the instance doing the blocking.</li>
<li>Interaction between the two instances is cut off in both directions; neither instance can interact with the other.</li>
<li>No new data from the blocked instance will be created on the instance that blocks it.</li>
</ul>
<p>
{{- if .blocklistExposed }}
<a href="/about/suspended">View the list of domains blocked by this instance</a>
{{- else }}
This instance does not publically share their list of blocked domains.
{{- end }}
</p>
<div class="about-section-contents">
<p>
ActivityPub instances federate with other instances by exchanging data with them over the network.
Exchanged data includes things like accounts, statuses, likes, boosts, and media attachments.
This exchange of data can prevented for instances on specific domains via a domain block created
by an instance admin. When an instance is domain blocked by another instance:
</p>
<ul>
<li>Any existing data from the blocked instance is deleted from the storage of the instance doing the blocking.</li>
<li>Interaction between the two instances is cut off in both directions; neither instance can interact with the other.</li>
<li>No new data from the blocked instance will be created on the instance that blocks it.</li>
</ul>
<p>
{{- if .blocklistExposed }}
<a href="/about/suspended">View the list of domains blocked by this instance</a>
{{- else }}
This instance does not publically share their list of blocked domains.
{{- end }}
</p>
</div>
</section>
</main>
{{- end }}

View file

@ -21,7 +21,7 @@
{{- if .instance.ShortDescription }}
{{ .instance.ShortDescription | noescape }}
{{- else }}
<p>No short description has yet been set for this instance.<p>
<p>No short description has yet been set for this instance.</p>
{{- end }}
{{- end -}}
@ -29,8 +29,10 @@
<main class="about">
<section class="about-section" role="region" aria-labelledby="about">
<h3 id="about">About this instance</h3>
{{- include "shortDescription" . | indent 2 }}
<a href="/about">See more details</a>
<div class="about-section-contents">
{{- include "shortDescription" . | indent 3 }}
<a href="/about">See more details</a>
</div>
</section>
{{- include "index_apps.tmpl" . | indent 1 }}
</main>

View file

@ -20,96 +20,98 @@
{{- with . }}
<section role="region" class="about-section apps" aria-labelledby="apps">
<h3 id="apps">Client applications</h3>
<p>
GoToSocial does not provide its own webclient, but implements the Mastodon client API.
You can use this server through a variety of other clients:
</p>
<ul class="applist nodot" role="group">
<li class="applist-entry">
<div class="applist-text">
<p><strong>Semaphore</strong> is a web client designed for speed and simplicity.</p>
<a
href="https://semaphore.social/"
rel="nofollow noreferrer noopener"
target="_blank"
<div class="about-section-contents">
<p>
GoToSocial does not provide its own webclient, but implements the Mastodon client API.
You can use this server through a variety of other clients:
</p>
<ul class="applist nodot" role="group">
<li class="applist-entry">
<div class="applist-text">
<p><strong>Semaphore</strong> is a web client designed for speed and simplicity.</p>
<a
href="https://semaphore.social/"
rel="nofollow noreferrer noopener"
target="_blank"
>
Use Semaphore
</a>
</div>
<svg
role="img"
aria-labelledby="semaphore-title semaphore-desc"
class="applist-logo redraw"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 146 120"
width="100"
height="100"
>
Use Semaphore
</a>
</div>
<svg
role="img"
aria-labelledby="semaphore-title semaphore-desc"
class="applist-logo redraw"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 146 120"
width="100"
height="100"
>
<title id="semaphore-title">The Semaphore logo</title>
<desc id="semaphore-desc">A waving flag</desc>
<path d="M68.13 0C53.94 0 42.81 20 13.9 27.1l-2.23-5.29a6.5 6.5 0 0 0-5.17-10.4 6.5 6.5 0 0 0-.81 12.95L46.2 120l5.99-2.5-14.42-33.33c22.8-6.86 32.51-22.16 49.83-20.58 9.9.9 4.87 19.56 8.11 17.93 16.22-8.15 32.44-11.41 50.29-11.41-7.96-9.78-17.38-20.55-22.71-31.74L120.8 32c-2.32-7.33-2.56-14.75.87-22.22-9.74-3.26-21.1 0-32.45 4.9C82.2 9.77 79.5 0 68.13 0zM15.26 30.42c8.95 6.63 13.63 13.86 16.07 20.94l1.62 6.32c1.24 6.58 1.07 12.8 1.27 18.03z"></path>
</svg>
</li>
<li class="applist-entry">
<div class="applist-text">
<p><strong>Tusky</strong> is a lightweight mobile client for Android.</p>
<a
href="https://tusky.app"
rel="nofollow noreferrer noopener"
target="_blank"
>
Get Tusky
</a>
</div>
<img
class="applist-logo"
src="/assets/tusky.svg"
alt="The Tusky mascot, a cartoon elephant tooting happily"
title="The Tusky mascot, a cartoon elephant tooting happily"
width="100"
height="100"
/>
</li>
<li class="applist-entry">
<div class="applist-text">
<p><strong>Feditext</strong> (beta) is a beautiful client for iOS, iPadOS and macOS.</p>
<a
href="https://fedi.software/@Feditext"
rel="nofollow noreferrer noopener"
target="_blank"
>
Get Feditext
</a>
</div>
<img
class="applist-logo"
src="/assets/feditext.svg"
alt="The Feditext logo, the characters 'ft' at a slight angle"
title="The Feditext logo, the characters 'ft' at a slight angle"
width="100"
height="100"
/>
</li>
<li class="applist-entry">
<div class="applist-text">
<p>Or try one of the <strong>Mastodon clients</strong> listed on the official Mastodon page.</p>
<a
href="https://joinmastodon.org/apps"
rel="nofollow noreferrer noopener"
target="_blank"
>
Get Mastodon apps
</a>
</div>
<img
class="applist-logo"
src="/assets/mastodon.svg"
alt="The Mastodon logo, the character 'M' in a speech bubble"
title="The Mastodon logo, the character 'M' in a speech bubble"
width="100"
height="100"
/>
</li>
</ul>
<title id="semaphore-title">The Semaphore logo</title>
<desc id="semaphore-desc">A waving flag</desc>
<path d="M68.13 0C53.94 0 42.81 20 13.9 27.1l-2.23-5.29a6.5 6.5 0 0 0-5.17-10.4 6.5 6.5 0 0 0-.81 12.95L46.2 120l5.99-2.5-14.42-33.33c22.8-6.86 32.51-22.16 49.83-20.58 9.9.9 4.87 19.56 8.11 17.93 16.22-8.15 32.44-11.41 50.29-11.41-7.96-9.78-17.38-20.55-22.71-31.74L120.8 32c-2.32-7.33-2.56-14.75.87-22.22-9.74-3.26-21.1 0-32.45 4.9C82.2 9.77 79.5 0 68.13 0zM15.26 30.42c8.95 6.63 13.63 13.86 16.07 20.94l1.62 6.32c1.24 6.58 1.07 12.8 1.27 18.03z"></path>
</svg>
</li>
<li class="applist-entry">
<div class="applist-text">
<p><strong>Tusky</strong> is a lightweight mobile client for Android.</p>
<a
href="https://tusky.app"
rel="nofollow noreferrer noopener"
target="_blank"
>
Get Tusky
</a>
</div>
<img
class="applist-logo"
src="/assets/tusky.svg"
alt="The Tusky mascot, a cartoon elephant tooting happily"
title="The Tusky mascot, a cartoon elephant tooting happily"
width="100"
height="100"
/>
</li>
<li class="applist-entry">
<div class="applist-text">
<p><strong>Feditext</strong> (beta) is a beautiful client for iOS, iPadOS and macOS.</p>
<a
href="https://fedi.software/@Feditext"
rel="nofollow noreferrer noopener"
target="_blank"
>
Get Feditext
</a>
</div>
<img
class="applist-logo"
src="/assets/feditext.svg"
alt="The Feditext logo, the characters 'ft' at a slight angle"
title="The Feditext logo, the characters 'ft' at a slight angle"
width="100"
height="100"
/>
</li>
<li class="applist-entry">
<div class="applist-text">
<p>Or try one of the <strong>Mastodon clients</strong> listed on the official Mastodon page.</p>
<a
href="https://joinmastodon.org/apps"
rel="nofollow noreferrer noopener"
target="_blank"
>
Get Mastodon apps
</a>
</div>
<img
class="applist-logo"
src="/assets/mastodon.svg"
alt="The Mastodon logo, the character 'M' in a speech bubble"
title="The Mastodon logo, the character 'M' in a speech bubble"
width="100"
height="100"
/>
</li>
</ul>
</div>
</section>
{{- end }}