[chore] Refactor HTML templates and CSS (#2480)

* [chore] Refactor HTML templates and CSS

* eslint

* ignore "Local"

* rss tests

* fiddle with OG just a tiny bit

* dick around with polls a bit more so SR stops saying "clickable"

* remove break

* oh lord

* don't lazy load avatar

* fix ogmeta tests

* clean up some cruft

* catch remaining calls to c.HTML

* fix error rendering + stack overflow in tag

* allow templating attributes

* fix indent

* set aria-hidden on status complementary content, since it's already present in the label anyway

* tidy up templating calls a little

* try to make styling a bit more consistent + readable

* fix up some remaining CSS issues

* fix up reports
This commit is contained in:
tobi 2023-12-27 11:23:52 +01:00 committed by GitHub
parent 97a1fd9a29
commit 0ff52b71f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 3262 additions and 1736 deletions

View File

@ -27,6 +27,8 @@ builds:
- static_build - static_build
- kvformat - kvformat
- timetzdata - timetzdata
- >-
{{ if and (index .Env "DEBUG") (.Env.DEBUG) }}debugenv{{ end }}
env: env:
- CGO_ENABLED=0 - CGO_ENABLED=0
goos: goos:

View File

@ -144,17 +144,25 @@ func (m *Module) AuthorizeGETHandler(c *gin.Context) {
return return
} }
// the authorize template will display a form to the user where they can get some information // The authorize template will display a form
// about the app that's trying to authorize, and the scope of the request. // to the user where they can see some info
// They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler // about the app that's trying to authorize,
c.HTML(http.StatusOK, "authorize.tmpl", gin.H{ // and the scope of the request. They can then
"appname": app.Name, // approve it if it looks OK to them, which
"appwebsite": app.Website, // will POST to the AuthorizePOSTHandler.
"redirect": redirect, page := apiutil.WebPage{
"scope": scope, Template: "authorize.tmpl",
"user": acct.Username, Instance: instance,
"instance": instance, Extra: map[string]any{
}) "appname": app.Name,
"appwebsite": app.Website,
"redirect": redirect,
"scope": scope,
"user": acct.Username,
},
}
apiutil.TemplateWebPage(c, page)
} }
// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize // AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize

View File

@ -143,11 +143,17 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1)
return return
} }
c.HTML(http.StatusOK, "finalize.tmpl", gin.H{
"instance": instance, page := apiutil.WebPage{
"name": claims.Name, Template: "finalize.tmpl",
"preferredUsername": claims.PreferredUsername, Instance: instance,
}) Extra: map[string]any{
"name": claims.Name,
"preferredUsername": claims.PreferredUsername,
},
}
apiutil.TemplateWebPage(c, page)
return return
} }
s.Set(sessionUserID, user.ID) s.Set(sessionUserID, user.ID)
@ -177,12 +183,18 @@ func (m *Module) FinalizePOSTHandler(c *gin.Context) {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return
} }
c.HTML(http.StatusOK, "finalize.tmpl", gin.H{
"instance": instance, page := apiutil.WebPage{
"name": form.Name, Template: "finalize.tmpl",
"preferredUsername": form.Username, Instance: instance,
"error": err, Extra: map[string]any{
}) "name": form.Name,
"preferredUsername": form.Username,
"error": err,
},
}
apiutil.TemplateWebPage(c, page)
} }
// check if the username conforms to the spec // check if the username conforms to the spec

View File

@ -21,7 +21,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -101,10 +100,15 @@ func (m *Module) OobHandler(c *gin.Context) {
// we're done with the session now, so just clear it out // we're done with the session now, so just clear it out
m.clearSession(s) m.clearSession(s)
c.HTML(http.StatusOK, "oob.tmpl", gin.H{ page := apiutil.WebPage{
"instance": instance, Template: "oob.tmpl",
"user": acct.Username, Instance: instance,
"oobToken": oobToken, Extra: map[string]any{
"scope": scope, "user": acct.Username,
}) "oobToken": oobToken,
"scope": scope,
},
}
apiutil.TemplateWebPage(c, page)
} }

View File

@ -32,8 +32,8 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// login just wraps a form-submitted username (we want an email) and password // signIn just wraps a form-submitted username (we want an email) and password
type login struct { type signIn struct {
Email string `form:"username"` Email string `form:"username"`
Password string `form:"password"` Password string `form:"password"`
} }
@ -55,10 +55,12 @@ func (m *Module) SignInGETHandler(c *gin.Context) {
return return
} }
// no idp provider, use our own funky little sign in page page := apiutil.WebPage{
c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{ Template: "sign-in.tmpl",
"instance": instance, Instance: instance,
}) }
apiutil.TemplateWebPage(c, page)
return return
} }
@ -83,7 +85,7 @@ func (m *Module) SignInGETHandler(c *gin.Context) {
func (m *Module) SignInPOSTHandler(c *gin.Context) { func (m *Module) SignInPOSTHandler(c *gin.Context) {
s := sessions.Default(c) s := sessions.Default(c)
form := &login{} form := &signIn{}
if err := c.ShouldBind(form); err != nil { if err := c.ShouldBind(form); err != nil {
m.clearSession(s) m.clearSession(s)
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1) apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
@ -129,7 +131,7 @@ func (m *Module) ValidatePassword(ctx context.Context, email string, password st
} }
if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil {
err := fmt.Errorf("password hash didn't match for user %s during login attempt: %s", user.Email, err) err := fmt.Errorf("password hash didn't match for user %s during sign in attempt: %s", user.Email, err)
return incorrectPassword(err) return incorrectPassword(err)
} }

View File

@ -116,6 +116,12 @@ type Status struct {
// //
// swagger:ignore // swagger:ignore
WebPollOptions []WebPollOption `json:"-"` WebPollOptions []WebPollOption `json:"-"`
// Status is from a local account.
// Always false for non-web statuses.
//
// swagger:ignore
Local bool `json:"-"`
} }
/* /*

View File

@ -50,10 +50,10 @@ func NotFoundHandler(c *gin.Context, instanceGet func(ctx context.Context) (*api
panic(err) panic(err)
} }
c.HTML(http.StatusNotFound, "404.tmpl", gin.H{ template404Page(c,
"instance": instance, instance,
"requestID": gtscontext.RequestID(ctx), gtscontext.RequestID(ctx),
}) )
default: default:
JSON(c, http.StatusNotFound, map[string]string{ JSON(c, http.StatusNotFound, map[string]string{
"error": errWithCode.Safe(), "error": errWithCode.Safe(),
@ -73,12 +73,12 @@ func genericErrorHandler(c *gin.Context, instanceGet func(ctx context.Context) (
panic(err) panic(err)
} }
c.HTML(errWithCode.Code(), "error.tmpl", gin.H{ templateErrorPage(c,
"instance": instance, instance,
"code": errWithCode.Code(), errWithCode.Code(),
"error": errWithCode.Safe(), errWithCode.Safe(),
"requestID": gtscontext.RequestID(ctx), gtscontext.RequestID(ctx),
}) )
default: default:
JSON(c, errWithCode.Code(), map[string]string{ JSON(c, errWithCode.Code(), map[string]string{
"error": errWithCode.Safe(), "error": errWithCode.Safe(),

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
package web package util
import ( import (
"html" "html"
@ -28,10 +28,10 @@ import (
const maxOGDescriptionLength = 300 const maxOGDescriptionLength = 300
// ogMeta represents supported OpenGraph Meta tags // OGMeta represents supported OpenGraph Meta tags
// //
// see eg https://ogp.me/ // see eg https://ogp.me/
type ogMeta struct { type OGMeta struct {
// vanilla og tags // vanilla og tags
Title string // og:title Title string // og:title
Type string // og:type Type string // og:type
@ -56,23 +56,23 @@ type ogMeta struct {
ProfileUsername string // profile:username ProfileUsername string // profile:username
} }
// ogBase returns an *ogMeta suitable for serving at // OGBase returns an *ogMeta suitable for serving at
// the base root of an instance. It also serves as a // the base root of an instance. It also serves as a
// foundation for building account / status ogMeta on // foundation for building account / status ogMeta on
// top of. // top of.
func ogBase(instance *apimodel.InstanceV1) *ogMeta { func OGBase(instance *apimodel.InstanceV1) *OGMeta {
var locale string var locale string
if len(instance.Languages) > 0 { if len(instance.Languages) > 0 {
locale = instance.Languages[0] locale = instance.Languages[0]
} }
og := &ogMeta{ og := &OGMeta{
Title: text.SanitizeToPlaintext(instance.Title) + " - GoToSocial", Title: text.SanitizeToPlaintext(instance.Title) + " - GoToSocial",
Type: "website", Type: "website",
Locale: locale, Locale: locale,
URL: instance.URI, URL: instance.URI,
SiteName: instance.AccountDomain, SiteName: instance.AccountDomain,
Description: parseDescription(instance.ShortDescription), Description: ParseDescription(instance.ShortDescription),
Image: instance.Thumbnail, Image: instance.Thumbnail,
ImageAlt: instance.ThumbnailDescription, ImageAlt: instance.ThumbnailDescription,
@ -81,15 +81,15 @@ func ogBase(instance *apimodel.InstanceV1) *ogMeta {
return og return og
} }
// withAccount uses the given account to build an ogMeta // WithAccount uses the given account to build an ogMeta
// struct specific to that account. It's suitable for serving // struct specific to that account. It's suitable for serving
// at account profile pages. // at account profile pages.
func (og *ogMeta) withAccount(account *apimodel.Account) *ogMeta { func (og *OGMeta) WithAccount(account *apimodel.Account) *OGMeta {
og.Title = parseTitle(account, og.SiteName) og.Title = AccountTitle(account, og.SiteName)
og.Type = "profile" og.Type = "profile"
og.URL = account.URL og.URL = account.URL
if account.Note != "" { if account.Note != "" {
og.Description = parseDescription(account.Note) og.Description = ParseDescription(account.Note)
} else { } else {
og.Description = `content="This GoToSocial user hasn't written a bio yet!"` og.Description = `content="This GoToSocial user hasn't written a bio yet!"`
} }
@ -102,11 +102,11 @@ func (og *ogMeta) withAccount(account *apimodel.Account) *ogMeta {
return og return og
} }
// withStatus uses the given status to build an ogMeta // WithStatus uses the given status to build an ogMeta
// struct specific to that status. It's suitable for serving // struct specific to that status. It's suitable for serving
// at status pages. // at status pages.
func (og *ogMeta) withStatus(status *apimodel.Status) *ogMeta { func (og *OGMeta) WithStatus(status *apimodel.Status) *OGMeta {
og.Title = "Post by " + parseTitle(status.Account, og.SiteName) og.Title = "Post by " + AccountTitle(status.Account, og.SiteName)
og.Type = "article" og.Type = "article"
if status.Language != nil { if status.Language != nil {
og.Locale = *status.Language og.Locale = *status.Language
@ -114,9 +114,9 @@ func (og *ogMeta) withStatus(status *apimodel.Status) *ogMeta {
og.URL = status.URL og.URL = status.URL
switch { switch {
case status.SpoilerText != "": case status.SpoilerText != "":
og.Description = parseDescription("CW: " + status.SpoilerText) og.Description = ParseDescription("CW: " + status.SpoilerText)
case status.Text != "": case status.Text != "":
og.Description = parseDescription(status.Text) og.Description = ParseDescription(status.Text)
default: default:
og.Description = og.Title og.Description = og.Title
} }
@ -147,34 +147,38 @@ func (og *ogMeta) withStatus(status *apimodel.Status) *ogMeta {
return og return og
} }
// parseTitle parses a page title from account and accountDomain // AccountTitle parses a page title from account and accountDomain
func parseTitle(account *apimodel.Account, accountDomain string) string { func AccountTitle(account *apimodel.Account, accountDomain string) string {
user := "@" + account.Acct + "@" + accountDomain user := "@" + account.Acct + "@" + accountDomain
if len(account.DisplayName) == 0 { if len(account.DisplayName) == 0 {
return user return user
} }
return account.DisplayName + " (" + user + ")" return account.DisplayName + ", " + user
} }
// parseDescription returns a string description which is // ParseDescription returns a string description which is
// safe to use as a template.HTMLAttr inside templates. // safe to use as a template.HTMLAttr inside templates.
func parseDescription(in string) string { func ParseDescription(in string) string {
i := text.SanitizeToPlaintext(in) i := text.SanitizeToPlaintext(in)
i = strings.ReplaceAll(i, "\n", " ") i = strings.ReplaceAll(i, "\n", " ")
i = strings.Join(strings.Fields(i), " ") i = strings.Join(strings.Fields(i), " ")
i = html.EscapeString(i) i = html.EscapeString(i)
i = strings.ReplaceAll(i, `\`, "&bsol;") i = strings.ReplaceAll(i, `\`, "&bsol;")
i = trim(i, maxOGDescriptionLength) i = truncate(i, maxOGDescriptionLength)
return `content="` + i + `"` return `content="` + i + `"`
} }
// trim strings trim s to specified length // truncate trims given string to
func trim(s string, length int) string { // specified length (in runes).
if len(s) < length { func truncate(s string, l int) string {
r := []rune(s)
if len(r) < l {
// No need
// to trim.
return s return s
} }
return s[:length] return string(r[:l]) + "..."
} }

View File

@ -15,7 +15,7 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
package web package util
import ( import (
"fmt" "fmt"
@ -40,18 +40,18 @@ func (suite *OpenGraphTestSuite) TestParseDescription() {
for _, tt := range tests { for _, tt := range tests {
tt := tt tt := tt
suite.Run(tt.name, func() { suite.Run(tt.name, func() {
suite.Equal(fmt.Sprintf("content=\"%s\"", tt.exp), parseDescription(tt.in)) suite.Equal(fmt.Sprintf("content=\"%s\"", tt.exp), ParseDescription(tt.in))
}) })
} }
} }
func (suite *OpenGraphTestSuite) TestWithAccountWithNote() { func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
baseMeta := ogBase(&apimodel.InstanceV1{ baseMeta := OGBase(&apimodel.InstanceV1{
AccountDomain: "example.org", AccountDomain: "example.org",
Languages: []string{"en"}, Languages: []string{"en"},
}) })
accountMeta := baseMeta.withAccount(&apimodel.Account{ accountMeta := baseMeta.WithAccount(&apimodel.Account{
Acct: "example_account", Acct: "example_account",
DisplayName: "example person!!", DisplayName: "example person!!",
URL: "https://example.org/@example_account", URL: "https://example.org/@example_account",
@ -59,8 +59,8 @@ func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
Username: "example_account", Username: "example_account",
}) })
suite.EqualValues(ogMeta{ suite.EqualValues(OGMeta{
Title: "example person!! (@example_account@example.org)", Title: "example person!!, @example_account@example.org",
Type: "profile", Type: "profile",
Locale: "en", Locale: "en",
URL: "https://example.org/@example_account", URL: "https://example.org/@example_account",
@ -79,12 +79,12 @@ func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
} }
func (suite *OpenGraphTestSuite) TestWithAccountNoNote() { func (suite *OpenGraphTestSuite) TestWithAccountNoNote() {
baseMeta := ogBase(&apimodel.InstanceV1{ baseMeta := OGBase(&apimodel.InstanceV1{
AccountDomain: "example.org", AccountDomain: "example.org",
Languages: []string{"en"}, Languages: []string{"en"},
}) })
accountMeta := baseMeta.withAccount(&apimodel.Account{ accountMeta := baseMeta.WithAccount(&apimodel.Account{
Acct: "example_account", Acct: "example_account",
DisplayName: "example person!!", DisplayName: "example person!!",
URL: "https://example.org/@example_account", URL: "https://example.org/@example_account",
@ -92,8 +92,8 @@ func (suite *OpenGraphTestSuite) TestWithAccountNoNote() {
Username: "example_account", Username: "example_account",
}) })
suite.EqualValues(ogMeta{ suite.EqualValues(OGMeta{
Title: "example person!! (@example_account@example.org)", Title: "example person!!, @example_account@example.org",
Type: "profile", Type: "profile",
Locale: "en", Locale: "en",
URL: "https://example.org/@example_account", URL: "https://example.org/@example_account",

View File

@ -0,0 +1,135 @@
// 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/>.
package util
import (
"net/http"
"github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
)
// WebPage encapsulates variables for
// rendering an HTML template within
// a standard GtS "page" template.
type WebPage struct {
// Name of the template for rendering
// the page. Eg., "example.tmpl".
Template string
// Instance model for rendering header,
// footer, and "about" information.
Instance *apimodel.InstanceV1
// OGMeta for rendering page
// "meta:og*" tags. Can be nil.
OGMeta *OGMeta
// Paths to CSS files to add to
// the page as "stylesheet" entries.
// Can be nil.
Stylesheets []string
// Paths to JS files to add to
// the page as "script" entries.
// Can be nil.
Javascript []string
// Extra parameters to pass to
// the template for rendering,
// eg., "account": *Account etc.
// Can be nil.
Extra map[string]any
}
// TemplateWebPage renders the given HTML template and
// page params within the standard GtS "page" template.
//
// ogMeta, stylesheets, javascript, and any extra
// properties will be provided to the template if
// set, but can all be nil.
func TemplateWebPage(
c *gin.Context,
page WebPage,
) {
obj := map[string]any{
"instance": page.Instance,
"ogMeta": page.OGMeta,
"stylesheets": page.Stylesheets,
"javascript": page.Javascript,
}
for k, v := range page.Extra {
obj[k] = v
}
templatePage(c, page.Template, http.StatusOK, obj)
}
// templateErrorPage renders the given
// HTTP code, error, and request ID
// within the standard error template.
func templateErrorPage(
c *gin.Context,
instance *apimodel.InstanceV1,
code int,
err string,
requestID string,
) {
const errorTmpl = "error.tmpl"
obj := map[string]any{
"instance": instance,
"code": code,
"error": err,
"requestID": requestID,
}
templatePage(c, errorTmpl, code, obj)
}
// template404Page renders
// a standard 404 page.
func template404Page(
c *gin.Context,
instance *apimodel.InstanceV1,
requestID string,
) {
const notFoundTmpl = "404.tmpl"
obj := map[string]any{
"instance": instance,
"requestID": requestID,
}
templatePage(c, notFoundTmpl, http.StatusNotFound, obj)
}
// render the given template inside
// "page.tmpl" with the provided
// code and template object.
func templatePage(
c *gin.Context,
template string,
code int,
obj map[string]any,
) {
const pageTmpl = "page.tmpl"
obj["pageContent"] = template
c.HTML(code, pageTmpl, obj)
}

View File

@ -56,8 +56,8 @@ const (
OOBTokenPath = "/oauth/oob" // #nosec G101 else we get a hardcoded credentials warning OOBTokenPath = "/oauth/oob" // #nosec G101 else we get a hardcoded credentials warning
// HelpfulAdvice is a handy hint to users; // HelpfulAdvice is a handy hint to users;
// particularly important during the login flow // particularly important during the login flow
HelpfulAdvice = "If you arrived at this error during a login/oauth flow, please try clearing your session cookies and logging in again; if problems persist, make sure you're using the correct credentials" HelpfulAdvice = "If you arrived at this error during a sign in/oauth flow, please try clearing your session cookies and signing in again; if problems persist, make sure you're using the correct credentials"
HelpfulAdviceGrant = "If you arrived at this error during a login/oauth flow, your client is trying to use an unsupported OAuth grant type. Supported grant types are: authorization_code, client_credentials; please reach out to developer of your client" HelpfulAdviceGrant = "If you arrived at this error during a sign in/oauth flow, your client is trying to use an unsupported OAuth grant type. Supported grant types are: authorization_code, client_credentials; please reach out to developer of your client"
) )
// Server wraps some oauth2 server functions in an interface, exposing only what is needed // Server wraps some oauth2 server functions in an interface, exposing only what is needed

View File

@ -39,7 +39,7 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() {
fmt.Println(feed) fmt.Println(feed)
suite.Equal("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n <channel>\n <title>Posts from @admin@localhost:8080</title>\n <link>http://localhost:8080/@admin</link>\n <description>Posts from @admin@localhost:8080</description>\n <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate>\n <lastBuildDate>Wed, 20 Oct 2021 12:36:45 +0000</lastBuildDate>\n <item>\n <title>open to see some puppies</title>\n <link>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</link>\n <description>@admin@localhost:8080 made a new post: &#34;🐕🐕🐕🐕🐕&#34;</description>\n <content:encoded><![CDATA[🐕🐕🐕🐕🐕]]></content:encoded>\n <author>@admin@localhost:8080</author>\n <guid>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</guid>\n <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate>\n <source>http://localhost:8080/@admin/feed.rss</source>\n </item>\n <item>\n <title>hello world! #welcome ! first post on the instance :rainbow: !</title>\n <link>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</link>\n <description>@admin@localhost:8080 posted 1 attachment: &#34;hello world! #welcome ! first post on the instance :rainbow: !&#34;</description>\n <content:encoded><![CDATA[hello world! #welcome ! first post on the instance <img src=\"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" class=\"emoji\"/> !]]></content:encoded>\n <author>@admin@localhost:8080</author>\n <enclosure url=\"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg\" length=\"62529\" type=\"image/jpeg\"></enclosure>\n <guid>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</guid>\n <pubDate>Wed, 20 Oct 2021 11:36:45 +0000</pubDate>\n <source>http://localhost:8080/@admin/feed.rss</source>\n </item>\n </channel>\n</rss>", feed) suite.Equal("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n <channel>\n <title>Posts from @admin@localhost:8080</title>\n <link>http://localhost:8080/@admin</link>\n <description>Posts from @admin@localhost:8080</description>\n <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate>\n <lastBuildDate>Wed, 20 Oct 2021 12:36:45 +0000</lastBuildDate>\n <item>\n <title>open to see some puppies</title>\n <link>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</link>\n <description>@admin@localhost:8080 made a new post: &#34;🐕🐕🐕🐕🐕&#34;</description>\n <content:encoded><![CDATA[🐕🐕🐕🐕🐕]]></content:encoded>\n <author>@admin@localhost:8080</author>\n <guid>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</guid>\n <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate>\n <source>http://localhost:8080/@admin/feed.rss</source>\n </item>\n <item>\n <title>hello world! #welcome ! first post on the instance :rainbow: !</title>\n <link>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</link>\n <description>@admin@localhost:8080 posted 1 attachment: &#34;hello world! #welcome ! first post on the instance :rainbow: !&#34;</description>\n <content:encoded><![CDATA[hello world! #welcome ! first post on the instance <img src=\"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" width=\"25\" height=\"25\"/> !]]></content:encoded>\n <author>@admin@localhost:8080</author>\n <enclosure url=\"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg\" length=\"62529\" type=\"image/jpeg\"></enclosure>\n <guid>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</guid>\n <pubDate>Wed, 20 Oct 2021 11:36:45 +0000</pubDate>\n <source>http://localhost:8080/@admin/feed.rss</source>\n </item>\n </channel>\n</rss>", feed)
} }
func (suite *GetRSSTestSuite) TestGetAccountRSSZork() { func (suite *GetRSSTestSuite) TestGetAccountRSSZork() {

View File

@ -83,7 +83,6 @@ func New(ctx context.Context) (*Router, error) {
// Attach functions used by HTML templating, // Attach functions used by HTML templating,
// and load HTML templates into the engine. // and load HTML templates into the engine.
LoadTemplateFunctions(engine)
if err := LoadTemplates(engine); err != nil { if err := LoadTemplates(engine); err != nil {
return nil, err return nil, err
} }

View File

@ -18,52 +18,121 @@
package router package router
import ( import (
"bytes"
"fmt" "fmt"
"html/template" "html/template"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"regexp"
"strings" "strings"
"time" "time"
"unsafe" "unsafe"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/render"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/regexes"
"github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/internal/util"
) )
const ( // LoadTemplates loads templates found at `web-template-base-dir`
justTime = "15:04" // into the Gin engine, or errors if templates cannot be loaded.
dateYear = "Jan 02, 2006" //
dateTime = "Jan 02, 15:04" // The special functions "include" and "includeAttr" will be added
dateYearTime = "Jan 02, 2006, 15:04" // to the template funcMap for use in any template. Use these "include"
monthYear = "Jan, 2006" // functions when you need to pass a template through a pipeline.
badTimestamp = "bad timestamp" // Otherwise, prefer the built-in "template" function.
)
// LoadTemplates loads html templates for use by the given engine
func LoadTemplates(engine *gin.Engine) error { func LoadTemplates(engine *gin.Engine) error {
templateBaseDir := config.GetWebTemplateBaseDir() templateBaseDir := config.GetWebTemplateBaseDir()
if templateBaseDir == "" { if templateBaseDir == "" {
return fmt.Errorf("%s cannot be empty and must be a relative or absolute path", config.WebTemplateBaseDirFlag()) return gtserror.Newf(
"%s cannot be empty and must be a relative or absolute path",
config.WebTemplateBaseDirFlag(),
)
} }
templateBaseDir, err := filepath.Abs(templateBaseDir) templateDirAbs, err := filepath.Abs(templateBaseDir)
if err != nil { if err != nil {
return fmt.Errorf("error getting absolute path of %s: %s", templateBaseDir, err) return gtserror.Newf(
"error getting absolute path of web-template-base-dir %s: %w",
templateBaseDir, err,
)
} }
if _, err := os.Stat(filepath.Join(templateBaseDir, "index.tmpl")); err != nil { indexTmplPath := filepath.Join(templateDirAbs, "index.tmpl")
return fmt.Errorf("%s doesn't seem to contain the templates; index.tmpl is missing: %w", templateBaseDir, err) if _, err := os.Stat(indexTmplPath); err != nil {
return gtserror.Newf(
"cannot find index.tmpl in web template directory %s: %w",
templateDirAbs, err,
)
} }
engine.LoadHTMLGlob(filepath.Join(templateBaseDir, "*")) // Bring base template into scope.
tmpl := template.New("base")
// Set additional "include" functions to render
// provided template name using the base template.
funcMap["include"] = func(name string, data any) (template.HTML, error) {
var buf strings.Builder
err := tmpl.ExecuteTemplate(&buf, name, data)
// Template was already escaped by
// ExecuteTemplate so we can trust it.
return noescape(buf.String()), err
}
funcMap["includeAttr"] = func(name string, data any) (template.HTMLAttr, error) {
var buf strings.Builder
err := tmpl.ExecuteTemplate(&buf, name, data)
// Template was already escaped by
// ExecuteTemplate so we can trust it.
return noescapeAttr(buf.String()), err
}
// Load functions into the base template, and
// associate other templates with base template.
templateGlob := filepath.Join(templateDirAbs, "*")
tmpl, err = tmpl.Funcs(funcMap).ParseGlob(templateGlob)
if err != nil {
return gtserror.Newf("error loading templates: %w", err)
}
// Almost done; teach the
// engine how to render.
engine.SetFuncMap(funcMap)
engine.HTMLRender = render.HTMLProduction{Template: tmpl}
return nil return nil
} }
var funcMap = template.FuncMap{
"add": add,
"acctInstance": acctInstance,
"demojify": demojify,
"deref": deref,
"emojify": emojify,
"escape": escape,
"increment": increment,
"indent": indent,
"indentAttr": indentAttr,
"isNil": isNil,
"outdentPre": outdentPre,
"noescapeAttr": noescapeAttr,
"noescape": noescape,
"oddOrEven": oddOrEven,
"subtract": subtract,
"timestampPrecise": timestampPrecise,
"timestamp": timestamp,
"timestampVague": timestampVague,
"visibilityIcon": visibilityIcon,
}
func oddOrEven(n int) string { func oddOrEven(n int) string {
if n%2 == 0 { if n%2 == 0 {
return "even" return "even"
@ -71,21 +140,40 @@ func oddOrEven(n int) string {
return "odd" return "odd"
} }
// escape HTML escapes the given string,
// returning a trusted template.
func escape(str string) template.HTML { func escape(str string) template.HTML {
/* #nosec G203 */ /* #nosec G203 */
return template.HTML(template.HTMLEscapeString(str)) return template.HTML(template.HTMLEscapeString(str))
} }
// noescape marks the given string as a
// trusted template. The provided string
// MUST have already passed through a
// template or escaping function.
func noescape(str string) template.HTML { func noescape(str string) template.HTML {
/* #nosec G203 */ /* #nosec G203 */
return template.HTML(str) return template.HTML(str)
} }
// noescapeAttr marks the given string as a
// trusted HTML attribute. The provided string
// MUST have already passed through a template
// or escaping function.
func noescapeAttr(str string) template.HTMLAttr { func noescapeAttr(str string) template.HTMLAttr {
/* #nosec G203 */ /* #nosec G203 */
return template.HTMLAttr(str) return template.HTMLAttr(str)
} }
const (
justTime = "15:04"
dateYear = "Jan 02, 2006"
dateTime = "Jan 02, 15:04"
dateYearTime = "Jan 02, 2006, 15:04"
monthYear = "Jan, 2006"
badTimestamp = "bad timestamp"
)
func timestamp(stamp string) string { func timestamp(stamp string) string {
t, err := util.ParseISO8601(stamp) t, err := util.ParseISO8601(stamp)
if err != nil { if err != nil {
@ -127,38 +215,55 @@ func timestampVague(stamp string) string {
return t.Format(monthYear) return t.Format(monthYear)
} }
type iconWithLabel struct {
faIcon string
label string
}
func visibilityIcon(visibility apimodel.Visibility) template.HTML { func visibilityIcon(visibility apimodel.Visibility) template.HTML {
var icon iconWithLabel var (
label string
icon string
)
switch visibility { switch visibility {
case apimodel.VisibilityPublic: case apimodel.VisibilityPublic:
icon = iconWithLabel{"globe", "public"} label = "public"
icon = "globe"
case apimodel.VisibilityUnlisted: case apimodel.VisibilityUnlisted:
icon = iconWithLabel{"unlock", "unlisted"} label = "unlisted"
icon = "unlock"
case apimodel.VisibilityPrivate: case apimodel.VisibilityPrivate:
icon = iconWithLabel{"lock", "private"} label = "private"
icon = "lock"
case apimodel.VisibilityMutualsOnly: case apimodel.VisibilityMutualsOnly:
icon = iconWithLabel{"handshake-o", "mutuals only"} label = "mutuals-only"
icon = "handshake-o"
case apimodel.VisibilityDirect: case apimodel.VisibilityDirect:
icon = iconWithLabel{"envelope", "direct"} label = "direct"
icon = "envelope"
} }
/* #nosec G203 */ /* #nosec G203 */
return template.HTML(fmt.Sprintf(`<i aria-label="Visibility: %v" class="fa fa-%v"></i>`, icon.label, icon.faIcon)) return template.HTML(fmt.Sprintf(
`<i aria-label="Visibility: %s" class="fa fa-%s"></i>`,
label, icon,
))
} }
// text is a template.HTML to affirm that the input of this function is already escaped // emojify replaces emojis in the given
func emojify(emojis []apimodel.Emoji, inputText template.HTML) template.HTML { // html fragment with suitable <img> tags.
out := text.Emojify(emojis, string(inputText)) //
// The provided input must have been
// escaped / templated already!
func emojify(
emojis []apimodel.Emoji,
html template.HTML,
) template.HTML {
return text.EmojifyWeb(emojis, html)
}
/* #nosec G203 */ // demojify replaces emoji shortcodes in
// (this is escaped above) // the given fragment with empty strings.
return template.HTML(out) //
// Output must then be escaped as appropriate.
func demojify(input string) string {
return text.Demojify(input)
} }
func acctInstance(acct string) string { func acctInstance(acct string) string {
@ -170,10 +275,79 @@ func acctInstance(acct string) string {
return "" return ""
} }
// increment adds 1
// to the given int.
func increment(i int) int { func increment(i int) int {
return i + 1 return i + 1
} }
// add adds n2 to n1.
func add(n1 int, n2 int) int {
return n1 + n2
}
// subtract subtracts n2 from n1.
func subtract(n1 int, n2 int) int {
return n1 - n2
}
var (
indentRegex = regexp.MustCompile(`(?m)^`)
indentStr = " "
indentStrLen = len(indentStr)
indents = strings.Repeat(indentStr, 12)
indentPre = regexp.MustCompile(fmt.Sprintf(`(?Ums)^((?:%s)+)<pre>.*</pre>`, indentStr))
)
// indent appropriately indents the given html
// by prepending each line with the indentStr.
func indent(n int, html template.HTML) template.HTML {
out := indentRegex.ReplaceAllString(
string(html),
indents[:n*indentStrLen],
)
return noescape(out)
}
// indentAttr appropriately indents the given html
// attribute by prepending each line with the indentStr.
func indentAttr(n int, html template.HTMLAttr) template.HTMLAttr {
out := indentRegex.ReplaceAllString(
string(html),
indents[:n*indentStrLen],
)
return noescapeAttr(out)
}
// outdentPre outdents all `<pre></pre>` tags in the
// given HTML so that they render correctly in code
// blocks, even if they were indented before.
func outdentPre(html template.HTML) template.HTML {
input := string(html)
output := regexes.ReplaceAllStringFunc(indentPre, input,
func(match string, buf *bytes.Buffer) string {
// Reuse the regex to pull out submatches.
matches := indentPre.FindAllStringSubmatch(match, -1)
if len(matches) != 1 {
return match
}
var (
indented = matches[0][0]
indent = matches[0][1]
)
// Outdent everything in the inner match, add
// a newline at the end to make it a bit neater.
outdented := strings.ReplaceAll(indented, indent, "")
// Replace original match with the outdented version.
return strings.ReplaceAll(match, indented, outdented)
},
)
return noescape(output)
}
// isNil will safely check if 'v' is nil without // isNil will safely check if 'v' is nil without
// dealing with weird Go interface nil bullshit. // dealing with weird Go interface nil bullshit.
func isNil(i interface{}) bool { func isNil(i interface{}) bool {
@ -193,21 +367,3 @@ func deref(i any) any {
return vOf.Elem() return vOf.Elem()
} }
func LoadTemplateFunctions(engine *gin.Engine) {
engine.SetFuncMap(template.FuncMap{
"escape": escape,
"noescape": noescape,
"noescapeAttr": noescapeAttr,
"oddOrEven": oddOrEven,
"visibilityIcon": visibilityIcon,
"timestamp": timestamp,
"timestampVague": timestampVague,
"timestampPrecise": timestampPrecise,
"emojify": emojify,
"acctInstance": acctInstance,
"increment": increment,
"isNil": isNil,
"deref": deref,
})
}

View File

@ -0,0 +1,204 @@
// 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/>.
package router
import (
"html/template"
"testing"
)
func TestOutdentPre(t *testing.T) {
const html = template.HTML(`
<div class="text">
<div class="content" lang="en">
<p>Here's a bunch of HTML, read it and weep, weep then!</p>
<pre><code class="language-html">&lt;section class=&#34;about-user&#34;&gt;
&lt;div class=&#34;col-header&#34;&gt;
&lt;h2&gt;About&lt;/h2&gt;
&lt;/div&gt;
&lt;div class=&#34;fields&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Fields&lt;/h3&gt;
&lt;dl&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;should you follow me?&lt;/dt&gt;
&lt;dd&gt;maybe!&lt;/dd&gt;
&lt;/div&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;age&lt;/dt&gt;
&lt;dd&gt;120&lt;/dd&gt;
&lt;/div&gt;
&lt;/dl&gt;
&lt;/div&gt;
&lt;div class=&#34;bio&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Bio&lt;/h3&gt;
&lt;p&gt;i post about things that concern me&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;sr-only&#34; role=&#34;group&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Stats&lt;/h3&gt;
&lt;span&gt;Joined in Jun, 2022.&lt;/span&gt;
&lt;span&gt;8 posts.&lt;/span&gt;
&lt;span&gt;Followed by 1.&lt;/span&gt;
&lt;span&gt;Following 1.&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&#34;accountstats&#34; aria-hidden=&#34;true&#34;&gt;
&lt;b&gt;Joined&lt;/b&gt;&lt;time datetime=&#34;2022-06-04T13:12:00.000Z&#34;&gt;Jun, 2022&lt;/time&gt;
&lt;b&gt;Posts&lt;/b&gt;&lt;span&gt;8&lt;/span&gt;
&lt;b&gt;Followed by&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;b&gt;Following&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;/div&gt;
&lt;/section&gt;
</code></pre>
<p>There, hope you liked that!</p>
</div>
</div>
<div class="text">
<div class="content" lang="en">
<p>Here's a bunch of HTML, read it and weep, weep then!</p>
<pre><code class="language-html">&lt;section class=&#34;about-user&#34;&gt;
&lt;div class=&#34;col-header&#34;&gt;
&lt;h2&gt;About&lt;/h2&gt;
&lt;/div&gt;
&lt;div class=&#34;fields&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Fields&lt;/h3&gt;
&lt;dl&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;should you follow me?&lt;/dt&gt;
&lt;dd&gt;maybe!&lt;/dd&gt;
&lt;/div&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;age&lt;/dt&gt;
&lt;dd&gt;120&lt;/dd&gt;
&lt;/div&gt;
&lt;/dl&gt;
&lt;/div&gt;
&lt;div class=&#34;bio&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Bio&lt;/h3&gt;
&lt;p&gt;i post about things that concern me&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;sr-only&#34; role=&#34;group&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Stats&lt;/h3&gt;
&lt;span&gt;Joined in Jun, 2022.&lt;/span&gt;
&lt;span&gt;8 posts.&lt;/span&gt;
&lt;span&gt;Followed by 1.&lt;/span&gt;
&lt;span&gt;Following 1.&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&#34;accountstats&#34; aria-hidden=&#34;true&#34;&gt;
&lt;b&gt;Joined&lt;/b&gt;&lt;time datetime=&#34;2022-06-04T13:12:00.000Z&#34;&gt;Jun, 2022&lt;/time&gt;
&lt;b&gt;Posts&lt;/b&gt;&lt;span&gt;8&lt;/span&gt;
&lt;b&gt;Followed by&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;b&gt;Following&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;/div&gt;
&lt;/section&gt;
</code></pre>
<p>There, hope you liked that!</p>
</div>
</div>
`)
const expected = template.HTML(`
<div class="text">
<div class="content" lang="en">
<p>Here's a bunch of HTML, read it and weep, weep then!</p>
<pre><code class="language-html">&lt;section class=&#34;about-user&#34;&gt;
&lt;div class=&#34;col-header&#34;&gt;
&lt;h2&gt;About&lt;/h2&gt;
&lt;/div&gt;
&lt;div class=&#34;fields&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Fields&lt;/h3&gt;
&lt;dl&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;should you follow me?&lt;/dt&gt;
&lt;dd&gt;maybe!&lt;/dd&gt;
&lt;/div&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;age&lt;/dt&gt;
&lt;dd&gt;120&lt;/dd&gt;
&lt;/div&gt;
&lt;/dl&gt;
&lt;/div&gt;
&lt;div class=&#34;bio&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Bio&lt;/h3&gt;
&lt;p&gt;i post about things that concern me&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;sr-only&#34; role=&#34;group&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Stats&lt;/h3&gt;
&lt;span&gt;Joined in Jun, 2022.&lt;/span&gt;
&lt;span&gt;8 posts.&lt;/span&gt;
&lt;span&gt;Followed by 1.&lt;/span&gt;
&lt;span&gt;Following 1.&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&#34;accountstats&#34; aria-hidden=&#34;true&#34;&gt;
&lt;b&gt;Joined&lt;/b&gt;&lt;time datetime=&#34;2022-06-04T13:12:00.000Z&#34;&gt;Jun, 2022&lt;/time&gt;
&lt;b&gt;Posts&lt;/b&gt;&lt;span&gt;8&lt;/span&gt;
&lt;b&gt;Followed by&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;b&gt;Following&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;/div&gt;
&lt;/section&gt;
</code></pre>
<p>There, hope you liked that!</p>
</div>
</div>
<div class="text">
<div class="content" lang="en">
<p>Here's a bunch of HTML, read it and weep, weep then!</p>
<pre><code class="language-html">&lt;section class=&#34;about-user&#34;&gt;
&lt;div class=&#34;col-header&#34;&gt;
&lt;h2&gt;About&lt;/h2&gt;
&lt;/div&gt;
&lt;div class=&#34;fields&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Fields&lt;/h3&gt;
&lt;dl&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;should you follow me?&lt;/dt&gt;
&lt;dd&gt;maybe!&lt;/dd&gt;
&lt;/div&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;age&lt;/dt&gt;
&lt;dd&gt;120&lt;/dd&gt;
&lt;/div&gt;
&lt;/dl&gt;
&lt;/div&gt;
&lt;div class=&#34;bio&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Bio&lt;/h3&gt;
&lt;p&gt;i post about things that concern me&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;sr-only&#34; role=&#34;group&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Stats&lt;/h3&gt;
&lt;span&gt;Joined in Jun, 2022.&lt;/span&gt;
&lt;span&gt;8 posts.&lt;/span&gt;
&lt;span&gt;Followed by 1.&lt;/span&gt;
&lt;span&gt;Following 1.&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&#34;accountstats&#34; aria-hidden=&#34;true&#34;&gt;
&lt;b&gt;Joined&lt;/b&gt;&lt;time datetime=&#34;2022-06-04T13:12:00.000Z&#34;&gt;Jun, 2022&lt;/time&gt;
&lt;b&gt;Posts&lt;/b&gt;&lt;span&gt;8&lt;/span&gt;
&lt;b&gt;Followed by&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;b&gt;Following&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;/div&gt;
&lt;/section&gt;
</code></pre>
<p>There, hope you liked that!</p>
</div>
</div>
`)
out := outdentPre(html)
if out != expected {
t.Fatalf("unexpected output:\n`%s`\n", out)
}
}

View File

@ -20,18 +20,76 @@ package text
import ( import (
"bytes" "bytes"
"html" "html"
"html/template"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/regexes" "github.com/superseriousbusiness/gotosocial/internal/regexes"
) )
// Emojify replaces shortcodes in `inputText` with the emoji in `emojis`. // EmojifyWeb replaces emoji shortcodes like `:example:` in the given HTML
// // fragment with `<img>` tags suitable for rendering on the web frontend.
// Callers should ensure that inputText and resulting text are escaped func EmojifyWeb(emojis []apimodel.Emoji, html template.HTML) template.HTML {
// appropriately depending on what they're used for. out := emojify(
func Emojify(emojis []apimodel.Emoji, inputText string) string { emojis,
emojisMap := make(map[string]apimodel.Emoji, len(emojis)) string(html),
func(url, code string, buf *bytes.Buffer) {
buf.WriteString(`<img src="`)
buf.WriteString(url)
buf.WriteString(`" title=":`)
buf.WriteString(code)
buf.WriteString(`:" alt=":`)
buf.WriteString(code)
buf.WriteString(`:" class="emoji" `)
// Lazy load emojis when
// they scroll into view.
buf.WriteString(`loading="lazy" `)
// Limit size to avoid showing
// huge emojis when unstyled.
buf.WriteString(`width="25" height="25"/>`)
},
)
// If input was safe,
// we can trust output.
return template.HTML(out) // #nosec G203
}
// EmojifyRSS replaces emoji shortcodes like `:example:` in the given text
// fragment with `<img>` tags suitable for rendering as RSS content.
func EmojifyRSS(emojis []apimodel.Emoji, text string) string {
return emojify(
emojis,
text,
func(url, code string, buf *bytes.Buffer) {
buf.WriteString(`<img src="`)
buf.WriteString(url)
buf.WriteString(`" title=":`)
buf.WriteString(code)
buf.WriteString(`:" alt=":`)
buf.WriteString(code)
buf.WriteString(`:" `)
// Limit size to avoid showing
// huge emojis in RSS readers.
buf.WriteString(`width="25" height="25"/>`)
},
)
}
// Demojify replaces emoji shortcodes like `:example:` in the given text
// fragment with empty strings, essentially stripping them from the text.
// This is useful for text used in OG Meta headers.
func Demojify(text string) string {
return regexes.EmojiFinder.ReplaceAllString(text, "")
}
func emojify(
emojis []apimodel.Emoji,
input string,
write func(url, code string, buf *bytes.Buffer),
) string {
// Build map of shortcodes. Normalize each
// shortcode by readding closing colons.
emojisMap := make(map[string]apimodel.Emoji, len(emojis))
for _, emoji := range emojis { for _, emoji := range emojis {
shortcode := ":" + emoji.Shortcode + ":" shortcode := ":" + emoji.Shortcode + ":"
emojisMap[shortcode] = emoji emojisMap[shortcode] = emoji
@ -39,27 +97,20 @@ func Emojify(emojis []apimodel.Emoji, inputText string) string {
return regexes.ReplaceAllStringFunc( return regexes.ReplaceAllStringFunc(
regexes.EmojiFinder, regexes.EmojiFinder,
inputText, input,
func(shortcode string, buf *bytes.Buffer) string { func(shortcode string, buf *bytes.Buffer) string {
// Look for emoji according to this shortcode // Look for emoji with this shortcode.
emoji, ok := emojisMap[shortcode] emoji, ok := emojisMap[shortcode]
if !ok { if !ok {
return shortcode return shortcode
} }
// Escape raw emoji content // Escape raw emoji content.
safeURL := html.EscapeString(emoji.URL) url := html.EscapeString(emoji.URL)
safeCode := html.EscapeString(emoji.Shortcode) code := html.EscapeString(emoji.Shortcode)
// Write HTML emoji repr to buffer
buf.WriteString(`<img src="`)
buf.WriteString(safeURL)
buf.WriteString(`" title=":`)
buf.WriteString(safeCode)
buf.WriteString(`:" alt=":`)
buf.WriteString(safeCode)
buf.WriteString(`:" class="emoji"/>`)
// Write emoji repr to buffer.
write(url, code, buf)
return buf.String() return buf.String()
}, },
) )

View File

@ -662,6 +662,10 @@ func (c *Converter) StatusToWebStatus(
return nil, err return nil, err
} }
// Whack a newline before and after each "pre" to make it easier to outdent it.
webStatus.Content = strings.ReplaceAll(webStatus.Content, "<pre>", "\n<pre>")
webStatus.Content = strings.ReplaceAll(webStatus.Content, "</pre>", "</pre>\n")
// Add additional information for template. // Add additional information for template.
// Assume empty langs, hope for not empty language. // Assume empty langs, hope for not empty language.
webStatus.LanguageTag = new(language.Language) webStatus.LanguageTag = new(language.Language)
@ -727,6 +731,8 @@ func (c *Converter) StatusToWebStatus(
a.Sensitive = webStatus.Sensitive a.Sensitive = webStatus.Sensitive
} }
webStatus.Local = *s.Local
return webStatus, nil return webStatus, nil
} }

View File

@ -151,7 +151,7 @@ func (c *Converter) StatusToRSSItem(ctx context.Context, s *gtsmodel.Status) (*f
apiEmojis = append(apiEmojis, apiEmoji) apiEmojis = append(apiEmojis, apiEmoji)
} }
} }
content := text.Emojify(apiEmojis, s.Content) content := text.EmojifyRSS(apiEmojis, s.Content)
return &feeds.Item{ return &feeds.Item{
Title: title, Title: title,

View File

@ -81,7 +81,7 @@ func (suite *InternalToRSSTestSuite) TestStatusToRSSItem2() {
suite.Equal("62529", item.Enclosure.Length) suite.Equal("62529", item.Enclosure.Length)
suite.Equal("image/jpeg", item.Enclosure.Type) suite.Equal("image/jpeg", item.Enclosure.Type)
suite.Equal("http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", item.Enclosure.Url) suite.Equal("http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", item.Enclosure.Url)
suite.Equal("hello world! #welcome ! first post on the instance <img src=\"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" class=\"emoji\"/> !", item.Content) suite.Equal("hello world! #welcome ! first post on the instance <img src=\"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" width=\"25\" height=\"25\"/> !", item.Content)
} }
func (suite *InternalToRSSTestSuite) TestStatusToRSSItem3() { func (suite *InternalToRSSTestSuite) TestStatusToRSSItem3() {

View File

@ -18,9 +18,10 @@
package web package web
import ( import (
"net/http" "context"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
@ -31,20 +32,35 @@ const (
) )
func (m *Module) aboutGETHandler(c *gin.Context) { func (m *Module) aboutGETHandler(c *gin.Context) {
instance, err := m.processor.InstanceGetV1(c.Request.Context()) instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
if err != nil { if errWithCode != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return
} }
c.HTML(http.StatusOK, "about.tmpl", gin.H{ // Return instance we already got from the db,
"instance": instance, // don't try to fetch it again when erroring.
"languages": config.GetInstanceLanguages().DisplayStrs(), instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
"ogMeta": ogBase(instance), return instance, nil
"blocklistExposed": config.GetInstanceExposeSuspendedWeb(), }
"stylesheets": []string{
assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css", // We only serve text/html at this endpoint.
if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet)
return
}
page := apiutil.WebPage{
Template: "about.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssAbout},
Extra: map[string]any{
"showStrap": true,
"blocklistExposed": config.GetInstanceExposeSuspendedWeb(),
"languages": config.GetInstanceLanguages().DisplayStrs(),
}, },
"javascript": []string{distPathPrefix + "/frontend.js"}, }
})
apiutil.TemplateWebPage(c, page)
} }

View File

@ -18,39 +18,58 @@
package web package web
import ( import (
"context"
"errors" "errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
) )
func (m *Module) confirmEmailGETHandler(c *gin.Context) { func (m *Module) confirmEmailGETHandler(c *gin.Context) {
ctx := c.Request.Context() instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
// if there's no token in the query, just serve the 404 web handler
token := c.Query(tokenParam)
if token == "" {
apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))), m.processor.InstanceGetV1)
return
}
user, errWithCode := m.processor.User().EmailConfirm(ctx, token)
if errWithCode != nil { if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return
} }
instance, err := m.processor.InstanceGetV1(ctx) // Return instance we already got from the db,
if err != nil { // don't try to fetch it again when erroring.
apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
return instance, nil
}
// We only serve text/html at this endpoint.
if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet)
return return
} }
c.HTML(http.StatusOK, "confirmed.tmpl", gin.H{ // If there's no token in the query,
"instance": instance, // just serve the 404 web handler.
"email": user.Email, token := c.Query("token")
"username": user.Account.Username, if token == "" {
}) errWithCode := gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound)))
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return
}
user, errWithCode := m.processor.User().EmailConfirm(c.Request.Context(), token)
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return
}
page := apiutil.WebPage{
Template: "confirmed.tmpl",
Instance: instance,
Extra: map[string]any{
"email": user.Email,
"username": user.Account.Username,
},
}
apiutil.TemplateWebPage(c, page)
} }

View File

@ -18,9 +18,7 @@
package web package web
import ( import (
"errors"
"net/http" "net/http"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
@ -31,31 +29,29 @@ import (
const textCSSUTF8 = string(apiutil.TextCSS + "; charset=utf-8") const textCSSUTF8 = string(apiutil.TextCSS + "; charset=utf-8")
func (m *Module) customCSSGETHandler(c *gin.Context) { func (m *Module) customCSSGETHandler(c *gin.Context) {
if !config.GetAccountsAllowCustomCSS() {
err := errors.New("accounts-allow-custom-css is not enabled on this instance")
apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.TextCSS); err != nil { if _, err := apiutil.NegotiateAccept(c, apiutil.TextCSS); err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return return
} }
// usernames on our instance will always be lowercase targetUsername, errWithCode := apiutil.ParseWebUsername(c.Param(apiutil.WebUsernameKey))
username := strings.ToLower(c.Param(usernameKey))
if username == "" {
err := errors.New("no account username specified")
apiutil.WebErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
return
}
customCSS, errWithCode := m.processor.Account().GetCustomCSSForUsername(c.Request.Context(), username)
if errWithCode != nil { if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return
} }
// Retrieve customCSS if enabled on the instance.
// Else use an empty string, to help with caching
// when custom CSS gets toggled on or off.
var customCSS string
if config.GetAccountsAllowCustomCSS() {
customCSS, errWithCode = m.processor.Account().GetCustomCSSForUsername(c.Request.Context(), targetUsername)
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
}
c.Header(cacheControlHeader, cacheControlNoCache) c.Header(cacheControlHeader, cacheControlNoCache)
c.Data(http.StatusOK, textCSSUTF8, []byte(customCSS)) c.Data(http.StatusOK, textCSSUTF8, []byte(customCSS))
} }

View File

@ -18,14 +18,14 @@
package web package web
import ( import (
"context"
"fmt" "fmt"
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
) )
const ( const (
@ -33,37 +33,44 @@ const (
) )
func (m *Module) domainBlockListGETHandler(c *gin.Context) { func (m *Module) domainBlockListGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, false, false, false, false) instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
if err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if !config.GetInstanceExposeSuspendedWeb() && (authed.Account == nil || authed.User == nil) {
err := fmt.Errorf("this instance does not expose the list of suspended domains publicly")
apiutil.WebErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
instance, err := m.processor.InstanceGetV1(c.Request.Context())
if err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1)
return
}
domainBlocks, errWithCode := m.processor.InstancePeersGet(c.Request.Context(), true, false, false)
if errWithCode != nil { if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return
} }
c.HTML(http.StatusOK, "domain-blocklist.tmpl", gin.H{ // Return instance we already got from the db,
"instance": instance, // don't try to fetch it again when erroring.
"ogMeta": ogBase(instance), instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
"blocklist": domainBlocks, return instance, nil
"stylesheets": []string{ }
assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css",
}, // We only serve text/html at this endpoint.
"javascript": []string{distPathPrefix + "/frontend.js"}, if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil {
}) apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet)
return
}
if !config.GetInstanceExposeSuspendedWeb() {
err := fmt.Errorf("this instance does not publicy expose its blocklist")
apiutil.WebErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), instanceGet)
return
}
domainBlocks, errWithCode := m.processor.InstancePeersGet(c.Request.Context(), true, false, false)
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return
}
page := apiutil.WebPage{
Template: "domain-blocklist.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssFA},
Javascript: []string{jsFrontend},
Extra: map[string]any{"blocklist": domainBlocks},
}
apiutil.TemplateWebPage(c, page)
} }

View File

@ -18,33 +18,50 @@
package web package web
import ( import (
"context"
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
) )
func (m *Module) baseHandler(c *gin.Context) { func (m *Module) indexHandler(c *gin.Context) {
// if a landingPageUser is set in the config, redirect to that user's profile instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
// Return instance we already got from the db,
// don't try to fetch it again when erroring.
instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
return instance, nil
}
// We only serve text/html at this endpoint.
if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet)
return
}
// If a landingPageUser is set in the config, redirect to
// that user's profile instead of rendering landing/index page.
if landingPageUser := config.GetLandingPageUser(); landingPageUser != "" { if landingPageUser := config.GetLandingPageUser(); landingPageUser != "" {
c.Redirect(http.StatusFound, "/@"+strings.ToLower(landingPageUser)) c.Redirect(http.StatusFound, "/@"+strings.ToLower(landingPageUser))
return return
} }
instance, err := m.processor.InstanceGetV1(c.Request.Context()) page := apiutil.WebPage{
if err != nil { Template: "index.tmpl",
apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) Instance: instance,
return OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssAbout, cssIndex},
Extra: map[string]any{"showStrap": true},
} }
c.HTML(http.StatusOK, "index.tmpl", gin.H{ apiutil.TemplateWebPage(c, page)
"instance": instance,
"ogMeta": ogBase(instance),
"stylesheets": []string{
distPathPrefix + "/index.css",
},
})
} }

View File

@ -27,7 +27,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
) )
@ -141,28 +140,28 @@ func (m *Module) profileGETHandler(c *gin.Context) {
return return
} }
stylesheets := []string{ page := apiutil.WebPage{
assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css", Template: "profile.tmpl",
distPathPrefix + "/status.css", Instance: instance,
distPathPrefix + "/profile.css", OGMeta: apiutil.OGBase(instance).WithAccount(targetAccount),
} Stylesheets: []string{
if config.GetAccountsAllowCustomCSS() { cssFA, cssStatus, cssThread, cssProfile,
stylesheets = append(stylesheets, "/@"+targetAccount.Username+"/custom.css") // Custom CSS for this user last in cascade.
"/@" + targetAccount.Username + "/custom.css",
},
Javascript: []string{jsFrontend},
Extra: map[string]any{
"account": targetAccount,
"rssFeed": rssFeed,
"robotsMeta": robotsMeta,
"statuses": statusResp.Items,
"statuses_next": statusResp.NextLink,
"pinned_statuses": pinnedStatuses,
"show_back_to_top": paging,
},
} }
c.HTML(http.StatusOK, "profile.tmpl", gin.H{ apiutil.TemplateWebPage(c, page)
"instance": instance,
"account": targetAccount,
"ogMeta": ogBase(instance).withAccount(targetAccount),
"rssFeed": rssFeed,
"robotsMeta": robotsMeta,
"statuses": statusResp.Items,
"statuses_next": statusResp.NextLink,
"pinned_statuses": pinnedStatuses,
"show_back_to_top": paging,
"stylesheets": stylesheets,
"javascript": []string{distPathPrefix + "/frontend.js"},
})
} }
// returnAPAccount returns an ActivityPub representation of // returnAPAccount returns an ActivityPub representation of

View File

@ -71,7 +71,7 @@ Crawl-delay: 500
# API endpoints. # API endpoints.
Disallow: /api/ Disallow: /api/
# Auth/login endpoints. # Auth/Sign in endpoints.
Disallow: /auth/ Disallow: /auth/
Disallow: /oauth/ Disallow: /oauth/
Disallow: /check_your_email Disallow: /check_your_email

View File

@ -18,30 +18,44 @@
package web package web
import ( import (
"net/http" "context"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
) )
func (m *Module) SettingsPanelHandler(c *gin.Context) { func (m *Module) SettingsPanelHandler(c *gin.Context) {
instance, err := m.processor.InstanceGetV1(c.Request.Context()) instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
if err != nil { if errWithCode != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return
} }
c.HTML(http.StatusOK, "frontend.tmpl", gin.H{ // Return instance we already got from the db,
"instance": instance, // don't try to fetch it again when erroring.
"stylesheets": []string{ instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css", return instance, nil
distPathPrefix + "/_colors.css", }
distPathPrefix + "/base.css",
distPathPrefix + "/profile.css", // We only serve text/html at this endpoint.
distPathPrefix + "/status.css", if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil {
distPathPrefix + "/settings-style.css", apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet)
return
}
page := apiutil.WebPage{
Template: "frontend.tmpl",
Instance: instance,
Stylesheets: []string{
cssFA,
cssProfile, // Used for rendering stub/fake profiles.
cssStatus, // Used for rendering stub/fake statuses.
cssSettings,
}, },
"javascript": []string{distPathPrefix + "/settings.js"}, Javascript: []string{jsSettings},
}) }
apiutil.TemplateWebPage(c, page)
} }

View File

@ -19,7 +19,6 @@ package web
import ( import (
"context" "context"
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
@ -56,16 +55,13 @@ func (m *Module) tagGETHandler(c *gin.Context) {
return return
} }
stylesheets := []string{ page := apiutil.WebPage{
assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css", Template: "tag.tmpl",
distPathPrefix + "/status.css", Instance: instance,
distPathPrefix + "/tag.css", OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssFA, cssThread, cssTag},
Extra: map[string]any{"tagName": tagName},
} }
c.HTML(http.StatusOK, "tag.tmpl", gin.H{ apiutil.TemplateWebPage(c, page)
"instance": instance,
"ogMeta": ogBase(instance),
"tagName": tagName,
"stylesheets": stylesheets,
})
} }

View File

@ -28,7 +28,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oauth"
) )
@ -139,22 +138,23 @@ func (m *Module) threadGETHandler(c *gin.Context) {
return return
} }
stylesheets := []string{ page := apiutil.WebPage{
assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css", Template: "thread.tmpl",
distPathPrefix + "/status.css", Instance: instance,
} OGMeta: apiutil.OGBase(instance).WithStatus(status),
if config.GetAccountsAllowCustomCSS() { Stylesheets: []string{
stylesheets = append(stylesheets, "/@"+targetUsername+"/custom.css") cssFA, cssStatus, cssThread,
// Custom CSS for this user last in cascade.
"/@" + targetUsername + "/custom.css",
},
Javascript: []string{jsFrontend},
Extra: map[string]any{
"status": status,
"context": context,
},
} }
c.HTML(http.StatusOK, "thread.tmpl", gin.H{ apiutil.TemplateWebPage(c, page)
"instance": instance,
"status": status,
"context": context,
"ogMeta": ogBase(instance).withStatus(status),
"stylesheets": stylesheets,
"javascript": []string{distPathPrefix + "/frontend.js"},
})
} }
// returnAPStatus returns an ActivityPub representation of target status, // returnAPStatus returns an ActivityPub representation of target status,

View File

@ -37,7 +37,7 @@ import (
const ( const (
confirmEmailPath = "/" + uris.ConfirmEmailPath confirmEmailPath = "/" + uris.ConfirmEmailPath
profileGroupPath = "/@:" + usernameKey profileGroupPath = "/@:username"
statusPath = "/statuses/:" + apiutil.WebStatusIDKey // leave out the '/@:username' prefix as this will be served within the profile group statusPath = "/statuses/:" + apiutil.WebStatusIDKey // leave out the '/@:username' prefix as this will be served within the profile group
tagsPath = "/tags/:" + apiutil.TagNameKey tagsPath = "/tags/:" + apiutil.TagNameKey
customCSSPath = profileGroupPath + "/custom.css" customCSSPath = profileGroupPath + "/custom.css"
@ -49,15 +49,24 @@ const (
userPanelPath = settingsPathPrefix + "/user" userPanelPath = settingsPathPrefix + "/user"
adminPanelPath = settingsPathPrefix + "/admin" adminPanelPath = settingsPathPrefix + "/admin"
tokenParam = "token"
usernameKey = "username"
cacheControlHeader = "Cache-Control" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control cacheControlHeader = "Cache-Control" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
cacheControlNoCache = "no-cache" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#response_directives cacheControlNoCache = "no-cache" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#response_directives
ifModifiedSinceHeader = "If-Modified-Since" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since ifModifiedSinceHeader = "If-Modified-Since" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since
ifNoneMatchHeader = "If-None-Match" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match ifNoneMatchHeader = "If-None-Match" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
eTagHeader = "ETag" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag eTagHeader = "ETag" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
lastModifiedHeader = "Last-Modified" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified lastModifiedHeader = "Last-Modified" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified
cssFA = assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css"
cssAbout = distPathPrefix + "/about.css"
cssIndex = distPathPrefix + "/index.css"
cssStatus = distPathPrefix + "/status.css"
cssThread = distPathPrefix + "/thread.css"
cssProfile = distPathPrefix + "/profile.css"
cssSettings = distPathPrefix + "/settings-style.css"
cssTag = distPathPrefix + "/tag.css"
jsFrontend = distPathPrefix + "/frontend.js" // Progressive enhancement frontend JS.
jsSettings = distPathPrefix + "/settings.js" // Settings panel React application.
) )
type Module struct { type Module struct {
@ -99,7 +108,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
profileGroup.Handle(http.MethodGet, statusPath, m.threadGETHandler) profileGroup.Handle(http.MethodGet, statusPath, m.threadGETHandler)
// Attach individual web handlers which require no specific middlewares // Attach individual web handlers which require no specific middlewares
r.AttachHandler(http.MethodGet, "/", m.baseHandler) // front-page r.AttachHandler(http.MethodGet, "/", m.indexHandler) // front-page
r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler) r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler) r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler) r.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler)

View File

@ -27,7 +27,6 @@ import (
// CreateGinTextContext creates a new gin.Context suitable for a test, with an instantiated gin.Engine. // CreateGinTextContext creates a new gin.Context suitable for a test, with an instantiated gin.Engine.
func CreateGinTestContext(rw http.ResponseWriter, r *http.Request) (*gin.Context, *gin.Engine) { func CreateGinTestContext(rw http.ResponseWriter, r *http.Request) (*gin.Context, *gin.Engine) {
ctx, eng := gin.CreateTestContext(rw) ctx, eng := gin.CreateTestContext(rw)
router.LoadTemplateFunctions(eng)
if err := router.LoadTemplates(eng); err != nil { if err := router.LoadTemplates(eng); err != nil {
panic(err) panic(err)
} }

View File

@ -59,6 +59,5 @@ func NewTestRouter(db db.DB) *router.Router {
// ConfigureTemplatesWithGin will panic on any errors related to template loading during tests // ConfigureTemplatesWithGin will panic on any errors related to template loading during tests
func ConfigureTemplatesWithGin(engine *gin.Engine, templatePath string) { func ConfigureTemplatesWithGin(engine *gin.Engine, templatePath string) {
router.LoadTemplateFunctions(engine)
engine.LoadHTMLGlob(filepath.Join(templatePath, "*")) engine.LoadHTMLGlob(filepath.Join(templatePath, "*"))
} }

View File

@ -1 +1,3 @@
node_modules node_modules
prism.js
prism.css

View File

@ -82,11 +82,11 @@ $button-danger-bg: $error3;
$button-danger-fg: $white1; $button-danger-fg: $white1;
$button-danger-hover-bg: $error2; $button-danger-hover-bg: $error2;
$toot-bg: $gray3; $status-bg: $gray3;
$toot-info-bg: $gray2; $status-info-bg: $gray2;
$toot-focus-bg: $gray5; $status-focus-bg: $gray5;
$toot-focus-info-bg: $gray4; $status-focus-info-bg: $gray4;
$no-img-desc-bg: $orange1; $no-img-desc-bg: $orange1;
$no-img-desc-fg: $gray1; $no-img-desc-fg: $gray1;

39
web/source/css/about.css Normal file
View File

@ -0,0 +1,39 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
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/>.
*/
.about {
display: flex;
flex-direction: column;
gap: 2rem;
padding: 2rem;
background: $bg-accent;
box-shadow: $boxshadow;
border: $boxshadow-border;
border-radius: $br;
.about-section {
ul, ol {
margin-top: 0;
}
h3, h4 {
margin-top: 0;
}
}
}

View File

@ -20,35 +20,52 @@
/* noto-sans-regular - latin */ /* noto-sans-regular - latin */
@font-face { @font-face {
font-family: "Noto Sans"; font-family: "Noto Sans";
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
font-style: normal; font-style: normal;
src: url('../fonts/noto-sans-v27-latin-regular.woff2') format('woff2'), src: url('../fonts/noto-sans-v27-latin-regular.woff2') format('woff2'),
url('../fonts/noto-sans-v27-latin-regular.woff') format('woff'); url('../fonts/noto-sans-v27-latin-regular.woff') format('woff');
} }
/* noto-sans-700 - latin */ /* noto-sans-700 - latin */
@font-face { @font-face {
font-family: "Noto Sans"; font-family: "Noto Sans";
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
font-style: normal; font-style: normal;
src: url('../fonts/noto-sans-v27-latin-700.woff2') format('woff2'), src: url('../fonts/noto-sans-v27-latin-700.woff2') format('woff2'),
url('../fonts/noto-sans-v27-latin-700.woff') format('woff'); url('../fonts/noto-sans-v27-latin-700.woff') format('woff');
} }
/* standard border radius for nice squircles */ /*************************************
***** SECTION 1: HANDY VARIABLES *****
**************************************/
/*
Standard border radius
for nice squircles.
*/
$br: 0.4rem; $br: 0.4rem;
/* border radius for items that are framed/bordered
inside something with $br, eg avatar, header img */ /*
Border radius for items that
are framed/bordered inside
something with $br, eg avatar,
header img, etc.
*/
$br-inner: 0.2rem; $br-inner: 0.2rem;
/* Fork-Awesome 'fa-fw' fixed icon width /*
keep in sync with https://github.com/ForkAwesome/Fork-Awesome/blob/a99579ae3e735ee70e51ed62dfcee3172b5b2db7/css/fork-awesome.css#L50 Fork-Awesome 'fa-fw' fixed icon width;
keep in sync with https://github.com/ForkAwesome/Fork-Awesome/blob/a99579ae3e735ee70e51ed62dfcee3172b5b2db7/css/fork-awesome.css#L50
*/ */
$fa-fw: 1.28571429em; $fa-fw: 1.28571429em;
/******************************************
***** SECTION 2: BASIC GLOBAL STYLING *****
*******************************************/
html, body { html, body {
padding: 0; padding: 0;
margin: 0; margin: 0;
@ -63,90 +80,28 @@ body {
position: relative; position: relative;
} }
.hidden {
display: none;
}
.page {
display: grid;
min-height: 100vh;
grid-template-columns: 1fr minmax(auto, 50rem) 1fr;
grid-template-columns: 1fr min(92%, 50rem) 1fr;
grid-template-rows: auto 1fr auto;
}
h1 {
margin: 0;
line-height: 2.4rem;
}
a { a {
color: $link-fg; color: $link-fg;
} }
header, footer { /*
grid-column: 1 / span 3; Normalize margins of first and last children.
} We generally don't want to open a paragraph or
paragraph-like element with a top margin or
.content { close it with a bottom margin.
grid-column: 2; */
align-self: start;
}
header {
display: flex;
justify-content: center;
}
header a {
display: flex;
flex-wrap: wrap;
margin: 1.5rem;
gap: 1rem;
justify-content: center;
img {
align-self: center;
height: 3rem;
}
h1 {
flex-grow: 1;
align-self: center;
text-align: center;
font-size: 1.5rem;
word-wrap: anywhere;
color: $fg;
}
}
.excerpt-top {
margin-bottom: 2rem;
font-style: italic;
font-weight: normal;
text-align: center;
font-size: 1.2rem;
.count {
font-weight: bold;
color: $fg-accent;
}
}
main { main {
p:first-child { p:first-child, ol:first-child, ul:first-child {
margin-top: 0; margin-top: 0;
} }
p:last-child { p:last-child, ol:last-child, ul:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
} }
.button, button { .button, button {
border-radius: 0.2rem; border-radius: $br-inner;
color: $button-fg; color: $button-fg;
background: $button-bg; background: $button-bg;
box-shadow: $boxshadow; box-shadow: $boxshadow;
@ -184,6 +139,166 @@ main {
} }
} }
/*
Form styling - used in settings frontend as well.
*/
input, select, textarea, .input {
box-sizing: border-box;
border: 0.15rem solid $input-border;
border-radius: 0.1rem;
color: $fg;
background: $input-bg;
width: 100%;
font-family: 'Noto Sans', sans-serif;
font-size: 1rem;
padding: 0.3rem;
&:focus, &:active {
border-color: $input-focus-border;
}
&:invalid, .invalid & {
border-color: $input-error-border;
}
&:disabled {
background: transparent;
}
&::placeholder {
opacity: 1;
color: $fg-reduced
}
}
/*
Squeeze emojis so they fit inline in text.
*/
.emoji {
width: 1.45em;
height: 1.45em;
margin: -0.2em 0.02em 0;
object-fit: contain;
vertical-align: middle;
transition: 0.1s;
/*
Enlarge emojis on hover to give
viewer a good look at them.
*/
&:hover, &:active {
transform: scale(2);
background-color: $bg;
box-shadow: $boxshadow;
border: $boxshadow-border;
border-radius: $br-inner;
}
}
/*
Restyle unordered lists; outdent
and replace dot with orange dot.
*/
ul {
padding-left: 2.5rem;
list-style: none;
li::before {
content: "\2022";
color: $border-accent;
font-weight: bold;
display: inline-block;
width: 1.5rem;
margin-left: -1.5rem;
}
}
/*
Mirror the same styling a little bit
for ordered lists by making marker bold.
*/
ol {
padding-left: 2.5rem;
li::marker {
font-weight: bold;
}
}
/*
Outdent block quotes a bit; use
orange stripe for left border.
*/
blockquote {
padding: 0.5rem 0 0.5rem 0.5rem;
border-left: 0.2rem solid $border-accent;
margin: 0;
font-style: italic;
}
/*
Nice dashed orange line
for horizontal rules.
*/
hr {
border: 0;
border-top: 1px dashed $border-accent;
}
/*
Don't indent definition
lists and definitions.
*/
dl {
margin: 0;
dd {
margin-left: 0;
}
}
label {
cursor: pointer;
}
/*************************************
***** SECTION 3: UTILITY CLASSES *****
**************************************/
/*
Column header that appears at the top
of threads, at the top of sections of
profiles (About, Pinned Posts, etc).
*/
.col-header {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
justify-content: start;
align-items: center;
margin: 0;
background: $profile-bg;
border-top-left-radius: $br;
border-top-right-radius: $br;
padding: 0.75rem;
a {
justify-self: end;
}
h1, h2, h3, h4 {
font-size: 1.2rem;
line-height: 1.3rem;
margin: 0;
}
}
.hidden {
display: none;
}
.nounderline { .nounderline {
text-decoration: none; text-decoration: none;
} }
@ -192,57 +307,37 @@ main {
color: $acc1; color: $acc1;
} }
.logo { .text-cutoff {
justify-self: center; text-overflow: ellipsis;
img { overflow: hidden;
height: 30vh; white-space: nowrap;
}
/*
Class for lists that don't
want the orange dot.
*/
.nodot {
li::before {
content: initial;
} }
} }
section.apps { /***********************************
align-self: start; ***** SECTION 4: SHAMEFUL MESS *****
************************************/
.applist { /*
display: grid; EVERYTHING BELOW THIS POINT:
grid-template-columns: 1fr 1fr; Should be moved somewhere else
grid-gap: 0.5rem; to avoid cluttering up this file.
align-content: start; */
.entry { /*
display: grid; Below section stylings are used
grid-template-columns: 25% 1fr; in transient/error templates.
gap: 1.5rem; */
padding: 0.5rem; section.sign-in {
background: $bg-accent;
border-radius: 0.5rem;
.logo {
align-self: center;
width: 100%;
object-fit: contain;
flex: 1 1 auto;
}
.logo.redraw {
fill: $fg;
stroke: $fg;
}
a {
font-weight: bold;
}
div {
padding: 0;
h3 {
margin-top: 0;
}
}
}
}
}
section.login {
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -291,98 +386,11 @@ section.oob-token {
} }
} }
.error-text { /*
color: $error1; TODO: This is only used in the "finalize"
background: $error2; template for new signups; move this elsewhere
border-radius: 0.1rem; when that stuff is finished up.
font-weight: bold; */
}
input, select, textarea, .input {
box-sizing: border-box;
border: 0.15rem solid $input-border;
border-radius: 0.1rem;
color: $fg;
background: $input-bg;
width: 100%;
font-family: 'Noto Sans', sans-serif;
font-size: 1rem;
padding: 0.3rem;
&:focus, &:active {
border-color: $input-focus-border;
}
&:invalid, .invalid & {
border-color: $input-error-border;
}
&:disabled {
background: transparent;
}
}
::placeholder {
opacity: 1;
color: $fg-reduced
}
hr {
color: transparent;
width: 100%;
border-bottom: 0.02rem solid $border-accent;
}
footer {
align-self: end;
padding: 2rem 0 1rem 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
div {
text-align: center;
padding: 1rem;
flex-grow: 1;
}
a {
font-weight: bold;
}
}
@media screen and (max-width: 600px) {
header {
text-align: center;
}
footer {
grid-template-columns: 1fr;
div {
text-align: initial;
width: 100%;
}
}
section.apps .applist {
grid-template-columns: 1fr;
}
}
.emoji {
width: 1.45em;
height: 1.45em;
margin: -0.2em 0.02em 0;
object-fit: contain;
vertical-align: middle;
}
.monospace {
font-family: monospace;
}
.callout { .callout {
margin: 1.5rem 0; margin: 1.5rem 0;
border: .05rem solid $border-accent; border: .05rem solid $border-accent;
@ -397,22 +405,11 @@ footer {
} }
} }
label { /*
cursor: pointer; TODO: list and blocklist are only used
} in settings panel and on blocklist page;
consider moving them somewhere else.
@media (prefers-reduced-motion) { */
.fa-spin {
animation: none;
}
}
.text-cutoff {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.list { .list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -495,21 +492,18 @@ label {
} }
} }
.about { @media screen and (max-width: 30rem) {
display: flex; .domain-blocklist .entry {
flex-direction: column; grid-template-columns: 1fr;
gap: 1rem; gap: 0;
h2 {
margin: 0.5rem 0;
} }
ul {
margin-bottom: 0;
}
} }
/*
TODO: this is only used on About
page and in settings application;
consider moving it somewhere else.
*/
.account-card { .account-card {
display: inline-grid; display: inline-grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
@ -541,61 +535,3 @@ label {
grid-row: 1 / span 2; grid-row: 1 / span 2;
} }
} }
.instance-rules {
list-style-position: inside;
margin: 0;
padding: 0;
a.rule {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
color: $fg;
text-decoration: none;
background: $toot-bg;
padding: 1rem;
margin: 0.5rem 0;
border-radius: $br;
line-height: 2rem;
position: relative;
&:hover {
color: $fg-accent;
.edit-icon {
display: inline;
}
}
.edit-icon {
display: none;
font-size: 1rem;
line-height: 1.5rem;
}
li {
font-size: 1.75rem;
padding: 0;
margin: 0;
h2 {
margin: 0;
margin-top: 0 !important;
display: inline-block;
font-size: 1.5rem;
}
}
span {
color: $fg-reduced;
}
}
}
@media screen and (max-width: 30rem) {
.domain-blocklist .entry {
grid-template-columns: 1fr;
gap: 0;
}
}

View File

@ -16,26 +16,85 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
header a { /*
margin: 2rem; Render instance title a
gap: 2rem; bit bigger on index page.
*/
img { .page-header a h1 {
height: 6rem; font-size: 2rem;
} line-height: 2rem;
h1 {
font-size: 2rem;
}
} }
main { /*
section { Reuse about styling, but rework it
to separate sections a bit more.
*/
.about {
display: flex;
flex-direction: column;
gap: 2rem;
padding: 0;
background: initial;
box-shadow: initial;
border: initial;
border-radius: initial;
.about-section {
padding: 2rem;
background: $bg-accent; background: $bg-accent;
box-shadow: $boxshadow; box-shadow: $boxshadow;
border: $boxshadow-border; border: $boxshadow-border;
border-radius: $br; border-radius: $br;
padding: 2rem;
margin-bottom: 2rem;
} }
} }
.apps {
align-self: start;
.applist {
margin: 0;
padding: 0;
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 0.5rem;
align-content: start;
.applist-entry {
display: grid;
grid-template-columns: 25% 1fr;
grid-template-areas: "logo text";
gap: 1.5rem;
padding: 0.5rem;
.applist-logo {
grid-area: logo;
align-self: center;
justify-self: center;
width: 100%;
object-fit: contain;
flex: 1 1 auto;
}
.applist-logo.redraw {
fill: $fg;
stroke: $fg;
}
.applist-text {
grid-area: text;
a {
font-weight: bold;
}
}
}
}
}
@media screen and (max-width: 600px) {
.apps .applist {
grid-template-columns: 1fr;
}
}

107
web/source/css/page.css Normal file
View File

@ -0,0 +1,107 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
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/>.
*/
.page {
display: grid;
min-height: 100vh;
grid-template-columns: 1fr minmax(auto, 50rem) 1fr;
grid-template-columns: 1fr min(92%, 50rem) 1fr;
grid-template-rows: auto 1fr auto;
}
.page-header, .page-footer {
grid-column: 1 / span 3;
}
.page-content {
grid-column: 2;
align-self: start;
}
.page-header {
display: flex;
flex-direction: column;
justify-content: center;
padding: 1.5rem;
gap: 1rem;
a {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
img {
align-self: center;
}
h1 {
align-self: center;
text-align: center;
font-size: 1.5rem;
line-height: 1.5rem;
word-wrap: anywhere;
color: $fg;
}
}
aside {
margin: 0;
font-style: italic;
font-weight: normal;
text-align: center;
font-size: 1.2rem;
.count {
font-weight: bold;
color: $fg-accent;
}
}
}
.page-footer {
align-self: end;
nav ul {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
/* Override list styling */
list-style-type: none;
padding-left: 0;
li {
text-align: center;
padding: 1rem;
flex-grow: 1;
a {
font-weight: bold;
}
}
}
}
@media screen and (max-width: 600px) {
.page-header {
text-align: center;
}
}

5
web/source/css/prism.css Normal file
View File

@ -0,0 +1,5 @@
/* PrismJS 1.29.0
https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript+bash+c+csharp+cpp+docker+elixir+erlang+go+go-module+ini+java+json+kotlin+lua+makefile+markup-templating+nginx+nix+perl+php+promql+python+r+jsx+tsx+ruby+rust+scala+sql+swift+typescript&plugins=show-invisibles+show-language+toolbar+copy-to-clipboard */
code[class*=language-],pre[class*=language-]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}
.token.cr,.token.lf,.token.space,.token.tab:not(:empty){position:relative}.token.cr:before,.token.lf:before,.token.space:before,.token.tab:not(:empty):before{color:grey;opacity:.6;position:absolute}.token.tab:not(:empty):before{content:'\21E5'}.token.cr:before{content:'\240D'}.token.crlf:before{content:'\240D\240A'}.token.lf:before{content:'\240A'}.token.space:before{content:'\00B7'}
div.code-toolbar{position:relative}div.code-toolbar>.toolbar{position:absolute;z-index:10;top:.3em;right:.2em;transition:opacity .3s ease-in-out;opacity:0}div.code-toolbar:hover>.toolbar{opacity:1}div.code-toolbar:focus-within>.toolbar{opacity:1}div.code-toolbar>.toolbar>.toolbar-item{display:inline-block}div.code-toolbar>.toolbar>.toolbar-item>a{cursor:pointer}div.code-toolbar>.toolbar>.toolbar-item>button{background:0 0;border:0;color:inherit;font:inherit;line-height:normal;overflow:visible;padding:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}div.code-toolbar>.toolbar>.toolbar-item>a,div.code-toolbar>.toolbar>.toolbar-item>button,div.code-toolbar>.toolbar>.toolbar-item>span{color:#bbb;font-size:.8em;padding:0 .5em;background:#f5f2f0;background:rgba(224,224,224,.2);box-shadow:0 2px 0 0 rgba(0,0,0,.2);border-radius:.5em}div.code-toolbar>.toolbar>.toolbar-item>a:focus,div.code-toolbar>.toolbar>.toolbar-item>a:hover,div.code-toolbar>.toolbar>.toolbar-item>button:focus,div.code-toolbar>.toolbar>.toolbar-item>button:hover,div.code-toolbar>.toolbar>.toolbar-item>span:focus,div.code-toolbar>.toolbar>.toolbar-item>span:hover{color:inherit;text-decoration:none}

View File

@ -17,28 +17,27 @@
*/ */
.page { .page {
grid-template-columns: 1fr minmax(auto, 60rem) 1fr; /* fallback for lack of min() support */ /*
Profile page can be a little wider than default
page, since we're using a side-by-side column view.
*/
grid-template-columns: 1fr minmax(auto, 60rem) 1fr;
grid-template-columns: 1fr min(92%, 65rem) 1fr; grid-template-columns: 1fr min(92%, 65rem) 1fr;
} }
.profile { .profile .column-split {
padding: 0.5rem; display: flex;
border-radius: $br; flex-wrap: wrap;
gap: 1rem;
.column-split {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
} }
.profile .header { .profile .profile-header {
background: $profile-bg; background: $profile-bg;
border-radius: $br; border-radius: $br;
overflow: hidden; overflow: hidden;
margin-bottom: 1rem; margin-bottom: 1rem;
.header-image { .header-image-wrapper {
position: relative; position: relative;
padding-top: 33.33%; /* aspect-ratio 1/3 */ padding-top: 33.33%; /* aspect-ratio 1/3 */
@ -55,12 +54,11 @@
/* /*
Basic info container has the user's avatar, display- and username, and role Basic info container has the user's avatar, display- and username, and role
It's partially overlapped over the header image, by a negative margin-top It's partially overlapped over the header image, by a negative margin-top.
*/ */
$avatar-size: 8.5rem; $avatar-size: 8.5rem;
$name-size: 3rem; $name-size: 3rem;
$username-size: 2rem; $username-size: 2rem;
$overlap: calc($avatar-size - $name-size - $username-size); $overlap: calc($avatar-size - $name-size - $username-size);
.basic-info { .basic-info {
@ -71,8 +69,8 @@
grid-template-rows: $overlap $name-size auto; grid-template-rows: $overlap $name-size auto;
grid-template-areas: grid-template-areas:
"avatar . ." "avatar . ."
"avatar displayname displayname" "avatar namerole namerole"
"avatar username role"; "avatar namerole namerole";
margin: 1rem; margin: 1rem;
margin-top: calc(-1 * $overlap); margin-top: calc(-1 * $overlap);
@ -93,131 +91,119 @@
} }
} }
.displayname { .namerole {
grid-area: displayname; grid-area: namerole;
line-height: $name-size;
font-size: 1.5rem;
font-weight: bold;
}
.username { display: grid;
min-width: 0; gap: 0 1rem;
grid-area: username; box-sizing: border-box;
line-height: $username-size; grid-template-columns: auto 1fr;
grid-template-rows: $name-size auto;
grid-template-areas:
"displayname displayname"
"username role";
font-size: 1rem; .displayname {
font-weight: bold; grid-area: displayname;
color: $fg-accent; line-height: $name-size;
user-select: all; font-size: 1.5rem;
} font-weight: bold;
.role {
background: $bg;
color: $fg;
border: 0.13rem solid $bg;
grid-area: role;
align-self: center;
justify-self: start;
border-radius: $br;
padding: 0.3rem;
line-height: 1.1rem;
font-size: 0.9rem;
font-variant: small-caps;
font-weight: bold;
&.admin {
color: $role-admin;
border-color: $role-admin;
} }
&.moderator { .username {
color: $role-mod; min-width: 0;
border-color: $role-mod; grid-area: username;
line-height: $username-size;
font-size: 1rem;
font-weight: bold;
color: $fg-accent;
user-select: all;
}
.role {
background: $bg;
color: $fg;
border: 0.13rem solid $bg;
grid-area: role;
align-self: center;
justify-self: start;
border-radius: $br;
padding: 0.3rem;
line-height: 1.1rem;
font-size: 0.9rem;
font-variant: small-caps;
font-weight: bold;
&.admin {
color: $role-admin;
border-color: $role-admin;
}
&.moderator {
color: $role-mod;
border-color: $role-mod;
}
} }
} }
} }
} }
@media screen and (max-width: 750px) { @media screen and (max-width: 750px) {
.profile .header { .profile .profile-header {
.basic-info { .basic-info {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
grid-template-rows: $avatar-size $name-size auto; grid-template-rows: $avatar-size $name-size auto;
grid-template-areas: grid-template-areas:
"avatar avatar" "avatar avatar"
"displayname displayname" "namerole namerole"
"username role"; "namerole namerole";
.displayname { .namerole {
font-size: 1.4rem; grid-template-columns: 1fr;
grid-template-rows: $name-size auto;
grid-template-areas:
"displayname displayname"
"username role";
.displayname {
font-size: 1.4rem;
}
} }
} }
} }
} }
.profile .col-header { .profile .statuses-wrapper {
display: flex;
justify-content: start;
gap: 2rem;
align-items: center;
margin: 0;
background: $profile-bg;
border-top-left-radius: $br;
border-top-right-radius: $br;
padding: 0.75rem;
h1, h2 {
font-size: 1.2rem;
line-height: 1.3rem;
margin: 0;
}
}
.profile .toots {
flex: 65 25rem; flex: 65 25rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
min-width: 0%; min-width: 0%;
}
.col-header { .profile .statuses {
display: grid; display: flex;
grid-template-columns: auto 1fr; flex-direction: column;
gap: 1rem; gap: 0.4rem;
a { .rss-icon {
justify-self: end; display: block;
} margin: -0.25rem 0;
.rss-icon { .fa {
display: block; font-size: 2rem;
margin: -0.25rem 0; object-fit: contain;
vertical-align: middle;
.fa { color: $orange2;
font-size: 2rem; /*
object-fit: contain; Can't size a single-color background, so we use
vertical-align: middle; a linear-gradient that's effectively white.
color: $orange2; */
/* can't size a single-color background, so we use a linear-gradient that's effectively white */ background: linear-gradient(to right, $white1 100%, transparent 0) no-repeat center center;
background: linear-gradient(to right, $white1 100%, transparent 0) no-repeat center center; background-size: 1.2rem 1.4rem;
background-size: 1.2rem 1.4rem;
}
}
}
.toot {
border-radius: 0;
.info {
padding: 0.3rem 0.75rem;
}
&:last-child {
border-bottom-left-radius: $br;
border-bottom-right-radius: $br;
} }
} }
@ -240,6 +226,10 @@
margin-bottom: -0.25rem; margin-bottom: -0.25rem;
} }
dt {
font-weight: bold;
}
.fields { .fields {
background: $profile-bg; background: $profile-bg;
display: flex; display: flex;

View File

@ -19,25 +19,19 @@
@import "photoswipe/dist/photoswipe.css"; @import "photoswipe/dist/photoswipe.css";
@import "photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css"; @import "photoswipe-dynamic-caption-plugin/photoswipe-dynamic-caption-plugin.css";
@import "plyr/dist/plyr.css"; @import "plyr/dist/plyr.css";
@import "./prism.css";
main { main {
background: transparent; background: transparent;
grid-auto-rows: auto; grid-auto-rows: auto;
} }
.thread { .status {
display: flex; background: $status-bg;
flex-direction: column;
border-radius: $br;
}
.toot {
background: $toot-bg;
box-shadow: $boxshadow; box-shadow: $boxshadow;
border: $boxshadow-border; border: $boxshadow-border;
border-radius: $br; border-radius: $br;
position: relative; position: relative;
margin-bottom: $br;
padding-top: 0.75rem; padding-top: 0.75rem;
a { a {
@ -47,66 +41,75 @@ main {
text-decoration: none; text-decoration: none;
} }
.author > a { .status-header > address {
padding: 0 0.75rem; /*
display: grid; Avoid stretching so wide that user
grid-template-columns: 3.5rem 1fr auto; can't click on open thread link that's
grid-template-rows: auto auto; behind the status header link.
grid-template-areas: */
"avatar display date" width: fit-content;
"avatar user .";
gap: 0 0.5rem;
.avatar { > a {
grid-area: avatar; padding: 0 0.75rem;
height: 3.5rem; display: grid;
width: 3.5rem; grid-template-columns: 3.5rem 1fr auto;
object-fit: cover; grid-template-rows: auto auto;
grid-template-areas:
border: 0.15rem solid $avatar-border; "avatar author-strap author-strap"
border-radius: $br; "avatar author-strap author-strap";
overflow: hidden; /* hides corners from img overflowing */ gap: 0 0.5rem;
font-style: normal;
img {
height: 100%;
width: 100%;
object-fit: cover;
background: $bg;
}
}
.displayname, .username {
justify-self: start;
align-self: start;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.displayname { .avatar {
grid-area: display; grid-area: avatar;
font-weight: bold; height: 3.5rem;
font-size: 1rem; width: 3.5rem;
line-height: 1.3rem; object-fit: cover;
/* margin-top: -0.5rem; */
} border: 0.15rem solid $avatar-border;
border-radius: $br;
.username { overflow: hidden; /* hides corners from img overflowing */
grid-area: user;
color: $link-fg; img {
font-size: 1rem; height: 100%;
line-height: 1.3rem; width: 100%;
} object-fit: cover;
background: $bg;
.timestamp { }
grid-area: date; }
color: $fg-reduced;
.author-strap {
grid-area: author-strap;
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto;
grid-template-areas:
"display display"
"user user";
gap: 0 0.5rem;
.displayname, .username {
justify-self: start;
align-self: start;
max-width: 100%;
font-size: 1rem;
line-height: 1.3rem;
}
.displayname {
grid-area: display;
font-weight: bold;
}
.username {
grid-area: user;
color: $link-fg;
}
}
} }
} }
.body { .status-body {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -157,6 +160,10 @@ main {
line-height: 1.6rem; line-height: 1.6rem;
width: 100%; width: 100%;
/*
Normalize header sizes to fit better
with the line-height we use for statuses.
*/
h1 { h1 {
margin: 0; margin: 0;
font-size: 1.8rem; font-size: 1.8rem;
@ -187,35 +194,63 @@ main {
line-height: initial; line-height: initial;
} }
blockquote {
padding: 0.5rem 0 0.5rem 0.5rem;
border-left: 0.2rem solid $border-accent;
margin: 0;
font-style: italic;
}
hr {
border: 1px dashed $border-accent;
}
pre, code { pre, code {
background-color: $gray2; background-color: $gray2;
} }
/*
Just code on its own inside status
content, ie, `here is some code`.
*/
code { code {
padding: 0.25rem; padding: 0.25rem;
border-radius: $br-inner; border-radius: $br-inner;
white-space: pre-wrap;
} }
pre { /*
Restyle Prism code highlighting toolbar
plugin buttons to our own button style.
*/
.code-toolbar .toolbar {
margin-right: 0.5rem;
display: flex; 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; border-radius: $br;
padding: 0.5rem; padding: 0.5rem;
white-space: pre;
overflow-x: auto;
code { /*
padding: 0.5rem; Code inside a pre block, ie.,
```
here is some code
```
*/
code {
width: 100%;
padding: 0;
white-space: pre; white-space: pre;
border-radius: 0;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@ -230,18 +265,6 @@ main {
} }
} }
.emoji {
transition: 0.1s;
}
.emoji:hover, .emoji:active {
transform: scale(2);
background-color: $bg;
box-shadow: $boxshadow;
border: $boxshadow-border;
border-radius: $br-inner;
}
.poll { .poll {
background-color: $gray2; background-color: $gray2;
z-index: 2; z-index: 2;
@ -451,41 +474,41 @@ main {
} }
} }
.info { .status-info {
display: flex; background: $status-info-bg;
background: $toot-info-bg;
color: $fg-reduced; color: $fg-reduced;
border-top: 0.15rem solid $toot-info-border; border-top: 0.15rem solid $status-info-border;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
time { .status-stats {
padding-right: 1rem; display: flex;
}
.stats {
display: inline-flex;
flex: 1;
gap: 1rem; gap: 1rem;
.stats-grouping {
display: flex;
flex-wrap: wrap;
column-gap: 1rem;
}
.stats-item { .stats-item {
span { display: flex;
white-space: nowrap; gap: 0.4rem;
} }
.stats-item:not(.published-at) {
z-index: 1;
user-select: none;
} }
.language { .language {
margin-left: auto; margin-left: auto;
z-index: 1;
cursor: pointer;
user-select: none;
} }
} }
grid-column: span 3; grid-column: span 3;
flex-wrap: wrap;
} }
.toot-link { .status-link {
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
@ -508,15 +531,12 @@ main {
/* bottom left, bottom right */ /* bottom left, bottom right */
border-bottom-left-radius: $br; border-bottom-left-radius: $br;
border-bottom-right-radius: $br; border-bottom-right-radius: $br;
margin-bottom: 0;
} }
&.expanded { &.expanded {
background: $toot-focus-bg; background: $status-focus-bg;
padding-bottom: 0; .status-info {
background: $status-focus-info-bg;
.info {
background: $toot-focus-info-bg;
} }
} }
} }

56
web/source/css/thread.css Normal file
View File

@ -0,0 +1,56 @@
/*
GoToSocial
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
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/>.
*/
.thread {
display: flex;
flex-direction: column;
gap: 0.4rem;
/*
This column header might contain
quite some info, so let it wrap.
*/
.col-header {
display: flex;
flex-direction: row;
flex-wrap: wrap;
column-gap: 1rem;
row-gap: 0.5rem;
box-shadow: $boxshadow;
border: $boxshadow-border;
h2 {
margin-right: auto;
}
}
.status {
border-radius: 0;
&:last-child {
border-bottom-left-radius: $br;
border-bottom-right-radius: $br;
.status-info {
border-bottom-left-radius: $br;
border-bottom-right-radius: $br;
}
}
}
}

View File

@ -21,6 +21,10 @@ const Photoswipe = require("photoswipe/dist/umd/photoswipe.umd.min.js");
const PhotoswipeLightbox = require("photoswipe/dist/umd/photoswipe-lightbox.umd.min.js"); const PhotoswipeLightbox = require("photoswipe/dist/umd/photoswipe-lightbox.umd.min.js");
const PhotoswipeCaptionPlugin = require("photoswipe-dynamic-caption-plugin").default; const PhotoswipeCaptionPlugin = require("photoswipe-dynamic-caption-plugin").default;
const Plyr = require("plyr"); const Plyr = require("plyr");
const Prism = require("./prism.js");
Prism.manual = true;
Prism.highlightAll();
let [_, _user, type, id] = window.location.pathname.split("/"); let [_, _user, type, id] = window.location.pathname.split("/");
if (type == "statuses") { if (type == "statuses") {

File diff suppressed because one or more lines are too long

View File

@ -140,18 +140,23 @@ function ReportedToot({ toot }) {
const account = toot.account; const account = toot.account;
return ( return (
<article className="toot expanded"> <article className="status expanded">
<section className="author"> <header className="status-header">
<a> <address>
<img className="avatar" src={account.avatar} alt="" /> <a style={{margin: 0}}>
<span className="displayname"> <img className="avatar" src={account.avatar} alt="" />
{account.display_name.trim().length > 0 ? account.display_name : account.username} <dl className="author-strap">
<span className="sr-only">.</span> <dt className="sr-only">Display name</dt>
</span> <dd className="displayname text-cutoff">
<span className="username">@{account.username}</span> {account.display_name.trim().length > 0 ? account.display_name : account.username}
</a> </dd>
</section> <dt className="sr-only">Username</dt>
<section className="body"> <dd className="username text-cutoff">@{account.username}</dd>
</dl>
</a>
</address>
</header>
<section className="status-body">
<div className="text"> <div className="text">
<div className="content"> <div className="content">
{toot.spoiler_text?.length > 0 {toot.spoiler_text?.length > 0
@ -164,8 +169,17 @@ function ReportedToot({ toot }) {
<TootMedia media={toot.media_attachments} sensitive={toot.sensitive} /> <TootMedia media={toot.media_attachments} sensitive={toot.sensitive} />
} }
</section> </section>
<aside className="info"> <aside className="status-info">
<time dateTime={toot.created_at}>{new Date(toot.created_at).toLocaleString()}</time> <dl class="status-stats">
<div class="stats-grouping">
<div class="stats-item published-at text-cutoff">
<dt class="sr-only">Published</dt>
<dd>
<time dateTime={toot.created_at}>{new Date(toot.created_at).toLocaleString()}</time>
</dd>
</div>
</div>
</dl>
</aside> </aside>
</article> </article>
); );

View File

@ -83,7 +83,7 @@ function ReportEntry({ report }) {
<div className="usernames"> <div className="usernames">
<Username user={from} link={false} /> reported <Username user={target} link={false} /> <Username user={from} link={false} /> reported <Username user={target} link={false} />
</div> </div>
<h3 className="status"> <h3 className="report-status">
{report.action_taken ? "Resolved" : "Open"} {report.action_taken ? "Resolved" : "Open"}
</h3> </h3>
</div> </div>

View File

@ -22,24 +22,29 @@ const React = require("react");
module.exports = function FakeProfile({ avatar, header, display_name, username, role }) { module.exports = function FakeProfile({ avatar, header, display_name, username, role }) {
return ( // Keep in sync with web/template/profile.tmpl return ( // Keep in sync with web/template/profile.tmpl
<div className="profile"> <div className="profile">
<div className="header"> <div className="profile-header">
<div className="header-image"> <div className="header-image-wrapper">
<img src={header} alt={header ? `header image for ${username}` : "None set"} /> <img src={header} alt={header ? `header image for ${username}` : "None set"} />
</div> </div>
<div className="basic-info" aria-hidden="true"> <div className="basic-info" aria-hidden="true">
<a className="avatar" href={avatar}> <a className="avatar" href={avatar}>
<img src={avatar} alt={avatar ? `avatar image for ${username}` : "None set"} /> <img src={avatar} alt={avatar ? `avatar image for ${username}` : "None set"} />
</a> </a>
<span className="displayname text-cutoff"> <dl className="namerole">
{display_name.trim().length > 0 ? display_name : username} <dt className="sr-only">Display name</dt>
<span className="sr-only">.</span> <dd className="displayname text-cutoff">{display_name.trim().length > 0 ? display_name : username}</dd>
</span> <dt className="sr-only">Username</dt>
<span className="username text-cutoff">@{username}</span> <dd className="username text-cutoff">@{username}</dd>
{(role && role.name != "user") && <dt className="sr-only">Role</dt>
<div className={`role ${role.name}`}> {
<span className="sr-only">Role: </span>{role.name} (role && role.name != "user") ?
</div> <>
} <dd className="sr-only">Role</dd>
<dt className={`role ${role.name}`}>{role.name}</dt>
</>
: null
}
</dl>
</div> </div>
</div> </div>
</div> </div>

View File

@ -29,20 +29,27 @@ module.exports = function FakeToot({ children }) {
} } = query.useVerifyCredentialsQuery(); } } = query.useVerifyCredentialsQuery();
return ( return (
<article className="toot expanded"> <article className="status expanded">
<section className="author"> <header className="status-header">
<a> <address>
<img className="avatar" src={account.avatar} alt="" /> <a style={{margin: 0}}>
<span className="displayname"> <img className="avatar" src={account.avatar} alt="" />
{account.display_name.trim().length > 0 ? account.display_name : account.username} <dl className="author-strap">
<span className="sr-only">.</span> <dt className="sr-only">Display name</dt>
</span> <dd className="displayname text-cutoff">
<span className="username">@{account.username}</span> {account.display_name.trim().length > 0 ? account.display_name : account.username}
</a> </dd>
</section> <dt className="sr-only">Username</dt>
<section className="body"> <dd className="username text-cutoff">@{account.username}</dd>
</dl>
</a>
</address>
</header>
<section className="status-body">
<div className="text"> <div className="text">
{children} <div className="content">
{children}
</div>
</div> </div>
</section> </section>
</article> </article>

View File

@ -20,26 +20,14 @@ body {
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
} }
.content { .page-content {
grid-column: 1 / span 3; /* stretch entire width, to fit panel + sidebar nav */ grid-column: 1 / span 3; /* stretch entire width, to fit panel + sidebar nav */
width: 100%; width: 100%;
} }
header { /* Don't inherit orange dot from base.css. */
justify-content: start; ul li::before {
content: initial;
a {
margin: 1.5rem;
gap: 1rem;
h1 {
font-size: 1.5rem;
}
img {
height: 3rem;
}
}
} }
#root { #root {
@ -1007,7 +995,7 @@ button.with-padding {
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
gap: 0.5rem; gap: 0.5rem;
.status { .report-status {
color: $border-accent; color: $border-accent;
} }
} }
@ -1029,7 +1017,7 @@ button.with-padding {
color: $fg-reduced; color: $fg-reduced;
border-left: 0.4rem solid $bg; border-left: 0.4rem solid $bg;
.byline .status { .byline .report-status {
color: $fg-reduced; color: $fg-reduced;
} }
@ -1141,11 +1129,62 @@ button.with-padding {
} }
} }
.instance-rules {
list-style-position: inside;
margin: 0;
padding: 0;
a.rule {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
color: $fg;
text-decoration: none;
background: $status-bg;
padding: 1rem;
margin: 0.5rem 0;
border-radius: $br;
line-height: 2rem;
position: relative;
&:hover {
color: $fg-accent;
.edit-icon {
display: inline;
}
}
.edit-icon {
display: none;
font-size: 1rem;
line-height: 1.5rem;
}
li {
font-size: 1.75rem;
padding: 0;
margin: 0;
h2 {
margin: 0;
margin-top: 0 !important;
display: inline-block;
font-size: 1.5rem;
}
}
span {
color: $fg-reduced;
}
}
}
@media screen and (orientation: portrait) { @media screen and (orientation: portrait) {
.reports .report .byline { .reports .report .byline {
grid-template-columns: 1fr; grid-template-columns: 1fr;
.status { .report-status {
grid-row: 1; grid-row: 1;
} }
} }
@ -1162,4 +1201,14 @@ button.with-padding {
to { to {
opacity: 0; opacity: 0;
} }
} }
@media (prefers-reduced-motion) {
.fa-spin {
animation: none;
}
}
.monospace {
font-family: monospace;
}

View File

@ -17,23 +17,27 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- with . }}
<main> <main>
<section> <section>
<h1>404: Page Not Found</h1> <h1>404: Not Found</h1>
<p> <p>
GoToSocial only serves Public statuses via the web. GoToSocial only serves Public statuses via the web.
If you reached this page by clicking on a status link, </p>
it's possible that the status is not Public, has been <p>
deleted by the author, you don't have permission to see If you reached this page by clicking on a status link,
it, or it just doesn't exist at all. it's likely that the status is not Public. You can try
</p> entering the status URL in your client's search bar,
<p> to view the status from your account. If that doesn't
If you believe this 404 was an error, you can contact work, it's possible that the status has been deleted by
the instance admin. Provide them with the following request the author, you don't have permission to view it, or it
Request ID: <code>{{.requestID}}</code>. doesn't exist at all.
</p> </p>
</section> <p>
If you believe this 404 was an error, you can contact
the instance admin. Provide them with the following
request ID: <code>{{- .requestID -}}</code>.
</p>
</section>
</main> </main>
{{- end }}
{{ template "footer.tmpl" .}}

View File

@ -17,105 +17,133 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- define "description" -}}
<main> {{- if .instance.Description }}
<section class="about"> {{ .instance.Description | noescape }}
<h1>About</h1> {{- else }}
<div> <p>No description has yet been set for this instance.<p>
{{.instance.Description |noescape}} {{- end }}
</div> {{- end -}}
<div> {{- define "registrationLimits" -}}
<h2 id="languages">Languages</h2> {{- if .instance.Registrations -}}
<p> Registration is enabled; new signups can be submitted to this instance.<br/>
{{ if .languages }} {{- if .instance.ApprovalRequired -}}
This instance prefers the following languages: Admin approval is required for new registrations.
<ol> {{- else -}}
{{range .languages}} Admin approval is not required for registrations; new signups will be automatically approved (pending email confirmation).
<li>{{.}}</li> {{- end -}}
{{end}} {{- else -}}
</ol> Registration is disabled; new signups are currently closed for this instance.
{{ else }} {{- end -}}
This instance does not have any preferred languages. {{- end -}}
{{ end }}
</p>
</div>
<div> {{- define "customCSSLimits" -}}
<h2 id="contact">Admin Contact</h2> {{- if .instance.Configuration.Accounts.AllowCustomCSS -}}
{{if .instance.ContactAccount}} Users are allowed to set <a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css/" target="_blank" rel="noopener noreferrer">Custom CSS</a> for their profiles.
<a href="{{.instance.ContactAccount.URL}}" class="account-card"> {{- else -}}
<img class="avatar" src="{{.instance.ContactAccount.Avatar}}" alt="" /> <a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css/" target="_blank" rel="noopener noreferrer">Custom CSS</a> is not enabled for user profiles.
<h3> {{- end -}}
{{if .instance.ContactAccount.DisplayName}}{{emojify .instance.ContactAccount.Emojis (escape .instance.ContactAccount.DisplayName)}}{{else}}{{.instance.ContactAccount.Username}}{{end}} {{- end -}}
</h3>
<span>@{{.instance.ContactAccount.Username}}</span>
</a><br />
{{end}}
{{if .instance.Email}}
Email: <a href="mailto:{{.instance.Email}}">{{.instance.Email}}</a>
{{end}}
</div>
<div> {{- define "statusLimits" -}}
<h2 id="rules">Rules</h2> Statuses can contain up to&nbsp;
<ol> {{- .instance.Configuration.Statuses.MaxCharacters }} characters, and&nbsp;
{{range .instance.Rules}} {{- .instance.Configuration.Statuses.MaxMediaAttachments }} media attachments.
<li>{{.Text}}</li> {{- end -}}
{{end}}
</ol>
</div>
<div> {{- define "pollLimits" -}}
<h2 id="features">Features</h2> Polls can have up to&nbsp;
<ul> {{- .instance.Configuration.Polls.MaxOptions }} options, with&nbsp;
<li> {{- .instance.Configuration.Polls.MaxCharactersPerOption }} characters per option.
Registration is {{- end -}}
{{if .instance.Registrations}}
enabled{{if .instance.ApprovalRequired}}, but requires admin approval{{end}}.
{{else}}
disabled.
{{end}}
</li>
{{if .instance.Configuration.Accounts.AllowCustomCSS}}
<li>
Users are allowed to set <a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css/"
target="_blank" rel="noopener noreferrer">Custom CSS</a> for their profiles.
</li>
{{end}}
<li>
Toots can contain up to {{.instance.Configuration.Statuses.MaxCharacters}} characters and
{{.instance.Configuration.Statuses.MaxMediaAttachments}} media attachments.
</li>
<li>
Polls can have up to {{.instance.Configuration.Polls.MaxOptions}} options, with
{{.instance.Configuration.Polls.MaxCharactersPerOption}} characters each.
</li>
</ul>
</div>
<div> {{- with . }}
<h2 id="moderated-servers">Moderated servers</h2> <main class="about">
<p> <section class="about-section" role="region" aria-labelledby="about">
ActivityPub instances exchange (federate) data with other instances, including accounts and toots. <h3 id="about">About {{ .instance.Title -}}</h3>
This can be prevented for specific domains by suspending them. None of their content is stored, {{- with . }}
and interaction with their users is blocked both ways.</br> {{- include "description" . | indent 2 }}
{{if .blocklistExposed}} {{- end }}
<a href="/about/suspended">View the list of suspended domains</a> </section>
{{else}} <section class="about-section" role="region" aria-labelledby="contact">
This instance does not publically share this list. <h3 id="contact">Admin Contact</h3>
{{end}} {{- if .instance.ContactAccount }}
</p> <a href="{{- .instance.ContactAccount.URL -}}" class="account-card">
</div> <img class="avatar" src="{{- .instance.ContactAccount.Avatar -}}" alt=""/>
<h3>
<div> {{- if .instance.ContactAccount.DisplayName -}}
<h2 id="stats">Instance Statistics</h2> {{- emojify .instance.ContactAccount.Emojis (escape .instance.ContactAccount.DisplayName) -}}
<ul> {{- else -}}
<li>Users: <span class="count">{{.instance.Stats.user_count}}</span></li> {{- .instance.ContactAccount.Username -}}
<li>Posts: <span class="count">{{.instance.Stats.status_count}}</span></li> {{- end -}}
<li>Federates with: <span class="count">{{.instance.Stats.domain_count}}</span> instances</li> </h3>
</ul> <span>@{{- .instance.ContactAccount.Username -}}</span>
</div> </a>
</section> {{- 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>
{{- 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>
{{- end }}
</ol>
{{- else }}
<p>This instance has not yet set any rules.</p>
{{- end }}
</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>
</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>
</section>
</main> </main>
{{ template "footer.tmpl" .}} {{- end }}

View File

@ -17,26 +17,24 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- with . }}
<main> <main>
<form action="/oauth/authorize" method="POST"> <form action="/oauth/authorize" method="POST">
<h1>Hi {{.user}}!</h1> <h1>Hi {{ .user -}}!</h1>
<p> <p>
Application <b>{{.appname}}</b> Application
{{if len .appwebsite | eq 0 | not}} {{- if .appwebsite }}
({{.appwebsite}}) <a href="{{- .appwebsite -}}" rel="nofollow noreferrer noopener" target="_blank">{{- .appname -}}</a>
{{end}} {{- else }}
would like to perform actions on your behalf, with scope <em>{{.scope}}</em>. <b>{{- .appname -}}</b>
</p> {{- end }}
<p>The application will redirect to {{.redirect}} to continue.</p> would like to perform actions on your behalf, with scope
<p> <em>{{- .scope -}}</em>.
<button </p>
type="submit" <p>
style="width:200px;" To continue, the application will redirect to: <code>{{- .redirect -}}</code>
> </p>
Allow <button type="submit" style="width:200px;">Allow</button>
</button> </form>
</p> </main>
</form> {{- end }}
</main>
{{ template "footer.tmpl" .}}

View File

@ -17,12 +17,11 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- with . }}
<main> <main>
<section> <section>
<h1>Email Address Confirmed</h1> <h1>Email Address Confirmed</h1>
<p>Thanks {{.username}}! Your email address <b>{{.email}}</b> has been confirmed.<p> <p>Thanks {{ .username -}}! Your email address <b>{{- .email -}}</b> has been confirmed.<p>
</section> </section>
</main> </main>
{{- end }}
{{ template "footer.tmpl" .}}

View File

@ -17,36 +17,36 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- with . }}
<main> <main>
<section> <section>
<h1>Suspended Instances</h1> <h1>Suspended Instances</h1>
<p> <p>
The following list of domains have been suspended by the administrator(s) of this server. The following list of domains have been suspended
</p> by the administrator(s) of this server.
<p> </p>
All current and future accounts on these instances are blocked, and no more data is federated to the remote <p>
servers. All current and future accounts on these instances are
This extends to subdomains, so an entry for 'example.com' includes 'social.example.com' as well. blocked, and no more data is federated to the remote servers.
</p> This extends to subdomains, so an entry for 'example.com'
<div class="list domain-blocklist"> includes 'social.example.com' as well.
<div class="header entry"> </p>
<div class="domain">Domain</div> <div class="list domain-blocklist">
<div class="public_comment">Public comment</div> <div class="header entry">
</div> <div class="domain">Domain</div>
{{range .blocklist}} <div class="public_comment">Public comment</div>
<div class="entry" id="{{.Domain}}"> </div>
<div class="domain"> {{- range .blocklist }}
<a class="text-cutoff" href="#{{.Domain}}" title="{{.Domain}}">{{.Domain}}</a> <div class="entry" id="{{- .Domain -}}">
</div> <div class="domain">
<div class="public_comment"> <a class="text-cutoff" href="#{{- .Domain -}}" title="{{- .Domain -}}">{{- .Domain -}}</a>
<p> </div>
{{.PublicComment}} <div class="public_comment">
</p> <p>{{- .PublicComment -}}</p>
</div> </div>
</div> </div>
{{end}} {{- end }}
</div> </div>
</section> </section>
</main> </main>
{{ template "footer.tmpl" .}} {{- end }}

View File

@ -17,16 +17,16 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- with . }}
<main> <main>
<section class="error"> <section class="error">
<h1>An error occured:</h1> <h1>An error occured:</h1>
<pre>{{.error}}</pre> <pre>{{- .error -}}</pre>
{{if .requestID}} {{- if .requestID }}
<div> <div>
<span>Request ID:</span> <code>{{.requestID}}</code> <span>Request ID:</span> <code>{{- .requestID -}}</code>
</div> </div>
{{end}} {{- end }}
</section> </section>
</main> </main>
{{ template "footer.tmpl" .}} {{- end }}

View File

@ -17,34 +17,31 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- with . }}
<main> <main>
<form action="/oauth/finalize" method="POST"> <form action="/oauth/finalize" method="POST">
<h1>Hi {{.name}}!</h1> <h1>Hi {{ .name -}}!</h1>
<p> <p>
You are about to sign-up to {{ .instance.Title }} (<code>{{ .instance.URI }}</code>) You are about to sign-up to {{ .instance.Title -}}.
<br> To ensure the best experience for you, we need you to provide some additional details.
To ensure the best experience for you, we need you to provide some additional details. </p>
</p> <div class="callout">
{{if .error}} <p class="callout-title">Important</p>
<section class="error"> <p>Due to the way the ActivityPub standard works, you <strong>cannot</strong> change your username after it has been set.</p>
<span>❌</span> <pre>{{.error}}</pre> </div>
</section> <div class="labelinput">
{{end}} <label for="username">Username <small>(must contain only lowercase letters, numbers, and underscores)</small></label>
<div class="callout"> <input
<p class="callout-title">Important</p> type="text"
<p>Due to the way the ActivityPub standard works, you <strong>cannot</strong> change your username after it has been set.</p> class="form-control"
</div> name="username"
<div class="labelinput"> required
<label for="username">Username <small>(must contain only lowercase letters, numbers, and underscores)</small></label> placeholder="Please enter your desired username"
<input type="text" value="{{- .preferredUsername -}}"
class="form-control" >
name="username" </div>
required <input type="hidden" name="name" value="{{- .name -}}">
placeholder="Please enter your desired username" value="{{ .preferredUsername }}"> <button type="submit" style="width: 100%; margin-top: 1rem;" class="btn btn-success">Submit</button>
</div> </form>
<input type="hidden" name="name" value="{{ .name }}"> </main>
<button type="submit" style="width: 100%; margin-top: 1rem;" class="btn btn-success">Submit</button> {{- end }}
</form>
</main>
{{ template "footer.tmpl" .}}

View File

@ -17,9 +17,8 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- with . }}
<main class="lightgray"> <main class="lightgray">
<div id="root"> <div id="root"></div>
</div>
</main> </main>
{{ template "footer.tmpl" .}} {{- end }}

View File

@ -1,122 +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/>.
*/ -}}
{{- /*
NESTED TEMPLATE DECLARATIONS
If some if/else macro is used multiple times, declare it once here instead.
When invoking these nested templates, remember to pass in the values passed
to the executing template, ie., use '{{ template "example" . }}' not
'{{ template "example" }}', otherwise you'll end up with empty variables.
*/ -}}
{{ define "thumbnailType" }}{{ if .instance.ThumbnailType }}{{ .instance.ThumbnailType }}{{ else }}image/png{{ end }}{{ end }}
{{ define "instanceTitle" }}{{ if .ogMeta }}{{ .ogMeta.Title }}{{ else }}{{ .instance.Title }} - GoToSocial{{ end }}{{ end }}
{{- /*
BOILERPLATE GOES HERE
*/ -}}
<!DOCTYPE html>
<!-- header.tmpl -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{- /*
ROBOTS META TAGS
If this template was provided with a specific robots meta policy, use that.
Otherwise, fall back to a default restrictive policy.
See: https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag
*/ -}}
<meta name="robots" content="{{ if .robotsMeta }}{{ .robotsMeta }}{{ else }}noindex, nofollow{{ end }}">
{{- /*
OPEN GRAPH META TAGS
To enable fancy previews of links to GtS posts/profiles shared via instant
messaging, or other social media, parse out provided Open Graph meta tags.
*/ -}}
{{ if .ogMeta -}}
{{ if .ogMeta.Locale }}<meta name="og:locale" content="{{ .ogMeta.Locale }}">{{ end }}
<meta property="og:type" content="{{ .ogMeta.Type }}">
<meta property="og:title" content="{{ .ogMeta.Title }}">
<meta property="og:url" content="{{ .ogMeta.URL }}">
<meta property="og:site_name" content="{{ .ogMeta.SiteName }}">
<meta property="og:description" {{ .ogMeta.Description | noescapeAttr }}>
{{ if .ogMeta.ArticlePublisher }}
<meta property="og:article:publisher" content="{{ .ogMeta.ArticlePublisher }}">
<meta property="og:article:author" content="{{ .ogMeta.ArticleAuthor }}">
<meta property="og:article:modified_time" content="{{ .ogMeta.ArticleModifiedTime }}">
<meta property="og:article:published_time" content="{{ .ogMeta.ArticlePublishedTime }}">
{{ end }}
{{ if .ogMeta.ProfileUsername }}<meta property="og:profile:username" content="{{ .ogMeta.ProfileUsername }}">{{ end }}
<meta property="og:image" content="{{ .ogMeta.Image }}">
{{ if .ogMeta.ImageAlt }}<meta property="og:image:alt" content="{{ .ogMeta.ImageAlt }}">{{ end }}
{{ if .ogMeta.ImageWidth }}
<meta property="og:image:width" content="{{ .ogMeta.ImageWidth }}">
<meta property="og:image:height" content="{{ .ogMeta.ImageHeight }}">
{{ end }}
{{- end }}
{{- /*
ICON
For icon, provide a link to the instance thumbnail. If the instance admin has
set a custom thumbnail, use the type they uploaded, else assume image/png.
See: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel#icon
*/ -}}
<link rel="icon" href="{{ .instance.Thumbnail }}" type="{{ template "thumbnailType" . }}">
<link rel="apple-touch-icon" href="{{ .instance.Thumbnail }}" type="{{ template "thumbnailType" . }}">
<link rel="apple-touch-startup-image" href="{{ .instance.Thumbnail }}" type="{{ template "thumbnailType" . }}">
{{- /*
RSS FEED
To enable automatic rss feed discovery for feed readers, provide the 'alternate'
link only if rss is enabled.
See: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel#alternate
*/ -}}
{{ if .rssFeed -}}
<link rel="alternate" type="application/rss+xml" href="{{ .rssFeed }}" title="{{ template "instanceTitle" . }}">
{{- end }}
{{- /*
STYLESHEET STUFF
To try to speed up rendering a little bit, offer a preload for each stylesheet.
See: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preload.
*/ -}}
<link rel="preload" href="/assets/dist/_colors.css" as="style">
<link rel="preload" href="/assets/dist/base.css" as="style">
{{ range .stylesheets }}<link rel="preload" href="{{ . }}" as="style">{{ end }}
<link rel="stylesheet" href="/assets/dist/_colors.css">
<link rel="stylesheet" href="/assets/dist/base.css">
{{ range .stylesheets }}<link rel="stylesheet" href="{{ . }}">{{ end }}
<title>{{ template "instanceTitle" . }}</title>
</head>
<body>
<div class="page">
<header>
<a aria-label="{{ .instance.Title }}. Go to instance homepage" href="/" class="nounderline header">
<img src="{{ .instance.Thumbnail }}"
alt="{{ if .instance.ThumbnailDescription }}{{ .instance.ThumbnailDescription }}{{ else }}Instance Logo{{ end }}" />
<h1>
{{ .instance.Title }}
</h1>
</a>
</header>
<div class="content">

View File

@ -17,61 +17,21 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- define "shortDescription" -}}
<section class="excerpt-top"> {{- if .instance.ShortDescription }}
home to <span class="count">{{.instance.Stats.user_count}}</span> users {{ .instance.ShortDescription | noescape }}
who posted <span class="count">{{.instance.Stats.status_count}}</span> statuses, {{- else }}
federating with <span class="count">{{.instance.Stats.domain_count}}</span> other instances. <p>No short description has yet been set for this instance.<p>
</section> {{- end }}
<main class="lightgray"> {{- end -}}
<section>
<div className="short-description"> {{- with . }}
{{.instance.ShortDescription |noescape}} <main class="about">
</div> <section class="about-section" role="region" aria-labelledby="about">
</section> <h3 id="about">About this instance</h3>
<section class="apps"> {{- include "shortDescription" . | indent 2 }}
<p> <a href="/about">See more details</a>
GoToSocial does not provide its own webclient, but implements the Mastodon client API. </section>
You can use this server through a variety of other clients: {{- include "index_apps.tmpl" . | indent 1 }}
</p>
<div class="applist">
<div class="entry">
<svg role="img" aria-labelledby="semaphoreTitle semaphoreDesc" class="logo redraw" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 146 120">
<title id="semaphoreTitle">The Semaphore logo</title>
<desc id="semaphoreDesc">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>
<div>
<h2>Semaphore</h2>
<p>Semaphore is a web client designed for speed and simplicity.</p>
<a href="https://semaphore.social/" target="_blank" rel="noopener">Use Semaphore</a>
</div>
</div>
<div class="entry">
<img class="logo" src="/assets/tusky.svg" alt="The Tusky mascot, a cartoon elephant tooting happily"/>
<div>
<h2>Tusky</h2>
<p>Tusky is a lightweight mobile client for Android.</p>
<a href="https://tusky.app" target="_blank" rel="noopener">Get Tusky</a>
</div>
</div>
<div class="entry">
<img class="logo" src="/assets/feditext.svg" alt="The Feditext logo, the characters ft at a slight angle">
<div>
<h2>Feditext</h2>
<p>Feditext (beta) is a beautiful client for iOS, iPadOS and macOS.</p>
<a href="https://fedi.software/@Feditext" target="_blank" rel="noopener">Get Feditext</a>
</div>
</div>
<div class="entry">
<img class="logo" src="/assets/mastodon.svg" alt="The Mastodon logo, the character M in a speech bubble">
<div>
<h2>More clients</h2>
<p>Or try one of the clients listed on the official Mastodon page.</p>
<a href="https://joinmastodon.org/apps" target="_blank" rel="noopener">Get Mastodon apps</a>
</div>
</div>
</div>
</section>
</main> </main>
{{ template "footer.tmpl" .}} {{- end }}

View File

@ -0,0 +1,115 @@
{{- /*
// 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/>.
*/ -}}
{{- 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"
>
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>
</section>
{{- end }}

View File

@ -17,12 +17,12 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- with . }}
<main> <main>
<section class="oob-token"> <section class="oob-token">
<h1>Hi {{ .user }}!</h1> <h1>Hi {{ .user -}}!</h1>
<p>Here's your out-of-band token with scope "<em>{{.scope}}</em>", use it wisely:</p> <p>Here's your out-of-band token with scope "<em>{{- .scope -}}</em>", use it wisely:</p>
<code>{{ .oobToken }}</code> <code>{{- .oobToken -}}</code>
</section> </section>
</main> </main>
{{ template "footer.tmpl" .}} {{- end }}

85
web/template/page.tmpl Normal file
View File

@ -0,0 +1,85 @@
{{- /*
// 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/>.
*/ -}}
{{- /*
NESTED TEMPLATE DECLARATIONS
If some if/else macro is used multiple times, declare it once here instead.
When invoking these nested templates, remember to pass in the values passed
to the executing template, ie., use '{{ template "example" . }}' not
'{{ template "example" }}', otherwise you'll end up with empty variables.
*/ -}}
{{- define "thumbnailType" -}}
{{- if .instance.ThumbnailType -}}
{{- .instance.ThumbnailType -}}
{{- else -}}
image/png
{{- end -}}
{{- end -}}
{{- define "instanceTitle" -}}
{{- if .ogMeta -}}
{{- demojify .ogMeta.Title | noescape -}}
{{- else -}}
{{- .instance.Title }} - GoToSocial
{{- end -}}
{{- end -}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="{{- if .robotsMeta -}}{{- .robotsMeta -}}{{- else -}}noindex, nofollow{{- end -}}">
{{- if .ogMeta }}
{{- include "page_ogmeta.tmpl" . | indent 2 }}
{{- else }}
{{- end }}
{{- if .rssFeed }}
<link rel="alternate" type="application/rss+xml" href="{{- .rssFeed -}}" title="{{- template "instanceTitle" . -}}">
{{- else }}
{{- end }}
{{- if .account }}
<link rel="alternate" type="application/activity+json" href="/users/{{- .account.Username -}}">
{{- else if .status }}
<link rel="alternate" type="application/activity+json" href="/users/{{- .status.Account.Username -}}/statuses/{{- .status.ID -}}">
{{- else }}
{{- end }}
<link rel="icon" href="{{- .instance.Thumbnail -}}" type="{{- template "thumbnailType" . -}}">
<link rel="apple-touch-icon" href="{{- .instance.Thumbnail -}}" type="{{- template "thumbnailType" . -}}">
<link rel="apple-touch-startup-image" href="{{- .instance.Thumbnail -}}" type="{{- template "thumbnailType" . -}}">
{{- include "page_stylesheets.tmpl" . | indent 2 }}
{{- range .javascript }}
<script type="text/javascript" src="{{- . -}}" async="" defer=""></script>
{{- end }}
<title>{{- template "instanceTitle" . -}}</title>
</head>
<body class="page">
<header class="page-header">
{{- include "page_header.tmpl" . | indent 3 }}
</header>
<div class="page-content">
{{- include .pageContent . | indent 3 | outdentPre }}
</div>
<footer class="page-footer">
{{- include "page_footer.tmpl" . | indent 3 }}
</footer>
</body>
</html>

View File

@ -0,0 +1,67 @@
{{- /*
// 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/>.
*/ -}}
{{- with . }}
<nav>
<ul class="nodot">
<li id="about">
<a
href="/about"
class="nounderline"
>
About {{ .instance.Title }}
</a>
</li>
<li id="version">
<a
href="https://github.com/superseriousbusiness/gotosocial"
class="nounderline"
rel="nofollow noreferrer noopener"
target="_blank"
>
<span aria-hidden="true">🦥</span>
Source - GoToSocial {{ .instance.Version }}
<span aria-hidden="true">🦥</span>
</a>
</li>
{{- if .instance.ContactAccount }}
<li id="contact">
<a
href="/@{{- .instance.ContactAccount.Username -}}"
class="nounderline"
>
Contact account - {{ .instance.ContactAccount.Username }}
</a>
</li>
{{- end }}
{{- if .instance.Email }}
<li id="email">
<a
href="mailto:{{- .instance.Email -}}"
class="nounderline"
rel="nofollow noreferrer noopener"
target="_blank"
>
Email - {{ .instance.Email }}
</a>
</li>
{{- end }}
</ul>
</nav>
{{- end }}

View File

@ -0,0 +1,72 @@
{{- /*
// 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/>.
*/ -}}
{{- define "thumbnailDescription" -}}
{{- if .instance.ThumbnailDescription -}}
{{- .instance.ThumbnailDescription -}}
{{- else -}}
Instance Logo
{{- end -}}
{{- end -}}
{{- define "strapUsers" -}}
{{- with .instance.Stats.user_count -}}
{{- if eq . 1 -}}
<span class="count">{{- . -}}</span> user
{{- else -}}
<span class="count">{{- . -}}</span> users
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "strapPosts" -}}
{{- with .instance.Stats.status_count -}}
{{- if eq . 1 -}}
<span class="count">{{- . -}}</span> post
{{- else -}}
<span class="count">{{- . -}}</span> posts
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "strapInstances" -}}
{{- with .instance.Stats.domain_count -}}
{{- if eq . 1 -}}
<span class="count">{{- . -}}</span> other instance
{{- else -}}
<span class="count">{{- . -}}</span> other instances
{{- end -}}
{{- end -}}
{{- end -}}
{{- with . }}
<a aria-label="{{- .instance.Title -}}. Go to instance homepage" href="/" class="nounderline">
<img
src="{{- .instance.Thumbnail -}}"
alt="{{- template "thumbnailDescription" . -}}"
title="{{- template "thumbnailDescription" . -}}"
width="100"
height="100"
/>
<h1>{{- .instance.Title -}}</h1>
</a>
{{- if .showStrap }}
<aside>home to {{ template "strapUsers" . }} who wrote {{ template "strapPosts" . }}, federating with {{ template "strapInstances" . }}</aside>
{{- end }}
{{- end }}

View File

@ -0,0 +1,57 @@
{{- /*
// 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/>.
*/ -}}
{{- /*
OPEN GRAPH META TAGS
To enable fancy previews of links to GtS posts/profiles shared via instant
messaging, or other social media, parse out provided Open Graph meta tags.
*/ -}}
{{- with .ogMeta }}
{{- if .Locale }}
<meta name="og:locale" content="{{- .Locale -}}">
{{- else }}
{{- end }}
<meta property="og:type" content="{{- .Type -}}">
<meta property="og:title" content="{{- demojify .Title | noescape -}}">
<meta property="og:url" content="{{- .URL -}}">
<meta property="og:site_name" content="{{- .SiteName -}}">
<meta property="og:description" {{ demojify .Description | noescapeAttr -}}>
{{- if .ArticlePublisher }}
<meta property="og:article:publisher" content="{{ .ArticlePublisher }}">
<meta property="og:article:author" content="{{ .ArticleAuthor }}">
<meta property="og:article:modified_time" content="{{ .ArticleModifiedTime }}">
<meta property="og:article:published_time" content="{{ .ArticlePublishedTime }}">
{{- else }}
{{- end }}
{{- if .ProfileUsername }}
<meta property="og:profile:username" content="{{- .ProfileUsername -}}">
{{- else }}
{{- end }}
<meta property="og:image" content="{{- .Image -}}">
{{- if .ImageAlt }}
<meta property="og:image:alt" content="{{- .ImageAlt -}}">
{{- else }}
{{- end }}
{{- if .ImageWidth }}
<meta property="og:image:width" content="{{ .ImageWidth }}">
<meta property="og:image:height" content="{{ .ImageHeight }}">
{{- else }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,41 @@
{{- /*
// 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/>.
*/ -}}
{{- /*
Order of stylesheet loading is important: _colors and base should always be loaded
before any other provided sheets, since the latter cascade from the former.
To try to speed up rendering a little bit, offer a preload for each stylesheet.
See: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preload.
*/ -}}
{{- with . }}
<link rel="preload" href="/assets/dist/_colors.css" as="style">
<link rel="preload" href="/assets/dist/base.css" as="style">
<link rel="preload" href="/assets/dist/page.css" as="style">
{{- range .stylesheets }}
<link rel="preload" href="{{- . -}}" as="style">
{{- end }}
<link rel="stylesheet" href="/assets/dist/_colors.css">
<link rel="stylesheet" href="/assets/dist/base.css">
<link rel="stylesheet" href="/assets/dist/page.css">
{{- range .stylesheets }}
<link rel="stylesheet" href="{{- . -}}">
{{- end }}
{{- end }}

View File

@ -17,129 +17,123 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- with . }}
<main class="profile"> <main class="profile">
<div class="header"> <h2 class="sr-only">Profile for {{ .account.Username -}}</h2>
<div class="header-image"> <section class="profile-header" role="region" aria-label="Basic info">
{{ if .account.Header }} <div class="header-image-wrapper">
<img src="{{.account.Header}}" alt="" /> <img
{{ end }} src="{{- .account.Header -}}"
</div> alt="Header for {{ .account.Username -}}"
<div class="basic-info" aria-hidden="true"> title="Header for {{ .account.Username -}}"
<a class="avatar" href="{{.account.Avatar}}"> />
<img src="{{.account.Avatar}}" alt=""> </div>
</a> <div class="basic-info">
<span class="displayname text-cutoff"> <a class="avatar" href="{{- .account.Avatar -}}">
{{if .account.DisplayName}} <img
{{emojify .account.Emojis (escape .account.DisplayName)}} src="{{- .account.Avatar -}}"
{{else}} alt="Avatar for {{ .account.Username -}}"
{{.account.Username}} title="Avatar for {{ .account.Username -}}"
{{end}} />
</span> </a>
<span class="username text-cutoff">@{{.account.Username}}@{{.instance.AccountDomain}}</span> <dl class="namerole">
{{- /* Only render account role if 1. it's present and 2. it's not equal to the standard 'user' role */ -}} <dt class="sr-only">Display name</dt>
{{ if and (.account.Role) (ne .account.Role.Name "user") }} <dd class="displayname text-cutoff">
<div class="role {{ .account.Role.Name }}"> {{- if .account.DisplayName -}}
{{ .account.Role.Name }} {{- emojify .account.Emojis (escape .account.DisplayName) -}}
</div> {{- else -}}
{{ end }} {{- .account.Username -}}
</div> {{- end -}}
<div class="sr-only"> </dd>
Profile for <dt class="sr-only">Username</dt>
{{if .account.DisplayName}}{{.account.DisplayName}}{{else}}{{.account.Username}}{{end}}. <dd class="username text-cutoff">@{{- .account.Username -}}@{{- .instance.AccountDomain -}}</dd>
Username @{{.account.Username}}, {{.instance.AccountDomain}}. {{- if and (.account.Role) (ne .account.Role.Name "user") }}
{{ if and (.account.Role) (ne .account.Role.Name "user") }} <dt class="sr-only">Role</dt>
Role: {{ .account.Role.Name }} <dd class="role {{ .account.Role.Name -}}">{{- .account.Role.Name -}}</dd>
{{ end }} {{- end }}
</div> </dl>
</div> </div>
</section>
<div class="column-split"> <div class="column-split">
<section class="about-user" role="region" aria-labelledby="about-header">
<section class="about-user"> <div class="col-header">
<div class="col-header"> <h3 id="about-header">About<span class="sr-only">&nbsp;{{- .account.Username -}}</span></h3>
<h1>About</h1> </div>
</div> {{- if .account.Fields }}
{{- include "profile_fields.tmpl" . | indent 3 }}
<div class="fields"> {{- end }}
{{ range .account.Fields }} <h4 class="sr-only">Bio</h4>
<div class="field"> <div class="bio">
<b>{{emojify $.account.Emojis (noescape .Name)}}</b> {{- if .account.Note }}
<span>{{emojify $.account.Emojis (noescape .Value)}}</span> {{ emojify .account.Emojis (noescape .account.Note) }}
</div> {{- else }}
{{ end }} <p>This GoToSocial user hasn't written a bio yet!</p>
</div> {{- end }}
</div>
<div class="bio"> <h4 class="sr-only">Stats</h4>
{{ if .account.Note }} <dl class="accountstats">
{{emojify .account.Emojis (noescape .account.Note)}} <dt>Joined</dt>
{{else}} <dd><time datetime="{{- .account.CreatedAt -}}">{{- .account.CreatedAt | timestampVague -}}</time></dd>
This GoToSocial user hasn't written a bio yet! <dt>Posts</dt>
{{end}} <dd>{{- .account.StatusesCount -}}</dd>
</div> <dt>Followed by</dt>
<dd>{{- .account.FollowersCount -}}</dd>
<div class="sr-only" role="group"> <dt>Following</dt>
<span>Joined on {{.account.CreatedAt | timestampVague}}.</span> <dd>{{- .account.FollowingCount -}}</dd>
<span>{{.account.StatusesCount}} post{{if .account.StatusesCount | eq 1 | not}}s{{end}}.</span> </dl>
<span>Followed by {{.account.FollowersCount}}.</span> </section>
<span>Following {{.account.FollowingCount}}.</span> <div class="statuses-wrapper" role="region" aria-label="Posts by {{ .account.Username -}}">
</div> {{- if .pinned_statuses }}
<section class="pinned statuses" aria-labelledby="pinned">
<div class="accountstats" aria-hidden="true"> <div class="col-header">
<b>Joined</b><time datetime="{{.account.CreatedAt}}">{{.account.CreatedAt | timestampVague}}</time> <h3 id="pinned">Pinned posts</h3>
<b>Posts</b><span>{{.account.StatusesCount}}</span> <a href="#recent">jump to recent</a>
<b>Followed by</b><span>{{.account.FollowersCount}}</span> </div>
<b>Following</b><span>{{.account.FollowingCount}}</span> <div class="thread">
</div> {{- range .pinned_statuses }}
</section> <article
class="status expanded"
<section class="toots"> {{- includeAttr "status_attributes.tmpl" . | indentAttr 6 }}
{{ if .pinned_statuses }} >
<div class="col-header"> {{- include "status.tmpl" . | indent 6 }}
<h2>Pinned posts</h2> </article>
<a href="#recent">jump to recent</a> {{- end }}
</div> </div>
<section class="thread"> </section>
{{ range .pinned_statuses }} {{- end }}
<article class="toot expanded" id="{{.ID}}"> <section class="recent statuses" aria-labelledby="recent">
{{ template "status.tmpl" .}} <div class="col-header">
</article> <h3 id="recent" tabindex="-1">Recent posts</h3>
{{ end }} {{- if .rssFeed }}
</section> <a href="{{- .rssFeed -}}" class="rss-icon" aria-label="RSS feed">
{{ end }} <i class="fa fa-rss-square" aria-hidden="true"></i>
</a>
<div class="col-header"> {{- end }}
<h2 id="recent" tabindex="-1">Recent posts</h2> </div>
{{ if .rssFeed }} <div class="thread">
<a href="{{ .rssFeed }}" class="rss-icon" aria-label="RSS feed"> {{- if not .statuses }}
<i class="fa fa-rss-square" aria-hidden="true"></i> <div data-nosnippet class="nothinghere">Nothing here!</div>
</a> {{- else }}
{{ end }} {{- range .statuses }}
</div> <article
class="status expanded"
<section class="thread"> {{- includeAttr "status_attributes.tmpl" . | indentAttr 6 }}
{{ if not .statuses }} >
<div data-nosnippet class="nothinghere">Nothing here!</div> {{- include "status.tmpl" . | indent 6 }}
{{ else }} </article>
{{ range .statuses }} {{- end }}
<article class="toot expanded" id="{{.ID}}"> {{- end }}
{{ template "status.tmpl" .}} </div>
</article> <nav class="backnextlinks">
{{ end }} {{- if .show_back_to_top }}
{{ end }} <a href="/@{{- .account.Username -}}">Back to top</a>
</section> {{- end }}
{{- if .statuses_next }}
<div class="backnextlinks"> <a href="{{- .statuses_next -}}" class="next">Show older</a>
{{ if .show_back_to_top }} {{- end }}
<a href="/@{{ .account.Username }}">Back to top</a> </nav>
{{ end }} </section>
{{ if .statuses_next }} </div>
<a href="{{ .statuses_next }}" class="next">Show older</a> </div>
{{ end }}
</div>
</section>
</div>
</main> </main>
{{- end }}
{{ template "footer.tmpl" .}}

View File

@ -17,30 +17,16 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
<!-- footer.tmpl --> {{- with . }}
</div> <div class="fields">
<footer> <h4 class="sr-only">Fields</h4>
<div id="version"> <dl>
<a name="Source code" href="https://github.com/superseriousbusiness/gotosocial"> {{- range .account.Fields }}
GoToSocial <span class="accent">{{.instance.Version}}</span> <div class="field">
</a> <dt>{{- emojify $.account.Emojis (noescape .Name) -}}</dt>
</div> <dd>{{- emojify $.account.Emojis (noescape .Value) -}}</dd>
{{ if .instance.ContactAccount }} </div>
<div id="contact"> {{- end }}
Contact: <a href="{{.instance.ContactAccount.URL}}" class="nounderline">{{.instance.ContactAccount.Username}}</a><br> </dl>
</div> </div>
{{ end }} {{- end }}
{{ if .instance.Email }}
<div id="email">
Email: <a href="mailto:{{.instance.Email}}" class="nounderline">{{.instance.Email}}</a><br>
</div>
{{ end }}
</footer>
</div>
{{ if .javascript }}
{{ range .javascript }}
<script src="{{.}}"></script>
{{ end }}
{{ end }}
</body>
</html>

View File

@ -17,10 +17,10 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- with . }}
<main> <main>
<section class="login"> <section class="sign-in" aria-labelledby="sign-in">
<h1>Login</h1> <h2 id="sign-in">Sign in</h2>
<form action="/auth/sign_in" method="POST"> <form action="/auth/sign_in" method="POST">
<div class="labelinput"> <div class="labelinput">
<label for="email">Email</label> <label for="email">Email</label>
@ -30,8 +30,8 @@
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" class="form-control" name="password" required placeholder="Please enter your password"> <input type="password" class="form-control" name="password" required placeholder="Please enter your password">
</div> </div>
<button type="submit" class="btn btn-success">Login</button> <button type="submit" class="btn btn-success">Sign in</button>
</form> </form>
</section> </section>
</main> </main>
{{ template "footer.tmpl" .}} {{- end }}

View File

@ -17,88 +17,74 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
<a data-nosnippet href="{{- .URL -}}" class="toot-link">Open thread</a> {{- define "statusContent" -}}
<section class="author"> {{- with .Content }}
<a href="{{- .Account.URL -}}"> <div class="content" lang="{{- $.LanguageTag.TagStr -}}">
<img class="avatar" src="{{- .Account.Avatar -}}" alt=""> {{ noescape . | emojify $.Emojis }}
<span aria-hidden="true" class="displayname"> </div>
{{- if .Account.DisplayName -}} {{- end }}
{{- emojify .Account.Emojis (escape .Account.DisplayName) -}} {{- end -}}
{{- else -}}
{{- .Account.Username -}} {{- /*
{{- end -}} When including this template, always wrap
</span> it in an appropriate <article></article>!
<span aria-hidden="true" class="username">@{{- .Account.Username -}}</span> */ -}}
<span class="sr-only">
{{- if .Account.DisplayName -}} {{- with . }}
{{- emojify .Account.Emojis (escape .Account.DisplayName) -}}. Username: @{{ .Account.Acct -}}. <header class="status-header">
{{- else -}} {{- include "status_header.tmpl" . | indent 1 }}
@{{- .Account.Acct -}}. </header>
{{- end -}} <div class="status-body">
</span> {{- if .SpoilerText }}
</a> <details class="text-spoiler">
</section> <summary>
<section class="body"> <span class="spoiler-text" lang="{{- .LanguageTag.TagStr -}}">{{- emojify .Emojis (escape .SpoilerText) -}}</span>
{{- if .SpoilerText }} <span class="button" role="button" tabindex="0">Toggle visibility</span>
<details class="text-spoiler"> </summary>
<summary> <div class="text">
<span class="spoiler-text" lang="{{- .LanguageTag.TagStr -}}">{{- emojify .Emojis (escape .SpoilerText) -}}</span> {{- with . }}
<span class="button" role="button" tabindex="0">Toggle visibility</span> {{- include "statusContent" . | indent 3 }}
</summary> {{- end }}
<div class="text"> {{- if .Poll }}
<div class="content" lang="{{- .LanguageTag.TagStr -}}"> {{- include "status_poll.tmpl" . | indent 3 }}
{{ emojify .Emojis (noescape .Content) }} {{- end }}
</div> </div>
{{- if .Poll }} </details>
{{ template "status_poll.tmpl" . }} {{- else }}
{{- end }} <div class="text">
</div> {{- with . }}
</details> {{- include "statusContent" . | indent 2 }}
{{- else }} {{- end }}
<div class="text"> {{- if .Poll }}
<div class="content" lang="{{- .LanguageTag.TagStr -}}"> {{- include "status_poll.tmpl" . | indent 2 }}
{{ emojify .Emojis (noescape .Content) }} {{- end }}
</div> </div>
{{- if .Poll }} {{- end }}
{{ template "status_poll.tmpl" . }} {{- if .MediaAttachments }}
{{- end }} {{- include "status_attachments.tmpl" . | indent 1 }}
</div> {{- end }}
{{- end }} </div>
{{- if .MediaAttachments }} <aside class="status-info" aria-hidden="true">
{{ template "status_attachments.tmpl" . }} {{- include "status_info.tmpl" . | indent 1 }}
{{- end }}
</section>
<aside class="info">
<dl class="sr-only">
<dt>Published<dt>
<dd>{{- .CreatedAt | timestampPrecise -}}</dd>
{{- if .LanguageTag.DisplayStr }}
<dt>Language</dt>
<dd>{{ .LanguageTag.DisplayStr }}</dd>
{{- end }}
</dl>
<time aria-hidden="true" datetime="{{- .CreatedAt -}}">{{- .CreatedAt | timestampPrecise -}}</time>
<div class="stats" role="group">
<div class="stats-item">
<span aria-hidden="true"><i class="fa fa-reply-all"></i> {{ .RepliesCount -}}</span>
<span class="sr-only">{{- .RepliesCount }} {{ if .RepliesCount | eq 1 }}reply{{ else }}replies{{ end -}}</span>
</div>
<div class="stats-item">
<span aria-hidden="true"><i class="fa fa-star"></i> {{ .FavouritesCount -}}</span>
<span class="sr-only">{{- .FavouritesCount }} {{ if .FavouritesCount | eq 1 }}favourite{{ else }}favourites{{ end -}}</span>
</div>
<div class="stats-item">
<span aria-hidden="true"><i class="fa fa-retweet"></i> {{ .ReblogsCount -}}</span>
<span class="sr-only">{{- .ReblogsCount }} {{ if .ReblogsCount | eq 1 }}boost{{ else }}boosts{{ end -}}</span>
</div>
{{- if .Pinned }}
<div class="stats-item">
<i class="fa fa-thumb-tack" aria-hidden="true"></i>
<span class="sr-only">pinned</span>
</div>
{{- end }}
{{- if .LanguageTag.DisplayStr }}
<div aria-hidden="true" class="stats-item language" title="Language: {{ .LanguageTag.DisplayStr }}">{{ .LanguageTag.TagStr }}</div>
{{- end }}
</div>
</aside> </aside>
{{- if .Local }}
<a
href="{{- .URL -}}"
class="status-link"
data-nosnippet
title="Open thread at this post"
>
Open thread at this post
</a>
{{- else }}
<a
href="{{- .URL -}}"
class="status-link"
data-nosnippet
rel="nofollow noreferrer noopener" target="_blank"
title="Open remote post (opens in a new window)"
>
Open remote post (opens in a new window)
</a>
{{- end }}
{{- end }}

View File

@ -18,77 +18,119 @@
*/ -}} */ -}}
{{- /* {{- /*
Template for rendering a gallery of status media attachments. Template for rendering a gallery of status media attachments.
To use this template, pass a web view status into it. To use this template, pass a web view status into it.
*/ -}} */ -}}
{{ with .MediaAttachments }} {{- define "imagePreview" }}
<div class="media photoswipe-gallery {{ (len .) | oddOrEven }} {{ if eq (len .) 1 }}single{{ else if eq (len .) 2 }}double{{- end -}}"> <img
{{- range $index, $media := . }} src="{{- .PreviewURL -}}"
<div class="media-wrapper"> loading="lazy"
<details class="{{- $media.Type -}}-spoiler media-spoiler" {{- if not $media.Sensitive }} open{{ end -}}> {{- if .Description }}
<summary> alt="{{- .Description -}}"
<div class="show sensitive button" aria-hidden="true">Show sensitive media</div> title="{{- .Description -}}"
<span class="eye button" role="button" tabindex="0" aria-label="Toggle media"> {{- end }}
<i class="hide fa fa-fw fa-eye-slash" aria-hidden="true"></i> width="{{- .Meta.Original.Width -}}"
<i class="show fa fa-fw fa-eye" aria-hidden="true"></i> height="{{- .Meta.Original.Height -}}"
</span> />
{{- if eq .Type "video" }}
<video {{- if .Description }} title="{{- $media.Description -}}" {{- end -}}>
<source type="video/mp4" src="{{- $media.URL -}}"/>
</video>
{{- else if eq .Type "image" }}
<img src="{{- $media.PreviewURL -}}" {{- if .Description }} title="{{- $media.Description -}}" {{- end }}/>
{{- end }}
</summary>
{{- if eq .Type "video" }}
<video
class="plyr-video photoswipe-slide"
controls
data-pswp-index="{{- $index -}}"
data-pswp-width="{{- $media.Meta.Original.Width -}}px"
data-pswp-height="{{- $media.Meta.Original.Height -}}px"
{{- if .Description }}
alt="{{- $media.Description -}}"
title="{{- $media.Description -}}"
{{- end }}
>
<source type="video/mp4" src="{{- $media.URL -}}"/>
</video>
{{- else if eq .Type "image" }}
<a
class="photoswipe-slide"
href="{{- $media.URL -}}"
target="_blank"
data-pswp-width="{{- $media.Meta.Original.Width -}}px"
data-pswp-height="{{- $media.Meta.Original.Height -}}px"
data-cropped="true"
{{- if .Description }}
title="{{- $media.Description -}}"
{{- end }}
>
<img src="{{$media.PreviewURL}}" {{if .Description}}alt="{{$media.Description}}" {{end}} />
</a>
{{- else }}
<a
class="unknown-attachment"
href="{{- $media.RemoteURL -}}"
target="_blank"
{{- if .Description }}
title="Link to external media: {{ $media.Description -}}&#10;&#13;{{- $media.RemoteURL -}}"
{{- else }}
title="Link to external media.&#10;&#13;{{- $media.RemoteURL -}}"
{{- end }}
>
<div class="placeholder" aria-hidden="true">
<i class="placeholder-external-link fa fa-external-link"></i>
<i class="placeholder-icon fa fa-file-text"></i>
<div class="placeholder-link-to">External media</div>
</div>
</a>
{{- end }}
</details>
</div>
{{- end }}
</div>
{{- end }} {{- end }}
{{- define "videoPreview" }}
<video
{{- if .Description }}
alt="{{- .Description -}}"
title="{{- .Description -}}"
{{- end }}
width="{{- .Meta.Original.Width -}}"
height="{{- .Meta.Original.Height -}}"
>
<source type="video/mp4" src="{{- .URL -}}"/>
</video>
{{- end }}
{{- /* Produces something like "1 attachment", "2 attachments", etc */ -}}
{{- define "attachmentsLength" -}}
{{- (len .) }}{{- if eq (len .) 1 }} attachment{{- else }} attachments{{- end -}}
{{- end -}}
{{- /* Produces something like "media photoswipe-gallery odd single" */ -}}
{{- define "galleryClass" -}}
media photoswipe-gallery {{ (len .) | oddOrEven }} {{ if eq (len .) 1 }}single{{ else if eq (len .) 2 }}double{{ end }}
{{- end -}}
{{- with .MediaAttachments }}
<div
class="{{- template "galleryClass" . -}}"
role="group"
aria-label="{{- template "attachmentsLength" . -}}"
>
{{- range $index, $media := . }}
<div class="media-wrapper">
<details class="{{- $media.Type -}}-spoiler media-spoiler" {{- if not $media.Sensitive }} open{{- end -}}>
<summary>
<div class="show sensitive button" aria-hidden="true">Show sensitive media</div>
<span class="eye button" role="button" tabindex="0" aria-label="Toggle media">
<i class="hide fa fa-fw fa-eye-slash" aria-hidden="true"></i>
<i class="show fa fa-fw fa-eye" aria-hidden="true"></i>
</span>
{{- if eq .Type "video" }}
{{- include "videoPreview" $media | indent 4 }}
{{- else if eq .Type "image" }}
{{- include "imagePreview" $media | indent 4 }}
{{- end }}
</summary>
{{- if eq .Type "video" }}
<video
class="plyr-video photoswipe-slide"
controls
data-pswp-index="{{- $index -}}"
data-pswp-width="{{- $media.Meta.Original.Width -}}px"
data-pswp-height="{{- $media.Meta.Original.Height -}}px"
{{- if .Description }}
alt="{{- $media.Description -}}"
title="{{- $media.Description -}}"
{{- end }}
>
<source type="video/mp4" src="{{- $media.URL -}}"/>
</video>
{{- else if eq .Type "image" }}
<a
class="photoswipe-slide"
href="{{- $media.URL -}}"
target="_blank"
data-pswp-width="{{- $media.Meta.Original.Width -}}px"
data-pswp-height="{{- $media.Meta.Original.Height -}}px"
data-cropped="true"
{{- if .Description }}
alt="{{- $media.Description -}}"
title="{{- $media.Description -}}"
{{- end }}
>
{{- with $media }}
{{- include "imagePreview" . | indent 4 }}
{{- end }}
</a>
{{- else }}
<a
class="unknown-attachment"
href="{{- $media.RemoteURL -}}"
rel="nofollow noreferrer noopener"
target="_blank"
{{- if .Description }}
title="Open external media: {{ $media.Description -}}&#10;&#13;{{- $media.RemoteURL -}}"
{{- else }}
title="Open external media.&#10;&#13;{{- $media.RemoteURL -}}"
{{- end }}
>
<div class="placeholder" aria-hidden="true">
<i class="placeholder-external-link fa fa-external-link"></i>
<i class="placeholder-icon fa fa-file-text"></i>
<div class="placeholder-link-to">External media</div>
</div>
</a>
{{- end }}
</details>
</div>
{{- end }}
</div>
{{- end }}

View File

@ -0,0 +1,55 @@
{{- /*
// 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/>.
*/ -}}
{{- define "ariaLabel" -}}
@{{ .Account.Acct -}}, {{ timestamp .CreatedAt -}}
{{- if .LanguageTag -}}
, language {{ .LanguageTag.DisplayStr -}}
{{- end -}}
{{- if .MediaAttachments -}}
, has media
{{- end -}}
{{- if .RepliesCount -}}
{{- if eq .RepliesCount 1 -}}
, 1 reply
{{- else -}}
, {{ .RepliesCount }} replies
{{- end -}}
{{- end -}}
{{- if .FavouritesCount -}}
{{- if eq .FavouritesCount 1 -}}
, 1 favourite
{{- else -}}
, {{ .FavouritesCount }} favourites
{{- end -}}
{{- end -}}
{{- if .ReblogsCount -}}
{{- if eq .ReblogsCount 1 -}}
, 1 boost
{{- else -}}
, {{ .ReblogsCount }} boosts
{{- end -}}
{{- end -}}
{{- end -}}
{{- with . }}
id="{{- .ID -}}{{- if .Pinned -}}-pinned{{- end -}}"
role="region"
aria-label="{{- template "ariaLabel" . -}}"
{{- end }}

View File

@ -0,0 +1,56 @@
{{- /*
// 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/>.
*/ -}}
{{- with .Account }}
<address>
{{- if $.Local }}
<a
href="{{- .URL -}}"
rel="author"
title="Open profile"
>
{{- else }}
<a
href="{{- .URL -}}"
rel="author nofollow noreferrer noopener" target="_blank"
title="Open remote profile (opens in a new window)"
>
{{- end }}
<img
class="avatar"
aria-hidden="true"
src="{{- .Avatar -}}"
alt="Avatar for {{ .Username -}}"
title="Avatar for {{ .Username -}}"
>
<div class="author-strap">
<span class="displayname text-cutoff">
{{- if .DisplayName -}}
{{- emojify .Emojis (escape .DisplayName) -}}
{{- else -}}
{{- .Username -}}
{{- end -}}
</span>
<span class="sr-only">,</span>
<span class="username text-cutoff">@{{- .Acct -}}</span>
</div>
<span class="sr-only">(open profile)</span>
</a>
</address>
{{- end }}

View File

@ -0,0 +1,74 @@
{{- /*
// 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/>.
*/ -}}
{{- with . }}
<dl class="status-stats">
<div class="stats-grouping">
<div class="stats-item published-at text-cutoff">
<dt class="sr-only">Published</dt>
<dd>
<time datetime="{{- .CreatedAt -}}">{{- .CreatedAt | timestampPrecise -}}</time>
</dd>
</div>
<div class="stats-grouping">
<div class="stats-item" title="Replies">
<dt>
<span class="sr-only">Replies</span>
<i class="fa fa-reply-all" aria-hidden="true"></i>
</dt>
<dd>{{- .RepliesCount -}}</dd>
</div>
<div class="stats-item" title="Faves">
<dt>
<span class="sr-only">Favourites</span>
<i class="fa fa-star" aria-hidden="true"></i>
</dt>
<dd>{{- .FavouritesCount -}}</dd>
</div>
<div class="stats-item" title="Boosts">
<dt>
<span class="sr-only">Reblogs</span>
<i class="fa fa-retweet" aria-hidden="true"></i>
</dt>
<dd>{{- .ReblogsCount -}}</dd>
</div>
{{- if .Pinned }}
<div class="stats-item" title="Pinned">
<dt>
<span class="sr-only">Pinned</span>
<i class="fa fa-thumb-tack" aria-hidden="true"></i>
</dt>
<dd class="sr-only">{{- .Pinned -}}</dd>
</div>
{{- else }}
{{- end }}
</div>
</div>
{{- if .LanguageTag.DisplayStr }}
<div class="stats-item language" title="{{ .LanguageTag.DisplayStr }}">
<dt class="sr-only">Language</dt>
<dd>
<span class="sr-only">{{ .LanguageTag.DisplayStr }}</span>
<span aria-hidden="true">{{- .LanguageTag.TagStr -}}</span>
</dd>
</div>
{{- else }}
{{- end }}
</dl>
{{- end }}

View File

@ -18,51 +18,64 @@
*/ -}} */ -}}
{{- /* {{- /*
Template for rendering a web view of a poll. Template for rendering a web view of a poll.
To use this template, pass a web view status into it. To use this template, pass a web view status into it.
*/ -}} */ -}}
<figure class="poll"> {{- define "votes" -}}
<figcaption class="poll-info"> {{- if eq . 1 -}}
<span class="poll-expiry"> {{- . -}}&nbsp;vote
{{- if .Poll.Multiple -}} {{- else -}}
Multiple-choice poll&nbsp; {{- . }}&nbsp;votes
{{- else -}} {{- end -}}
Poll&nbsp; {{- end -}}
{{- end -}}
{{- if .Poll.Expired -}} {{- with . }}
closed {{ .Poll.ExpiresAt | timestampPrecise -}} <figure class="poll">
{{- else if .Poll.ExpiresAt -}} <figcaption class="poll-info">
open until {{ .Poll.ExpiresAt | timestampPrecise -}} <span class="poll-expiry">
{{- else -}} {{- if .Poll.Multiple -}}
open forever Multiple-choice poll&nbsp;
{{- end -}} {{- else -}}
</span> Poll&nbsp;
<span class="total-votes">Total votes: {{ .Poll.VotesCount }}</span> {{- end -}}
</figcaption> {{- if .Poll.Expired -}}
<ul class="poll-options"> closed <time datetime="{{- .Poll.ExpiresAt -}}">{{- .Poll.ExpiresAt | timestampPrecise -}}</time>
{{- range $index, $pollOption := .WebPollOptions }} {{- else if .Poll.ExpiresAt -}}
<li class="poll-option"> open until <time datetime="{{- .Poll.ExpiresAt -}}">{{- .Poll.ExpiresAt | timestampPrecise -}}</time>
<label aria-hidden="true" for="poll-{{- $pollOption.PollID -}}-option-{{- increment $index -}}" lang="{{- .LanguageTag.TagStr -}}">{{- emojify .Emojis (noescape $pollOption.Title) -}}</label> {{- else -}}
<meter aria-hidden="true" id="poll-{{- $pollOption.PollID -}}-option-{{- increment $index -}}" min="0" max="100" value="{{- $pollOption.VoteShare -}}">{{- $pollOption.VoteShare -}}&#37;</meter> open forever
<div class="sr-only">Option {{ increment $index }}:&nbsp;<span lang="{{ .LanguageTag.TagStr }}">{{ emojify .Emojis (noescape $pollOption.Title) -}}</span></div> {{- end -}}
<div class="poll-vote-summary"> </span>
{{- if isNil $pollOption.VotesCount }} <span class="sr-only">,</span>
Results not yet published. <span class="total-votes">
{{- else -}} {{- template "votes" .Poll.VotesCount -}}&nbsp;
{{- with deref $pollOption.VotesCount }} {{- if .Poll.Expired -}}
<span class="poll-vote-share">{{- $pollOption.VoteShareStr -}}&#37;</span> total
<span class="poll-vote-count"> {{- else -}}
{{- if eq . 1 -}} so far
{{- . }} vote {{- end -}}
{{- else -}} </span>
{{- . }} votes </figcaption>
{{- end -}} <ul class="poll-options nodot">
</span> {{- range $index, $pollOption := .WebPollOptions }}
{{- end -}} <li class="poll-option">
{{- end }} <span class="sr-only">Option {{ increment $index }},</span>
</div> <span lang="{{- .LanguageTag.TagStr -}}">{{ emojify .Emojis (noescape $pollOption.Title) }}</span>
</li> <meter aria-hidden="true" min="0" max="100" value="{{- $pollOption.VoteShare -}}"></meter>
{{- end }} <div class="poll-vote-summary">
</ul> {{- if isNil $pollOption.VotesCount }}
</figure> Results not yet published.
{{- else }}
{{- with deref $pollOption.VotesCount }}
<span class="poll-vote-share">{{- $pollOption.VoteShareStr -}}&#37;</span>
<span class="sr-only">,</span>
<span class="poll-vote-count">{{- template "votes" . -}}</span>
{{- end }}
{{- end }}
</div>
</li>
{{- end }}
</ul>
</figure>
{{- end }}

View File

@ -17,11 +17,13 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- with . }}
<main>
<main class="thread"> <h2 id="tag-name" tabindex="-1">#{{- .tagName -}}</h2>
<h2 id="tag-name" tabindex="-1">#{{.tagName}}</h2> <p>There's nothing here!</p>
<p>There's nothing here yet!</p> <p>
For privacy reasons, GoToSocial doesn't (yet) implement public web views of tag timelines.
To soften the blow, here's a tongue twister: "I squeeze the soft sloth often in the mothy loft" 🦥
</p>
</main> </main>
{{- end }}
{{ template "footer.tmpl" .}}

View File

@ -17,22 +17,45 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ -}} */ -}}
{{ template "header.tmpl" .}} {{- define "threadLength" -}}
<main> {{- with $length := add (len $.context.Ancestors) (len $.context.Descendants) | increment -}}
<section data-nosnippet class="thread"> {{- if eq $length 1 -}}
{{range .context.Ancestors}} {{- $length }} post
<article class="toot" id="{{.ID}}"> {{- else -}}
{{ template "status.tmpl" .}} {{- $length }} posts
</article> {{- end -}}
{{end}} {{- end -}}
<article class="toot expanded" id="{{.status.ID}}"> {{- end -}}
{{ template "status.tmpl" .status}}
</article> {{- with . }}
{{range .context.Descendants}} <main data-nosnippet class="thread" aria-labelledby="thread-summary">
<article class="toot" id="{{.ID}}"> <div class="col-header">
{{ template "status.tmpl" .}} <h2 id="thread-summary">Thread with {{ template "threadLength" . -}}</h2>
</article> <a href="#{{- .status.ID -}}">jump to expanded post</a>
{{end}} </div>
</section> {{- range .context.Ancestors }}
<article
class="status"
{{- includeAttr "status_attributes.tmpl" . | indentAttr 2 }}
>
{{- include "status.tmpl" . | indent 2 }}
</article>
{{- end }}
{{- with .status }}
<article
class="status expanded"
{{- includeAttr "status_attributes.tmpl" . | indentAttr 2 }}
>
{{- include "status.tmpl" . | indent 2 }}
</article>
{{- end }}
{{- range .context.Descendants }}
<article
class="status"
{{- includeAttr "status_attributes.tmpl" . | indentAttr 2 }}
>
{{- include "status.tmpl" . | indent 2 }}
</article>
{{- end }}
</main> </main>
{{ template "footer.tmpl" .}} {{- end }}