[frontend] Unified panels (#812)

* settings panel restructuring

* clean up old Gin handlers

* colorscheme redesign, some other small css tweaks

* basic router layout, error boundary

* colorscheme redesign, some other small css tweaks

* kebab-case consistency

* superfluous padding on applist

* remove unused consts

* redux, whitespace changes..

* use .jsx extensions for components

* login flow up till app registration

* full redux oauth implementation, with basic error handling

* split oauth api functions

* oauth api revocation handling

* basic profile change submission

* move old dir

* profile overview

* fix keeping track of the wrong instance url (for different instance/api domains)

* use redux state for profile form

* delete old/index.js, old/basic.js, fully implemented

* implement old/user/profile.js

* implement password change

* remove debug logging

* support future api for removing files

* customize profile css

* remove unneeded wrapper components

* restructure form fields

* start on admin pages

* admin panel settings

* admin settings panel

* remove old/admin files

* add top-level redirect

* refactor/cleanup forms

* only do API checks on logged-in state

* admin-status based routing

* federation block routing

* federation blocks

* upgrade dependencies

* react 18 changes

* media cleanup

* fix useEffect hooks

* remove unused require

* custom emoji base

* emoji uploader

* delete last old panel files

* sidebar styling, remove unused page

* refactor submit functions

* fix sidebar boxshadow-border

* fix old css variables

* fix fake-toot avatar

* fix non-square emoji

* fix user settings redux keys

* properly get admin account contact from instance response

* Account.source default values

* source.status_format key

* mobile responsiveness

* mobile element tweaks

* proper redirect after removing block

* add redirects for old setting panel urls

* deletes

* fix mobile overflow

* clean up debug logging calls
This commit is contained in:
f0x52 2022-09-29 12:02:41 +02:00 committed by GitHub
parent 2f22780800
commit 938328cd07
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 3989 additions and 2837 deletions

View file

@ -117,6 +117,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
"show_back_to_top": showBackToTop,
"stylesheets": stylesheets,
"javascript": []string{
"/assets/dist/bundle.js",
"/assets/dist/frontend.js",
},
})

View file

@ -27,7 +27,7 @@
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
func (m *Module) UserPanelHandler(c *gin.Context) {
func (m *Module) SettingsPanelHandler(c *gin.Context) {
host := config.GetHost()
instance, err := m.processor.InstanceGet(c.Request.Context(), host)
if err != nil {
@ -41,37 +41,13 @@ func (m *Module) UserPanelHandler(c *gin.Context) {
assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css",
assetsPathPrefix + "/dist/_colors.css",
assetsPathPrefix + "/dist/base.css",
assetsPathPrefix + "/dist/panels-base.css",
assetsPathPrefix + "/dist/panels-user-style.css",
assetsPathPrefix + "/dist/profile.css",
assetsPathPrefix + "/dist/status.css",
assetsPathPrefix + "/dist/settings-panel-style.css",
},
"javascript": []string{
assetsPathPrefix + "/dist/bundle.js",
assetsPathPrefix + "/dist/user-panel.js",
},
})
}
// TODO: abstract the {admin, user}panel handlers in some way
func (m *Module) AdminPanelHandler(c *gin.Context) {
host := config.GetHost()
instance, err := m.processor.InstanceGet(c.Request.Context(), host)
if err != nil {
api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet)
return
}
c.HTML(http.StatusOK, "frontend.tmpl", gin.H{
"instance": instance,
"stylesheets": []string{
assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css",
assetsPathPrefix + "/dist/_colors.css",
assetsPathPrefix + "/dist/base.css",
assetsPathPrefix + "/dist/panels-base.css",
assetsPathPrefix + "/dist/panels-admin-style.css",
},
"javascript": []string{
assetsPathPrefix + "/dist/bundle.js",
assetsPathPrefix + "/dist/admin-panel.js",
assetsPathPrefix + "/dist/settings.js",
},
})
}

View file

@ -119,6 +119,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
"ogMeta": ogBase(instance).withStatus(status),
"stylesheets": stylesheets,
"javascript": []string{
"/assets/dist/bundle.js",
"/assets/dist/frontend.js",
},
})

View file

@ -37,9 +37,9 @@
profilePath = "/@:" + usernameKey
customCSSPath = profilePath + "/custom.css"
statusPath = profilePath + "/statuses/:" + statusIDKey
adminPanelPath = "/admin"
userPanelpath = "/user"
assetsPathPrefix = "/assets"
userPanelPath = "/settings/user"
adminPanelPath = "/settings/admin"
tokenParam = "token"
usernameKey = "username"
@ -70,20 +70,24 @@ func (m *Module) Route(s router.Router) error {
assetsGroup := s.AttachGroup(assetsPathPrefix)
m.mountAssetsFilesystem(assetsGroup)
s.AttachHandler(http.MethodGet, adminPanelPath, m.AdminPanelHandler)
// redirect /admin/ to /admin
s.AttachHandler(http.MethodGet, adminPanelPath+"/", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, adminPanelPath)
s.AttachHandler(http.MethodGet, "/settings", m.SettingsPanelHandler)
s.AttachHandler(http.MethodGet, "/settings/*panel", m.SettingsPanelHandler)
// User panel redirects
// used by clients
s.AttachHandler(http.MethodGet, "/auth/edit", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, userPanelPath)
})
s.AttachHandler(http.MethodGet, userPanelpath, m.UserPanelHandler)
// redirect /user/ to /user
s.AttachHandler(http.MethodGet, userPanelpath+"/", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, userPanelpath)
// old version of settings panel
s.AttachHandler(http.MethodGet, "/user", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, userPanelPath)
})
// redirect /auth/edit to /user
s.AttachHandler(http.MethodGet, "/auth/edit", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, userPanelpath)
// Admin panel redirects
// old version of settings panel
s.AttachHandler(http.MethodGet, "/admin", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, adminPanelPath)
})
// serve front-page

View file

@ -23,57 +23,85 @@
/* Color definitions */
$near_white: #fafaff;
/* Foreground */
$white1: #fafaff; /* default text color, contrast >= 5.0 with all $grays */
$white2: #b3b5c6; /* less important text, can be used with $gray1 (6.8), $gray2 (5.5), $gray3 (4.9), $gray4 (4.5) */
$sloth_gray1: #b0b0b5;
$sloth_gray2: #4d4e56;
/* Background shades, contrast >= 5.0 with $white1 (#fafaff) */
$gray1: #2a2b2f;
$gray2: #35363b;
$gray3: #3a3b41;
$gray4: #45464e;
$gray5: #4d4e56;
$gray6: #575861;
$gray7: #5d5e67;
$gray8: #696a75;
$sloth_orange1: #e78e5a;
$sloth_orange2: #D87841;
$blue: #63b1de; // complementary color to $sloth_orange1
$orange1: #fd6a00; /* Used for non-text accent colors, can be used as background: $gray1 for text color (contrast 4.6)*/
$orange2: #ff853e; /* hover/selected accent to $orange1, can be used with $gray1 (5.7), $gray2 (4.6) */
/* derivative colors */
$blue1: #3a9fde; /* darker blue for smaller elements (borders), can only be used with $gray1 (4.7) */
$blue2: #66befe; /* all-round accent color, can be used with $gray1 (6.8), $gray2 (5.5), $gray3 (4.9), $gray4 (4.5) */
$blue3: #89caff; /* hover/selected accent to $blue2, can be used with $gray1 (7.9), $gray2 (6.3), $gray3 (5.6), $gray4 (5.2), $gray5 (4.7) */
$sloth_gray2_darker3: color-mod($sloth_gray2 lightness(-3%));
$sloth_gray2_darker5: color-mod($sloth_gray2 lightness(-5%));
$sloth_gray2_darker7: color-mod($sloth_gray2 lightness(-7%));
$sloth_gray2_darker15: color-mod($sloth_gray2 lightness(-15%));
$sloth_gray2_lighter3: color-mod($sloth_gray2 lightness(+3%));
$sloth_gray2_lighter5: color-mod($sloth_gray2 lightness(+5%));
$error1: #860000; /* Error border/foreground text, can be used with $error2 (5.0), $white1 (10), $white2 (5.1) */
$error2: #ff9796; /* Error background text, can be used with $error1 (5.0), $gray1 (6.6), $gray2 (5.3), $gray3 (4.8) */
$error-link: #185F8C; /* Error link text, can be used with $error2 (5.54) */
$blue_lighter8: color-mod($blue lightness(+4%));
$lightblue: color-mod($blue lightness(+16%));
$fg: $white1;
$bg: $gray1;
$fg: $near_white;
$bg: $sloth_gray2_darker7;
$bg-trans: color-mod($gray5 alpha(62%));
$bg_trans: color-mod($sloth_gray2 alpha(62%));
$bg_accent: $sloth_gray2_lighter3;
$fg_accent: $lightblue;
$border_accent: $sloth_orange2;
$bg-accent: $gray5;
$fg-accent: $blue3;
$fg-reduced: $white2;
$border-accent: $orange2;
/* Color variables as used in a specific location */
$footer_bg: $bg_accent;
$link-fg: $fg-accent;
$link_fg: $fg_accent;
$button-bg: $blue2;
$button-fg: $gray1;
$button-hover-bg: $blue3;
$button_border: 0.08rem solid color-mod($sloth_orange2 lightness(-15%));
$button_bg: $blue_lighter8;
$button_fg: $sloth_gray2_darker15;
$button_hover_bg: $lightblue;
$button-danger-bg: $orange1;
$button-danger-fg: $gray1;
$button-danger-hover-bg: $orange2;
$status_focus_bg: $bg_accent;
$status_unfocus_bg: $sloth_gray2_darker3;
$status_info_fg: #CBCBD7;
$toot-focus-bg: $gray5;
$toot-unfocus-bg: $gray3;
$bg_no_img_desc: $sloth_orange2;
$bg_sensitive: $sloth_gray2_darker15;
$toot-info-bg: $gray4;
$no-img-desc-bg: $orange1;
$no-img-desc-fg: $gray1;
$bg-sensitive: $gray1;
$boxshadow: 0 0.4rem 1rem -0.1rem rgba(0,0,0,0.15);
$boxshadow_border: 0.08rem solid $sloth_gray2_darker5;
$boxshadow-border: 0.08rem solid $gray1;
$profile_avatar_border: 0.2rem solid $border_accent;
$avatar-border: $orange2;
$input_bg: $sloth_gray2_darker3;
$input-bg: $gray4;
$input-disabled-bg: $gray2;
$input-border: $blue1;
$input-focus-border: $blue3;
$settings-nav-bg: $bg-accent;
$settings-nav-header-fg: $gray1;
$settings-nav-header-bg: $orange1;
$settings-nav-bg-hover: $gray3;
/* $settings-nav-fg-hover: $gray1; */
$settings-nav-bg-active: $gray2;
/* $settings-nav-fg-active: $orange2; */
$error-fg: $error1;
$error-bg: $error2;
$settings-entry-bg: $gray3;
$settings-entry-hover-bg: $gray4;

View file

@ -34,7 +34,7 @@
$br: 0.4rem;
// border radius for items that are framed/bordered
// inside something with $br, eg avatar, header img
$br_inner: 0.2rem;
$br-inner: 0.2rem;
html, body {
padding: 0;
@ -42,7 +42,7 @@ html, body {
background: $bg;
color: $fg;
font-family: "Noto Sans", sans-serif;
scrollbar-color: $sloth_orange1 $sloth_gray2_darker3;
scrollbar-color: $orange1 $gray3;
}
body {
@ -71,7 +71,7 @@ h1 {
}
a {
color: $link_fg;
color: $link-fg;
}
header, footer {
@ -83,9 +83,13 @@ header, footer {
align-self: start;
}
header {
display: flex;
justify-content: center;
}
header a {
margin: 2rem;
/* background: $header_bg; */
display: flex;
flex-direction: column;
flex-wrap: wrap;
@ -109,7 +113,7 @@ header a {
}
}
.excerpt_top {
.excerpt-top {
margin-top: -1rem;
margin-bottom: 2rem;
font-style: italic;
@ -119,15 +123,15 @@ header a {
.count {
font-weight: bold;
color: $fg_accent;
color: $fg-accent;
}
}
main {
section {
background: $bg_accent;
background: $bg-accent;
box-shadow: $boxshadow;
border: $boxshadow_border;
border: $boxshadow-border;
border-radius: $br;
padding: 2rem;
margin-bottom: 2rem;
@ -144,10 +148,10 @@ main {
.button, button {
border-radius: 0.2rem;
color: $button_fg;
background: $button_bg;
color: $button-fg;
background: $button-bg;
box-shadow: $boxshadow;
border: $button_border;
border: $button-border;
text-decoration: none;
font-size: 1.2rem;
font-weight: bold;
@ -157,8 +161,17 @@ main {
text-align: center;
font-family: 'Noto Sans', sans-serif;
&.danger {
color: $button-danger-fg;
background: $button-danger-bg;
&:hover {
background: $button-danger-hover-bg;
}
}
&:hover {
background: $button_hover_bg;
background: $button-hover-bg;
}
}
@ -191,7 +204,7 @@ section.apps {
grid-template-columns: 25% 1fr;
gap: 1.5rem;
padding: 0.5rem;
background: $bg_accent;
background: $bg-accent;
border-radius: 0.5rem;
.logo {
@ -211,7 +224,7 @@ section.apps {
}
div {
padding: 1rem 0;
padding: 0;
h3 {
margin-top: 0;
}
@ -264,26 +277,42 @@ section.error {
}
}
.error-text {
color: $error1;
background: $error2;
border-radius: 0.1rem;
font-weight: bold;
}
input, select, textarea {
box-sizing: border-box;
border: 0.15rem solid $border_accent;
border: 0.15rem solid $input-border;
border-radius: 0.1rem;
color: $fg;
/* background: $input_bg; */
background: $bg_accent;
background: $input-bg;
width: 100%;
font-family: 'Noto Sans', sans-serif;
font-size: 1rem;
padding: 0.3rem;
&:focus {
border-color: $fg_accent;
border-color: $input-focus-border;
}
&:disabled {
background: $input-disabled-bg;
}
}
input, textarea {
padding-top: 0.1rem;
padding-bottom: 0.1rem;
::placeholder {
opacity: 1;
color: $fg-reduced
}
hr {
color: transparent;
width: 100%;
border-bottom: 0.02rem solid $border-accent;
}
footer {
@ -330,4 +359,8 @@ footer {
margin: -0.5ex 0 0;
object-fit: contain;
vertical-align: middle;
}
.monospace {
font-family: monospace;
}

View file

@ -28,7 +28,7 @@ main {
}
.profile {
background: $bg_accent;
background: $bg-accent;
display: grid;
grid-template-rows: auto auto auto;
grid-template-columns: auto;
@ -38,7 +38,7 @@ main {
border-radius: $br;
box-shadow: $boxshadow;
border: $boxshadow_border;
border: $boxshadow-border;
.headerimage {
width: 100%;
@ -50,7 +50,7 @@ main {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: $br_inner $br_inner 0 0;
border-radius: $br-inner $br-inner 0 0;
}
}
@ -69,7 +69,7 @@ main {
#profile-basic-filler2 {
grid-area: filler2;
background: $bg_trans;
background: $bg-trans;
}
.avatar {
@ -79,7 +79,7 @@ main {
width: 8.5rem;
grid-area: avatar;
background: $bg;
border: $profile_avatar_border;
border: 0.2rem solid $avatar-border;
padding: 0;
border-radius: $br;
position: relative;
@ -87,7 +87,7 @@ main {
box-shadow: $boxshadow;
img {
object-fit: cover;
border-radius: $br_inner;
border-radius: $br-inner;
width: 100%;
height: 100%;
}
@ -105,7 +105,7 @@ main {
font-weight: bold;
font-size: 2rem;
line-height: 2.2rem;
background: $bg_trans;
background: $bg-trans;
word-break: break-all;
text-overflow: ellipsis;
overflow: hidden;
@ -120,7 +120,7 @@ main {
padding-top: 0;
margin-top: 0.25rem;
padding-bottom: 0.25rem;
color: $fg_accent;
color: $fg-accent;
font-weight: bold;
word-break: break-all;
text-overflow: ellipsis;

View file

@ -31,13 +31,13 @@ main {
}
.toot {
background: $status_unfocus_bg;
background: $toot-unfocus-bg;
box-shadow: $boxshadow;
border: $boxshadow_border;
border: $boxshadow-border;
position: relative;
margin-bottom: $br;
border-radius: $br;
padding: 1.5rem 0;
padding-top: 1.5rem;
padding-bottom: 0.7rem;
a {
position: relative;
@ -49,27 +49,34 @@ main {
.contentgrid {
padding: 0 1.5rem;
display: grid;
grid-template-columns: 4rem auto 1fr;
grid-template-rows: 1.5rem auto auto;
grid-template-columns: 4rem 1fr auto;
grid-template-rows: 1.5rem auto auto auto;
column-gap: 0.5rem;
}
.not-expanded {
color: $fg-reduced;
grid-column: 3;
grid-row: 1;
}
.avatar {
grid-row: span 2;
grid-row: span 3;
aspect-ratio: 1/1;
display: flex;
border: 0.2rem solid $avatar-border;
border-radius: 0.4rem;
overflow: hidden; /* hides corners from img overflowing */
img {
height: 100%;
width: 100%;
object-fit: cover;
background: $bg;
border: 0.1rem solid $acc2;
border-radius: calc($br / 1.5);
}
}
.displayname {
grid-column: span 2;
font-weight: bold;
font-size: 1.2rem;
line-height: 2rem;
@ -82,7 +89,7 @@ main {
}
.username {
color: $link_fg;
color: $link-fg;
line-height: 2rem;
margin-top: -0.5rem;
align-self: start;
@ -119,8 +126,7 @@ main {
.text {
margin: 0;
margin-top: 0.5rem;
grid-column: span 3;
grid-column: 2 / span 2;
grid-row: span 1;
overflow: hidden;
@ -128,34 +134,33 @@ main {
z-index: 2;
a {
color: $link_fg;
color: $link-fg;
text-decoration: underline;
}
.content {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
word-break: break-word;
blockquote {
padding: 0.5rem 0 0.5rem 1.5rem;
border-left: 0.2rem solid $sloth_orange1;
border-left: 0.2rem solid $border-accent;
margin-left: 1rem;
font-style: italic;
}
hr {
border: 1px dashed $sloth_orange1;
border: 1px dashed $border-accent;
}
pre, code {
background-color: $sloth_gray2_darker7;
background-color: $gray2;
}
code {
padding: 0.25rem;
border-radius: $br_inner;
border-radius: $br-inner;
}
pre {
@ -249,7 +254,7 @@ main {
.closed {
transition: 0.3s;
background: $bg_sensitive;
background: $bg-sensitive;
@supports (backdrop-filter: blur(2rem)) {
background: transparent;
backdrop-filter: blur(2rem);
@ -263,17 +268,17 @@ main {
}
.no-image-desc {
color: $button_fg;
color: $no-img-desc-fg;
background: $no-img-desc-bg;
display: flex;
position: absolute;
bottom: 0.1rem;
right: 0.4rem;
margin-bottom: 0.4rem;
margin-right: 0.4rem;
background: $bg_no_img_desc;
padding: 0.1rem 0.45rem;
border-radius: 100%;
border: 0.2rem solid $button_fg;
border: 0.2rem solid $button-fg;
z-index: 3;
i.fa {
@ -302,12 +307,13 @@ main {
}
.info {
background: $toot-info-bg;
color: $fg-reduced;
display: none;
border-top: 0.15rem solid $status_unfocus_bg;
border-top: 0.15rem solid $toot-info-border;
padding: 0.5rem 1.5rem;
div {
position: relative;
padding-right: 1.3rem;
}
@ -317,30 +323,6 @@ main {
grid-column: span 3;
flex-wrap: wrap;
div.stats::after {
display: none;
}
div::after {
$size: 0.25rem;
display: block;
background: $fg_dark;
height: $size;
width: $size;
content: "";
position: absolute;
top: calc((1.5rem - $size) / 2);
right: 0.55rem;
border-radius: 1rem;
}
div:last-child {
&::after {
display: none;
}
margin-right: 0;
}
}
.toot-link {
@ -362,7 +344,7 @@ main {
border-top-right-radius: $br;
}
&:last-child {
&:last-child, &:last-child .info {
/* bottom left, bottom right */
border-bottom-left-radius: $br;
border-bottom-right-radius: $br;
@ -370,11 +352,21 @@ main {
}
&.expanded {
background: $status_focus_bg;
background: $toot-focus-bg;
padding-bottom: 0;
.contentgrid {
padding-bottom: 1rem;
.displayname {
grid-column: span 2;
}
.text {
grid-column: 1 / span 3;
}
.not-expanded {
display: none;
}
}
.info {

View file

@ -1,19 +1,19 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
GoToSocial
Copyright (C) 2021-2022 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 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.
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/>.
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/>.
*/
"use strict";

View file

@ -18,11 +18,6 @@
"use strict";
// WARNING: currently dependencies get deduplicated with factor-bundle, but
// our frontend templates don't load the common bundle.js since it contains React etc
// so we can't use any dependencies that would deduplicate with the other files
const Photoswipe = require("photoswipe/dist/umd/photoswipe.umd.min.js");
const PhotoswipeLightbox = require("photoswipe/dist/umd/photoswipe-lightbox.umd.min.js");
const PhotoswipeCaptionPlugin = require("photoswipe-dynamic-caption-plugin").default;

View file

@ -1,19 +1,19 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
GoToSocial
Copyright (C) 2021-2022 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 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.
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/>.
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/>.
*/
"use strict";
@ -23,7 +23,8 @@
*/
const path = require('path');
const budoExpress = require('budo-express');
// Forked budo-express supports EventEmitter, to write bundle.js to disk in development
const budoExpress = require('@f0x52/budo-express');
const babelify = require('babelify');
const fs = require("fs");
const EventEmitter = require('events');
@ -38,8 +39,9 @@ const splitCSS = require("./lib/split-css.js");
const bundles = {
"./frontend/index.js": "frontend.js",
"./panels/admin/index.js": "admin-panel.js",
"./panels/user/index.js": "user-panel.js",
"./settings-panel/index.js": "settings.js",
// "./panels/admin/index.js": "admin-panel.js",
// "./panels/user/index.js": "user-panel.js",
};
const postcssPlugins = [
@ -50,6 +52,18 @@ const postcssPlugins = [
"postcss-color-mod-function"
].map((plugin) => require(plugin)());
let uglifyifyInProduction;
if (process.env.NODE_ENV != "development") {
console.log("uglifyify'ing production bundles");
uglifyifyInProduction = [
require("uglifyify"), {
global: true,
exts: ".js"
}
];
}
const browserifyConfig = {
transform: [
[
@ -69,10 +83,7 @@ const browserifyConfig = {
exclude: /node_modules\/(?!photoswipe-dynamic-caption-plugin)/,
}
],
[require("uglifyify"), {
global: true,
exts: ".js"
}]
uglifyifyInProduction
],
plugin: [
[require("icssify"), {
@ -86,7 +97,8 @@ const browserifyConfig = {
return out(file);
})
}]
]
],
extensions: [".js", ".jsx", ".css"]
};
const entryFiles = Object.keys(bundles);

View file

@ -1,19 +1,19 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
GoToSocial
Copyright (C) 2021-2022 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 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.
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/>.
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/>.
*/
"use strict";

View file

@ -1,30 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const React = require("react");
module.exports = function Submit({onClick, label, errorMsg, statusMsg}) {
return (
<div className="messagebutton">
<button type="submit" onClick={onClick}>{ label }</button>
<div className="error accent">{errorMsg ? errorMsg : statusMsg}</div>
</div>
);
};

View file

@ -1,6 +1,6 @@
{
"name": "gotosocial-frontend",
"version": "0.3.8",
"version": "0.5.0",
"description": "GoToSocial frontend sources",
"main": "index.js",
"author": "f0x",
@ -9,18 +9,23 @@
"@babel/core": "^7.12.13",
"@babel/preset-env": "^7.12.13",
"@babel/preset-react": "^7.12.13",
"@f0x52/budo-express": "^1.1.0",
"@reduxjs/toolkit": "^1.8.5",
"autoprefixer": "^10.4.8",
"babelify": "^10.0.0",
"bluebird": "^3.7.2",
"browserify": "^17.0.0",
"browserlist": "^1.0.1",
"budo-express": "^1.0.8",
"create-error": "^0.3.1",
"css-extract": "^2.0.0",
"default-value": "^1.0.0",
"dotty": "^0.1.2",
"eslint-plugin-react": "^7.24.0",
"express": "^4.18.1",
"factor-bundle": "^2.5.0",
"from2-string": "^1.1.0",
"icssify": "^2.0.0",
"is-plain-object": "^5.0.0",
"is-valid-domain": "^0.1.6",
"js-file-download": "^0.4.12",
"modern-normalize": "^1.1.0",
"photoswipe": "^5.3.0",
@ -31,11 +36,17 @@
"postcss-nested": "^5.0.6",
"postcss-scss": "^4.0.4",
"postcss-strip-inline-comments": "^0.1.5",
"pretty-bytes": "^5.6.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"reactify": "^1.1.1",
"uglifyify": "^5.0.2"
"prettier-bytes": "^1.0.4",
"pretty-bytes": "4",
"react": "18",
"react-dom": "18",
"react-error-boundary": "^3.1.4",
"react-redux": "^8.0.2",
"redux-devtools-extension": "^2.13.9",
"redux-persist": "^6.0.0",
"redux-thunk": "^2.4.1",
"uglifyify": "^5.0.2",
"wouter": "^2.8.0-alpha.2"
},
"devDependencies": {
"@f0x52/eslint-config-react": "^1.1.0",

View file

@ -1,21 +0,0 @@
# GoToSocial Admin Panel
Standalone web admin panel for [GoToSocial](https://github.com/superseriousbusiness/gotosocial).
A public hosted instance is also available at https://gts.superseriousbusiness.org/admin/, so you can fill your own instance URL in there.
## Installation
Build requirements: some version of Node.js with npm,
```
git clone https://github.com/superseriousbusiness/gotosocial-admin.git && cd gotosocial-admin
npm install
node index.js
```
All processed build output will now be in `public/`, which you can copy over to a folder in your GoToSocial installation like `web/assets/admin`, or serve elsewhere.
No further configuration is required, authentication happens through normal OAUTH flow.
## Development
Follow the installation steps, but run `NODE_ENV=development node index.js` to start the livereloading dev server instead.
## License, donations
[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html). If you want to support my work, you can: <a href="https://liberapay.com/f0x/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a>

View file

@ -1,318 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const fileDownload = require("js-file-download");
function sortBlocks(blocks) {
return blocks.sort((a, b) => { // alphabetical sort
return a.domain.localeCompare(b.domain);
});
}
function deduplicateBlocks(blocks) {
let a = new Map();
blocks.forEach((block) => {
a.set(block.id, block);
});
return Array.from(a.values());
}
module.exports = function Blocks({oauth}) {
const [blocks, setBlocks] = React.useState([]);
const [info, setInfo] = React.useState("Fetching blocks");
const [errorMsg, setError] = React.useState("");
const [checked, setChecked] = React.useState(new Set());
React.useEffect(() => {
Promise.try(() => {
return oauth.apiRequest("/api/v1/admin/domain_blocks", undefined, undefined, "GET");
}).then((json) => {
setInfo("");
setError("");
setBlocks(sortBlocks(json));
}).catch((e) => {
setError(e.message);
setInfo("");
});
}, []);
let blockList = blocks.map((block) => {
function update(e) {
let newChecked = new Set(checked.values());
if (e.target.checked) {
newChecked.add(block.id);
} else {
newChecked.delete(block.id);
}
setChecked(newChecked);
}
return (
<React.Fragment key={block.id}>
<div><input type="checkbox" onChange={update} checked={checked.has(block.id)}></input></div>
<div>{block.domain}</div>
<div>{(new Date(block.created_at)).toLocaleString()}</div>
</React.Fragment>
);
});
function clearChecked() {
setChecked(new Set());
}
function undoChecked() {
let amount = checked.size;
if(confirm(`Are you sure you want to remove ${amount} block(s)?`)) {
setInfo("");
Promise.map(Array.from(checked.values()), (block) => {
console.log("deleting", block);
return oauth.apiRequest(`/api/v1/admin/domain_blocks/${block}`, "DELETE");
}).then((res) => {
console.log(res);
setInfo(`Deleted ${amount} blocks: ${res.map((a) => a.domain).join(", ")}`);
}).catch((e) => {
setError(e);
});
let newBlocks = blocks.filter((block) => {
if (checked.size > 0 && checked.has(block.id)) {
checked.delete(block.id);
return false;
} else {
return true;
}
});
setBlocks(newBlocks);
clearChecked();
}
}
return (
<section className="blocks">
<h1>Blocks</h1>
<div className="error accent">{errorMsg}</div>
<div>{info}</div>
<AddBlock oauth={oauth} blocks={blocks} setBlocks={setBlocks} />
<h3>Blocks:</h3>
<div style={{display: "grid", gridTemplateColumns: "1fr auto"}}>
<span onClick={clearChecked} className="accent" style={{alignSelf: "end"}}>uncheck all</span>
<button onClick={undoChecked}>Unblock selected</button>
</div>
<div className="blocklist overflow">
{blockList}
</div>
<BulkBlocking oauth={oauth} blocks={blocks} setBlocks={setBlocks}/>
</section>
);
};
function BulkBlocking({oauth, blocks, setBlocks}) {
const [bulk, setBulk] = React.useState("");
const [blockMap, setBlockMap] = React.useState(new Map());
const [output, setOutput] = React.useState();
React.useEffect(() => {
let newBlockMap = new Map();
blocks.forEach((block) => {
newBlockMap.set(block.domain, block);
});
setBlockMap(newBlockMap);
}, [blocks]);
const fileRef = React.useRef();
function error(e) {
setOutput(<div className="error accent">{e}</div>);
throw e;
}
function fileUpload() {
let reader = new FileReader();
reader.addEventListener("load", (e) => {
try {
// TODO: use validatem?
let json = JSON.parse(e.target.result);
json.forEach((block) => {
console.log("block:", block);
});
} catch(e) {
error(e.message);
}
});
reader.readAsText(fileRef.current.files[0]);
}
React.useEffect(() => {
if (fileRef && fileRef.current) {
fileRef.current.addEventListener("change", fileUpload);
}
return function cleanup() {
fileRef.current.removeEventListener("change", fileUpload);
};
});
function textImport() {
Promise.try(() => {
if (bulk[0] == "[") {
// assume it's json
return JSON.parse(bulk);
} else {
return bulk.split("\n").map((val) => {
return {
domain: val.trim()
};
});
}
}).then((domains) => {
console.log(domains);
let before = domains.length;
setOutput(`Importing ${before} domain(s)`);
domains = domains.filter(({domain}) => {
return (domain != "" && !blockMap.has(domain));
});
setOutput(<span>{output}<br/>{`Deduplicated ${before - domains.length}/${before} with existing blocks, adding ${domains.length} block(s)`}</span>);
if (domains.length > 0) {
let data = new FormData();
data.append("domains", new Blob([JSON.stringify(domains)], {type: "application/json"}), "import.json");
return oauth.apiRequest("/api/v1/admin/domain_blocks?import=true", "POST", data, "form");
}
}).then((json) => {
console.log("bulk import result:", json);
setBlocks(sortBlocks(deduplicateBlocks([...json, ...blocks])));
}).catch((e) => {
error(e.message);
});
}
function textExport() {
setBulk(blocks.reduce((str, val) => {
if (typeof str == "object") {
return str.domain;
} else {
return str + "\n" + val.domain;
}
}));
}
function jsonExport() {
Promise.try(() => {
return oauth.apiRequest("/api/v1/admin/domain_blocks?export=true", "GET");
}).then((json) => {
fileDownload(JSON.stringify(json), "block-export.json");
}).catch((e) => {
error(e);
});
}
function textAreaUpdate(e) {
setBulk(e.target.value);
}
return (
<React.Fragment>
<h3>Bulk import/export</h3>
<label htmlFor="bulk">Domains, one per line:</label>
<textarea value={bulk} rows={20} onChange={textAreaUpdate}></textarea>
<div className="controls">
<button onClick={textImport}>Import All From Field</button>
<button onClick={textExport}>Export To Field</button>
<label className="button" htmlFor="upload">Upload .json</label>
<button onClick={jsonExport}>Download .json</button>
</div>
{output}
<input type="file" id="upload" className="hidden" ref={fileRef}></input>
</React.Fragment>
);
}
function AddBlock({oauth, blocks, setBlocks}) {
const [domain, setDomain] = React.useState("");
const [type, setType] = React.useState("suspend");
const [obfuscated, setObfuscated] = React.useState(false);
const [privateDescription, setPrivateDescription] = React.useState("");
const [publicDescription, setPublicDescription] = React.useState("");
function addBlock() {
console.log(`${type}ing`, domain);
Promise.try(() => {
return oauth.apiRequest("/api/v1/admin/domain_blocks", "POST", {
domain: domain,
obfuscate: obfuscated,
private_comment: privateDescription,
public_comment: publicDescription
}, "json");
}).then((json) => {
setDomain("");
setPrivateDescription("");
setPublicDescription("");
setBlocks([json, ...blocks]);
});
}
function onDomainChange(e) {
setDomain(e.target.value);
}
function onTypeChange(e) {
setType(e.target.value);
}
function onKeyDown(e) {
if (e.key == "Enter") {
addBlock();
}
}
return (
<React.Fragment>
<h3>Add Block:</h3>
<div className="addblock">
<input id="domain" placeholder="instance" onChange={onDomainChange} value={domain} onKeyDown={onKeyDown} />
<select value={type} onChange={onTypeChange}>
<option id="suspend">Suspend</option>
<option id="silence">Silence</option>
</select>
<button onClick={addBlock}>Add</button>
<div>
<label htmlFor="private">Private description:</label><br/>
<textarea id="private" value={privateDescription} onChange={(e) => setPrivateDescription(e.target.value)}></textarea>
</div>
<div>
<label htmlFor="public">Public description:</label><br/>
<textarea id="public" value={publicDescription} onChange={(e) => setPublicDescription(e.target.value)}></textarea>
</div>
<div className="single">
<label htmlFor="obfuscate">Obfuscate:</label>
<input id="obfuscate" type="checkbox" value={obfuscated} onChange={(e) => setObfuscated(e.target.checked)}/>
</div>
</div>
</React.Fragment>
);
}
// function Blocklist() {
// return (
// <section className="blocklists">
// <h1>Blocklists</h1>
// </section>
// );
// }

View file

@ -1,64 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const ReactDom = require("react-dom");
const createPanel = require("../lib/panel");
const Settings = require("./settings");
const Blocks = require("./blocks");
require("../base.css");
require("./style.css");
function AdminPanel({oauth}) {
/*
Features: (issue #78)
- [ ] Instance information updating
GET /api/v1/instance PATCH /api/v1/instance
- [ ] Domain block creation, viewing, and deletion
GET /api/v1/admin/domain_blocks
POST /api/v1/admin/domain_blocks
GET /api/v1/admin/domain_blocks/DOMAIN_BLOCK_ID, DELETE /api/v1/admin/domain_blocks/DOMAIN_BLOCK_ID
- [ ] Blocklist import/export
GET /api/v1/admin/domain_blocks?export=true
POST json file as form field domains to /api/v1/admin/domain_blocks
*/
return (
<React.Fragment>
<Logout oauth={oauth}/>
<Settings oauth={oauth} />
<Blocks oauth={oauth}/>
</React.Fragment>
);
}
function Logout({oauth}) {
return (
<div>
<button onClick={oauth.logout}>Logout</button>
</div>
);
}
createPanel("GoToSocial Admin Panel", ["admin"], AdminPanel);

View file

@ -1,182 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
module.exports = function Settings({oauth}) {
const [info, setInfo] = React.useState({});
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("Fetching instance info");
React.useEffect(() => {
Promise.try(() => {
return oauth.apiRequest("/api/v1/instance", "GET");
}).then((json) => {
setInfo(json);
}).catch((e) => {
setError(e.message);
setStatus("");
});
}, []);
function submit() {
setStatus("PATCHing");
setError("");
return Promise.try(() => {
let formDataInfo = new FormData();
Object.entries(info).forEach(([key, val]) => {
if (key == "contact_account") {
key = "contact_username";
val = val.username;
}
if (key == "email") {
key = "contact_email";
}
if (typeof val != "object") {
formDataInfo.append(key, val);
}
});
return oauth.apiRequest("/api/v1/instance", "PATCH", formDataInfo, "form");
}).then((json) => {
setStatus("Config saved");
console.log(json);
}).catch((e) => {
setError(e.message);
setStatus("");
});
}
return (
<section className="info login">
<h1>Instance Information <button onClick={submit}>Save</button></h1>
<div className="error accent">
{errorMsg}
</div>
<div>
{statusMsg}
</div>
<form onSubmit={(e) => e.preventDefault()}>
{editableObject(info)}
</form>
</section>
);
};
function editableObject(obj, path=[]) {
const readOnlyKeys = ["uri", "version", "urls_streaming_api", "stats"];
const hiddenKeys = ["contact_account_", "urls"];
const explicitShownKeys = ["contact_account_username"];
const implementedKeys = "title, contact_account_username, email, short_description, description, terms, avatar, header".split(", ");
const textareaKeys = ["short_description", "description"]
let listing = Object.entries(obj).map(([key, val]) => {
let fullkey = [...path, key].join("_");
if (
hiddenKeys.includes(fullkey) ||
hiddenKeys.includes(path.join("_")+"_") // also match just parent path
) {
if (!explicitShownKeys.includes(fullkey)) {
return null;
}
}
if (Array.isArray(val)) {
// FIXME: handle this
} else if (typeof val == "object") {
return (<React.Fragment key={fullkey}>
{editableObject(val, [...path, key])}
</React.Fragment>);
}
let isImplemented = "";
if (!implementedKeys.includes(fullkey)) {
isImplemented = " notImplemented";
}
let isReadOnly = (
readOnlyKeys.includes(fullkey) ||
readOnlyKeys.includes(path.join("_")) ||
isImplemented != ""
);
let label = key.replace(/_/g, " ");
if (path.length > 0) {
label = `\u00A0`.repeat(4 * path.length) + label;
}
let inputProps;
let changeFunc;
if (val === true || val === false) {
inputProps = {
type: "checkbox",
defaultChecked: val,
disabled: isReadOnly
};
changeFunc = (e) => e.target.checked;
} else if (val.length != 0 && !isNaN(val)) {
inputProps = {
type: "number",
defaultValue: val,
readOnly: isReadOnly
};
changeFunc = (e) => e.target.value;
} else {
inputProps = {
type: "text",
defaultValue: val,
readOnly: isReadOnly
};
changeFunc = (e) => e.target.value;
}
function setRef(element) {
if (element != null) {
element.addEventListener("change", (e) => {
obj[key] = changeFunc(e);
});
}
}
let field;
if (textareaKeys.includes(fullkey)) {
field = <textarea className={isImplemented} ref={setRef} {...inputProps}></textarea>
} else {
field = <input className={isImplemented} ref={setRef} {...inputProps} />
}
return (
<React.Fragment key={fullkey}>
<label htmlFor={key} className="capitalize">{label}</label>
<div className={isImplemented}>
{field}
</div>
</React.Fragment>
);
});
return (
<React.Fragment>
{path != "" &&
<><b>{path}:</b> <span id="filler"></span></>
}
{listing}
</React.Fragment>
);
}

View file

@ -1,106 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
section.info {
form {
grid-template-columns: auto 1fr;
width: calc(100% - 0.35rem);
input {
width: 100%;
line-height: 1.5rem;
}
label, input {
padding: 0.2rem 0.5rem;
}
input[type=checkbox] {
justify-self: start;
width: initial;
}
input:read-only {
border: none;
}
input:invalid {
border-color: red;
}
}
textarea {
width: 100%;
height: 8rem;
}
h1 {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
}
section.blocks {
.overflow {
max-height: 80vh;
overflow-y: auto;
}
.blocklist {
display: grid;
grid-template-columns: auto 1fr auto;
grid-gap: 0.35rem 0;
div {
background: rgb(70, 79, 88);
padding: 0.2rem 0.4rem;
}
}
.addblock {
display: grid;
grid-template-columns: 1fr auto auto;
grid-gap: 0.35rem;
input, select {
font-size: 1.2rem;
}
input, select, textarea {
padding: 0.5rem;
}
div {
grid-column: 1/4;
}
div.single input {
width: initial;
}
}
h3 {
margin-bottom: 0;
}
.controls {
display: flex;
gap: 0.5rem;
}
}

View file

@ -1,67 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
body {
grid-template-rows: auto 1fr;
}
.capitalize {
text-transform: capitalize;
}
section {
margin-bottom: 1rem;
}
input, select, textarea {
box-sizing: border-box;
}
.error {
font-weight: bold;
}
.hidden {
display: none;
}
.messagebutton {
margin-top: 1rem;
display: flex;
gap: 1rem;
align-items: center;
button {
white-space: nowrap;
}
}
.notImplemented {
border: 2px solid rgb(70, 79, 88);
background: repeating-linear-gradient(
-45deg,
#525c66,
#525c66 10px,
rgb(70, 79, 88) 10px,
rgb(70, 79, 88) 20px
) !important;
}
.mono {
font-family: monospace;
}

View file

@ -1,227 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
function getCurrentUrl() {
return window.location.origin + window.location.pathname; // strips ?query=string and #hash
}
module.exports = function oauthClient(config, initState) {
/* config:
instance: instance domain (https://testingtesting123.xyz)
client_name: "GoToSocial Admin Panel"
scope: []
website:
*/
let state = initState;
if (initState == undefined) {
state = localStorage.getItem("oauth");
if (state == undefined) {
state = {
config
};
storeState();
} else {
state = JSON.parse(state);
}
}
function storeState() {
localStorage.setItem("oauth", JSON.stringify(state));
}
/* register app
/api/v1/apps
*/
function register() {
if (state.client_id != undefined) {
return true; // we already have a registration
}
let url = new URL(config.instance);
url.pathname = "/api/v1/apps";
return fetch(url.href, {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
client_name: config.client_name,
redirect_uris: getCurrentUrl(),
scopes: config.scope.join(" "),
website: getCurrentUrl()
})
}).then((res) => {
if (res.status != 200) {
throw res;
}
return res.json();
}).then((json) => {
state.client_id = json.client_id;
state.client_secret = json.client_secret;
storeState();
});
}
/* authorize:
/oauth/authorize
?client_id=CLIENT_ID
&redirect_uri=window.location.href
&response_type=code
&scope=admin
*/
function authorize() {
let url = new URL(config.instance);
url.pathname = "/oauth/authorize";
url.searchParams.set("client_id", state.client_id);
url.searchParams.set("redirect_uri", getCurrentUrl());
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", config.scope.join(" "));
window.location.assign(url.href);
}
function callback() {
if (state.access_token != undefined) {
return; // we're already done :)
}
let params = (new URL(window.location)).searchParams;
let token = params.get("code");
if (token != null) {
console.log("got token callback:", token);
}
return authorizeToken(token)
.catch((e) => {
console.log("Error processing oauth callback:", e);
logout(); // just to be sure
});
}
function authorizeToken(token) {
let url = new URL(config.instance);
url.pathname = "/oauth/token";
return fetch(url.href, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
client_id: state.client_id,
client_secret: state.client_secret,
redirect_uri: getCurrentUrl(),
grant_type: "authorization_code",
code: token
})
}).then((res) => {
if (res.status != 200) {
throw res;
}
return res.json();
}).then((json) => {
state.access_token = json.access_token;
storeState();
window.location = getCurrentUrl(); // clear ?token=
});
}
function isAuthorized() {
return (state.access_token != undefined);
}
function apiRequest(path, method, data, type="json", accept="json") {
if (!isAuthorized()) {
throw new Error("Not Authenticated");
}
let url = new URL(config.instance);
let [p, s] = path.split("?");
url.pathname = p;
if (s != undefined) {
url.search = s;
}
let headers = {
"Authorization": `Bearer ${state.access_token}`,
"Accept": accept == "json" ? "application/json" : "*/*"
};
let body = data;
if (type == "json" && body != undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(data);
}
return fetch(url.href, {
method,
headers,
body
}).then((res) => {
return Promise.all([res.json(), res]);
}).then(([json, res]) => {
if (res.status != 200) {
if (json.error) {
throw new Error(json.error);
} else {
throw new Error(`${res.status}: ${res.statusText}`);
}
} else {
return json;
}
}).catch(e => {
if (e instanceof SyntaxError) {
throw new Error("Error: The GtS API returned a non-json error. This usually means a network problem, or an issue with your instance's reverse proxy configuration.", {cause: e});
} else {
throw e;
}
});
}
function logout() {
let url = new URL(config.instance);
url.pathname = "/oauth/revoke";
return fetch(url.href, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
client_id: state.client_id,
client_secret: state.client_secret,
token: state.access_token,
})
}).then((res) => {
if (res.status != 200) {
// GoToSocial doesn't actually implement this route yet,
// so error is to be expected
return;
}
return res.json();
}).catch(() => {
// see above
}).then(() => {
localStorage.removeItem("oauth");
window.location = getCurrentUrl();
});
}
return {
register, authorize, callback, isAuthorized, apiRequest, logout
};
};

View file

@ -1,151 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const React = require("react");
const Promise = require("bluebird");
const Submit = require("../../lib/submit");
module.exports = function Basic({oauth, account, allowCustomCSS}) {
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
const [headerFile, setHeaderFile] = React.useState(undefined);
const [headerSrc, setHeaderSrc] = React.useState("");
const [avatarFile, setAvatarFile] = React.useState(undefined);
const [avatarSrc, setAvatarSrc] = React.useState("");
const [displayName, setDisplayName] = React.useState("");
const [bio, setBio] = React.useState("");
const [locked, setLocked] = React.useState(false);
const [customCSS, setCustomCSS] = React.useState("");
React.useEffect(() => {
setHeaderSrc(account.header);
setAvatarSrc(account.avatar);
setDisplayName(account.display_name);
setBio(account.source ? account.source.note : "");
setLocked(account.locked);
setCustomCSS((allowCustomCSS && account.custom_css) ? account.custom_css : "");
}, [account, setHeaderSrc, setAvatarSrc, setDisplayName, setBio, setLocked, setCustomCSS]);
const headerOnChange = (e) => {
setHeaderFile(e.target.files[0]);
setHeaderSrc(URL.createObjectURL(e.target.files[0]));
};
const avatarOnChange = (e) => {
setAvatarFile(e.target.files[0]);
setAvatarSrc(URL.createObjectURL(e.target.files[0]));
};
const submit = (e) => {
e.preventDefault();
setStatus("PATCHing");
setError("");
return Promise.try(() => {
let formDataInfo = new FormData();
if (headerFile) {
formDataInfo.set("header", headerFile);
}
if (avatarFile) {
formDataInfo.set("avatar", avatarFile);
}
formDataInfo.set("display_name", displayName);
formDataInfo.set("note", bio);
formDataInfo.set("locked", locked);
if (allowCustomCSS) {
formDataInfo.set("custom_css", customCSS);
}
return oauth.apiRequest("/api/v1/accounts/update_credentials", "PATCH", formDataInfo, "form");
}).then((json) => {
setStatus("Saved!");
setHeaderSrc(json.header);
setAvatarSrc(json.avatar);
setDisplayName(json.display_name);
setBio(json.source.note);
setLocked(json.locked);
setCustomCSS(allowCustomCSS && json.custom_css ? json.custom_css : "");
}).catch((e) => {
setError(e.message);
setStatus("");
});
};
return (
<section className="basic">
<h1>@{account.username}&apos;s Profile Info</h1>
<form>
<div className="labelinput">
<label htmlFor="header">Header</label>
<div className="border">
<img className="headerpreview" src={headerSrc} alt={headerSrc ? `header image for ${account.username}` : "None set"}/>
<div>
<label htmlFor="header" className="file-input button">Browse</label>
<span>{headerFile ? headerFile.name : ""}</span>
</div>
</div>
<input className="hidden" id="header" type="file" accept="image/*" onChange={headerOnChange}/>
</div>
<div className="labelinput">
<label htmlFor="avatar">Avatar</label>
<div className="border">
<img className="avatarpreview" src={avatarSrc} alt={headerSrc ? `avatar image for ${account.username}` : "None set"}/>
<div>
<label htmlFor="avatar" className="file-input button">Browse</label>
<span>{avatarFile ? avatarFile.name : ""}</span>
</div>
</div>
<input className="hidden" id="avatar" type="file" accept="image/*" onChange={avatarOnChange}/>
</div>
<div className="labelinput">
<label htmlFor="displayname">Display Name</label>
<input id="displayname" type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="A GoToSocial user"/>
</div>
<div className="labelinput">
<label htmlFor="bio">Bio</label>
<textarea id="bio" value={bio} onChange={(e) => setBio(e.target.value)} placeholder="Just trying out GoToSocial, my pronouns are they/them and I like sloths."/>
</div>
{ !allowCustomCSS ? null :
<div className="labelinput">
<label htmlFor="customcss">Custom CSS</label>
<textarea className="mono" id="customcss" value={customCSS} onChange={(e) => setCustomCSS(e.target.value)}/>
<a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about custom CSS (opens in a new tab)</a>
</div>
}
<div className="labelcheckbox">
<label htmlFor="locked">Manually approve follow requests</label>
<input id="locked" type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)}/>
</div>
<Submit onClick={submit} label="Save profile info" errorMsg={errorMsg} statusMsg={statusMsg}/>
</form>
</section>
);
};

View file

@ -1,76 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const ReactDom = require("react-dom");
const createPanel = require("../lib/panel");
const Basic = require("./basic");
const Posts = require("./posts");
const Security = require("./security");
require("../base.css");
require("./style.css");
function UserPanel({oauth}) {
const [account, setAccount] = React.useState({});
const [allowCustomCSS, setAllowCustomCSS] = React.useState(false);
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("Fetching user info");
React.useEffect(() => {
}, [oauth, setAllowCustomCSS, setError, setStatus]);
React.useEffect(() => {
Promise.try(() => {
return oauth.apiRequest("/api/v1/instance", "GET");
}).then((json) => {
setAllowCustomCSS(json.configuration.accounts.allow_custom_css);
Promise.try(() => {
return oauth.apiRequest("/api/v1/accounts/verify_credentials", "GET");
}).then((json) => {
setAccount(json);
}).catch((e) => {
setError(e.message);
setStatus("");
});
}).catch((e) => {
setError(e.message);
setStatus("");
});
}, [oauth, setAllowCustomCSS, setAccount, setError, setStatus]);
return (
<React.Fragment>
<div>
<button className="logout" onClick={oauth.logout}>Log out of settings panel</button>
</div>
<Basic oauth={oauth} account={account} allowCustomCSS={allowCustomCSS}/>
<Posts oauth={oauth} account={account}/>
<Security oauth={oauth}/>
</React.Fragment>
);
}
createPanel("GoToSocial User Panel", ["read write"], UserPanel);

View file

@ -1,107 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const React = require("react");
const Promise = require("bluebird");
const Languages = require("./languages");
const Submit = require("../../lib/submit");
module.exports = function Posts({oauth, account}) {
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
const [language, setLanguage] = React.useState("");
const [privacy, setPrivacy] = React.useState("");
const [format, setFormat] = React.useState("");
const [sensitive, setSensitive] = React.useState(false);
React.useEffect(() => {
if (account.source) {
setLanguage(account.source.language.toUpperCase());
setPrivacy(account.source.privacy);
setSensitive(account.source.sensitive ? account.source.sensitive : false);
setFormat(account.source.status_format ? account.source.status_format : "plain");
}
}, [account, setSensitive, setPrivacy]);
const submit = (e) => {
e.preventDefault();
setStatus("PATCHing");
setError("");
return Promise.try(() => {
let formDataInfo = new FormData();
formDataInfo.set("source[language]", language);
formDataInfo.set("source[privacy]", privacy);
formDataInfo.set("source[sensitive]", sensitive);
formDataInfo.set("source[status_format]", format);
return oauth.apiRequest("/api/v1/accounts/update_credentials", "PATCH", formDataInfo, "form");
}).then((json) => {
setStatus("Saved!");
setLanguage(json.source.language.toUpperCase());
setPrivacy(json.source.privacy);
setSensitive(json.source.sensitive ? json.source.sensitive : false);
setFormat(json.source.status_format ? json.source.status_format : "plain");
}).catch((e) => {
setError(e.message);
setStatus("");
});
};
return (
<section className="posts">
<h1>Post Settings</h1>
<form>
<div className="labelselect">
<label htmlFor="language">Default post language</label>
<select id="language" autoComplete="language" value={language} onChange={(e) => setLanguage(e.target.value)}>
<Languages />
</select>
</div>
<div className="labelselect">
<label htmlFor="privacy">Default post privacy</label>
<select id="privacy" value={privacy} onChange={(e) => setPrivacy(e.target.value)}>
<option value="private">Private / followers-only)</option>
<option value="unlisted">Unlisted</option>
<option value="public">Public</option>
</select>
<a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#privacy-settings" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post privacy settings (opens in a new tab)</a>
</div>
<div className="labelselect">
<label htmlFor="format">Default post format</label>
<select id="format" value={format} onChange={(e) => setFormat(e.target.value)}>
<option value="plain">Plain (default)</option>
<option value="markdown">Markdown</option>
</select>
<a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#input-types" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post format settings (opens in a new tab)</a>
</div>
<div className="labelcheckbox">
<label htmlFor="sensitive">Mark my posts as sensitive by default</label>
<input id="sensitive" type="checkbox" checked={sensitive} onChange={(e) => setSensitive(e.target.checked)}/>
</div>
<Submit onClick={submit} label="Save post settings" errorMsg={errorMsg} statusMsg={statusMsg}/>
</form>
</section>
);
};

View file

@ -1,80 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const React = require("react");
const Promise = require("bluebird");
const Submit = require("../../lib/submit");
module.exports = function Security({oauth}) {
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
const [oldPassword, setOldPassword] = React.useState("");
const [newPassword, setNewPassword] = React.useState("");
const [newPasswordConfirm, setNewPasswordConfirm] = React.useState("");
const submit = (e) => {
e.preventDefault();
if (newPassword !== newPasswordConfirm) {
setError("New password and confirm new password did not match!");
return;
}
setStatus("PATCHing");
setError("");
return Promise.try(() => {
let formDataInfo = new FormData();
formDataInfo.set("old_password", oldPassword);
formDataInfo.set("new_password", newPassword);
return oauth.apiRequest("/api/v1/user/password_change", "POST", formDataInfo, "form");
}).then((json) => {
setStatus("Saved!");
setOldPassword("");
setNewPassword("");
setNewPasswordConfirm("");
}).catch((e) => {
setError(e.message);
setStatus("");
});
};
return (
<section className="security">
<h1>Password Change</h1>
<form>
<div className="labelinput">
<label htmlFor="password">Current password</label>
<input name="password" id="password" type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
</div>
<div className="labelinput">
<label htmlFor="new-password">New password</label>
<input name="new-password" id="new-password" type="password" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
</div>
<div className="labelinput">
<label htmlFor="confirm-new-password">Confirm new password</label>
<input name="confirm-new-password" id="confirm-new-password" type="password" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} />
</div>
<Submit onClick={submit} label="Save new password" errorMsg={errorMsg} statusMsg={statusMsg}/>
</form>
</section>
);
};

View file

@ -1,118 +0,0 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
section.basic, section.posts, section.security {
form {
display: flex;
flex-direction: column;
gap: 1rem;
input, textarea {
width: 100%;
line-height: 1.5rem;
}
input[type=checkbox] {
justify-self: start;
width: initial;
}
input:read-only {
border: none;
}
input:invalid {
border-color: red;
}
}
textarea {
width: 100%;
height: 8rem;
}
h1 {
margin-bottom: 0.5rem;
}
img {
display: flex;
justify-content: center;
align-items: center;
border: $boxshadow_border;
box-shadow: $box-shadow;
object-fit: cover;
border-radius: 0.2rem;
box-sizing: border-box;
margin-bottom: 0.5rem;
}
.avatarpreview {
height: 8.5rem;
width: 8.5rem;
}
.headerpreview {
width: 100%;
aspect-ratio: 3 / 1;
overflow: hidden;
}
.moreinfolink {
font-size: 0.9em;
}
}
.labelinput .border {
border-radius: 0.2rem;
border: 0.15rem solid $border_accent;
padding: 0.3rem;
display: flex;
flex-direction: column;
}
.file-input.button {
display: inline-block;
font-size: 1rem;
font-weight: normal;
padding: 0.3rem 0.3rem;
align-self: flex-start;
/* background: $border_accent; */
margin-right: 0.2rem;
}
.labelinput, .labelselect {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.labelcheckbox {
display: flex;
gap: 0.4rem;
}
.titlesave {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.logout {
margin-bottom: 2rem;
}

View file

@ -0,0 +1,61 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const Submit = require("../components/submit");
const api = require("../lib/api");
const submit = require("../lib/submit");
module.exports = function AdminActionPanel() {
const dispatch = Redux.useDispatch();
const [days, setDays] = React.useState(30);
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
const removeMedia = submit(
() => dispatch(api.admin.mediaCleanup(days)),
{setStatus, setError}
);
return (
<>
<h1>Admin Actions</h1>
<div>
<h2>Media cleanup</h2>
<p>
Clean up remote media older than the specified number of days.
If the remote instance is still online they will be refetched when needed.
Also cleans up unused headers and avatars from the media cache.
</p>
<div>
<label htmlFor="days">Days: </label>
<input id="days" type="number" value={days} onChange={(e) => setDays(e.target.value)}/>
</div>
<Submit onClick={removeMedia} label="Remove media" errorMsg={errorMsg} statusMsg={statusMsg} />
</div>
</>
);
};

View file

@ -0,0 +1,212 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const {Switch, Route, Link, Redirect, useRoute, useLocation} = require("wouter");
const Submit = require("../components/submit");
const FakeToot = require("../components/fake-toot");
const { formFields } = require("../components/form-fields");
const api = require("../lib/api");
const adminActions = require("../redux/reducers/admin").actions;
const submit = require("../lib/submit");
const base = "/settings/admin/custom-emoji";
module.exports = function CustomEmoji() {
return (
<Switch>
<Route path={`${base}/:emojiId`}>
<EmojiDetailWrapped />
</Route>
<EmojiOverview />
</Switch>
);
};
function EmojiOverview() {
const dispatch = Redux.useDispatch();
const [loaded, setLoaded] = React.useState(false);
const [errorMsg, setError] = React.useState("");
React.useEffect(() => {
if (!loaded) {
Promise.try(() => {
return dispatch(api.admin.fetchCustomEmoji());
}).then(() => {
setLoaded(true);
}).catch((e) => {
setLoaded(true);
setError(e.message);
});
}
}, []);
if (!loaded) {
return (
<>
<h1>Custom Emoji</h1>
Loading...
</>
);
}
return (
<>
<h1>Custom Emoji</h1>
<EmojiList/>
<NewEmoji/>
{errorMsg.length > 0 &&
<div className="error accent">{errorMsg}</div>
}
</>
);
}
const NewEmojiForm = formFields(adminActions.updateNewEmojiVal, (state) => state.admin.newEmoji);
function NewEmoji() {
const dispatch = Redux.useDispatch();
const newEmojiForm = Redux.useSelector((state) => state.admin.newEmoji);
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
const uploadEmoji = submit(
() => dispatch(api.admin.newEmoji()),
{
setStatus, setError,
onSuccess: function() {
URL.revokeObjectURL(newEmojiForm.image);
return Promise.all([
dispatch(adminActions.updateNewEmojiVal(["image", undefined])),
dispatch(adminActions.updateNewEmojiVal(["imageFile", undefined])),
dispatch(adminActions.updateNewEmojiVal(["shortcode", ""])),
]);
}
}
);
React.useEffect(() => {
if (newEmojiForm.shortcode.length == 0) {
if (newEmojiForm.imageFile != undefined) {
let [name, ext] = newEmojiForm.imageFile.name.split(".");
dispatch(adminActions.updateNewEmojiVal(["shortcode", name]));
}
}
});
let emojiOrShortcode = `:${newEmojiForm.shortcode}:`;
if (newEmojiForm.image != undefined) {
emojiOrShortcode = <img
className="emoji"
src={newEmojiForm.image}
title={`:${newEmojiForm.shortcode}:`}
alt={newEmojiForm.shortcode}
/>;
}
return (
<div>
<h2>Add new custom emoji</h2>
<FakeToot>
Look at this new custom emoji {emojiOrShortcode} isn&apos;t it cool?
</FakeToot>
<NewEmojiForm.File
id="image"
name="Image"
fileType="image/png,image/gif"
showSize={true}
maxSize={50 * 1000}
/>
<NewEmojiForm.TextInput
id="shortcode"
name="Shortcode (without : :), must be unique on the instance"
placeHolder="blobcat"
/>
<Submit onClick={uploadEmoji} label="Upload" errorMsg={errorMsg} statusMsg={statusMsg} />
</div>
);
}
function EmojiList() {
const emoji = Redux.useSelector((state) => state.admin.emoji);
return (
<div>
<h2>Overview</h2>
<div className="list emoji-list">
{Object.entries(emoji).map(([category, entries]) => {
return <EmojiCategory key={category} category={category} entries={entries}/>;
})}
</div>
</div>
);
}
function EmojiCategory({category, entries}) {
return (
<div className="entry">
<b>{category}</b>
<div className="emoji-group">
{entries.map((e) => {
return (
// <Link key={e.static_url} to={`${base}/${e.shortcode}`}>
<Link key={e.static_url} to={`${base}`}>
<a>
<img src={e.static_url} alt={e.shortcode} title={`:${e.shortcode}:`}/>
</a>
</Link>
);
})}
</div>
</div>
);
}
function EmojiDetailWrapped() {
/* We wrap the component to generate formFields with a setter depending on the domain
if formFields() is used inside the same component that is re-rendered with their state,
inputs get re-created on every change, causing them to lose focus, and bad performance
*/
let [_match, {emojiId}] = useRoute(`${base}/:emojiId`);
function alterEmoji([key, val]) {
return adminActions.updateDomainBlockVal([emojiId, key, val]);
}
const fields = formFields(alterEmoji, (state) => state.admin.blockedInstances[emojiId]);
return <EmojiDetail id={emojiId} Form={fields} />;
}
function EmojiDetail({id, Form}) {
return (
"Not implemented yet"
);
}

View file

@ -0,0 +1,382 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const {Switch, Route, Link, Redirect, useRoute, useLocation} = require("wouter");
const fileDownload = require("js-file-download");
const { formFields } = require("../components/form-fields");
const api = require("../lib/api");
const adminActions = require("../redux/reducers/admin").actions;
const submit = require("../lib/submit");
const base = "/settings/admin/federation";
// const {
// TextInput,
// TextArea,
// File
// } = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings);
module.exports = function AdminSettings() {
const dispatch = Redux.useDispatch();
// const instance = Redux.useSelector(state => state.instances.adminSettings);
const loadedBlockedInstances = Redux.useSelector(state => state.admin.loadedBlockedInstances);
React.useEffect(() => {
if (!loadedBlockedInstances ) {
Promise.try(() => {
return dispatch(api.admin.fetchDomainBlocks());
});
}
}, []);
if (!loadedBlockedInstances) {
return (
<div>
<h1>Federation</h1>
Loading...
</div>
);
}
return (
<Switch>
<Route path={`${base}/:domain`}>
<InstancePageWrapped />
</Route>
<InstanceOverview />
</Switch>
);
};
function InstanceOverview() {
const [filter, setFilter] = React.useState("");
const blockedInstances = Redux.useSelector(state => state.admin.blockedInstances);
const [_location, setLocation] = useLocation();
function filterFormSubmit(e) {
e.preventDefault();
setLocation(`${base}/${filter}`);
}
return (
<>
<h1>Federation</h1>
Here you can see an overview of blocked instances.
<div className="instance-list">
<h2>Blocked instances</h2>
<form action={`${base}/view`} className="filter" role="search" onSubmit={filterFormSubmit}>
<input name="domain" value={filter} onChange={(e) => setFilter(e.target.value)}/>
<Link to={`${base}/${filter}`}><a className="button">Add block</a></Link>
</form>
<div className="list">
{Object.values(blockedInstances).filter((a) => a.domain.startsWith(filter)).map((entry) => {
return (
<Link key={entry.domain} to={`${base}/${entry.domain}`}>
<a className="entry nounderline">
<span id="domain">
{entry.domain}
</span>
<span id="date">
{new Date(entry.created_at).toLocaleString()}
</span>
</a>
</Link>
);
})}
</div>
</div>
<BulkBlocking/>
</>
);
}
const Bulk = formFields(adminActions.updateBulkBlockVal, (state) => state.admin.bulkBlock);
function BulkBlocking() {
const dispatch = Redux.useDispatch();
const {bulkBlock, blockedInstances} = Redux.useSelector(state => state.admin);
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
function importBlocks() {
setStatus("Processing");
setError("");
return Promise.try(() => {
return dispatch(api.admin.bulkDomainBlock());
}).then(({success, invalidDomains}) => {
return Promise.try(() => {
return resetBulk();
}).then(() => {
dispatch(adminActions.updateBulkBlockVal(["list", invalidDomains.join("\n")]));
let stat = "";
if (success == 0) {
return setError("No valid domains in import");
} else if (success == 1) {
stat = "Imported 1 domain";
} else {
stat = `Imported ${success} domains`;
}
if (invalidDomains.length > 0) {
if (invalidDomains.length == 1) {
stat += ", input contained 1 invalid domain.";
} else {
stat += `, input contained ${invalidDomains.length} invalid domains.`;
}
} else {
stat += "!";
}
setStatus(stat);
});
}).catch((e) => {
console.error(e);
setError(e.message);
setStatus("");
});
}
function exportBlocks() {
return Promise.try(() => {
setStatus("Exporting");
setError("");
let asJSON = bulkBlock.exportType.startsWith("json");
let _asCSV = bulkBlock.exportType.startsWith("csv");
let exportList = Object.values(blockedInstances).map((entry) => {
if (asJSON) {
return {
domain: entry.domain,
public_comment: entry.public_comment
};
} else {
return entry.domain;
}
});
if (bulkBlock.exportType == "json") {
return dispatch(adminActions.updateBulkBlockVal(["list", JSON.stringify(exportList)]));
} else if (bulkBlock.exportType == "json-download") {
return fileDownload(JSON.stringify(exportList), "block-export.json");
} else if (bulkBlock.exportType == "plain") {
return dispatch(adminActions.updateBulkBlockVal(["list", exportList.join("\n")]));
}
}).then(() => {
setStatus("Exported!");
}).catch((e) => {
setError(e.message);
setStatus("");
});
}
function resetBulk(e) {
if (e != undefined) {
e.preventDefault();
}
return dispatch(adminActions.resetBulkBlockVal());
}
function disableInfoFields(props={}) {
if (bulkBlock.list[0] == "[") {
return {
...props,
disabled: true,
placeHolder: "Domain list is a JSON import, input disabled"
};
} else {
return props;
}
}
return (
<div className="bulk">
<h2>Import / Export <a onClick={resetBulk}>reset</a></h2>
<Bulk.TextArea
id="list"
name="Domains, one per line"
placeHolder={`google.com\nfacebook.com`}
/>
<Bulk.TextArea
id="public_comment"
name="Public comment"
inputProps={disableInfoFields({rows: 3})}
/>
<Bulk.TextArea
id="private_comment"
name="Private comment"
inputProps={disableInfoFields({rows: 3})}
/>
<Bulk.Checkbox
id="obfuscate"
name="Obfuscate domains? "
inputProps={disableInfoFields()}
/>
<div className="hidden">
<Bulk.File
id="json"
fileType="application/json"
withPreview={false}
/>
</div>
<div className="messagebutton">
<div>
<button type="submit" onClick={importBlocks}>Import</button>
</div>
<div>
<button type="submit" onClick={exportBlocks}>Export</button>
<Bulk.Select id="exportType" name="Export type" options={
<>
<option value="plain">One per line in text field</option>
<option value="json">JSON in text field</option>
<option value="json-download">JSON file download</option>
<option disabled value="csv">CSV in text field (glitch-soc)</option>
<option disabled value="csv-download">CSV file download (glitch-soc)</option>
</>
}/>
</div>
<br/>
<div>
{errorMsg.length > 0 &&
<div className="error accent">{errorMsg}</div>
}
{statusMsg.length > 0 &&
<div className="accent">{statusMsg}</div>
}
</div>
</div>
</div>
);
}
function BackButton() {
return (
<Link to={base}>
<a className="button">&lt; back</a>
</Link>
);
}
function InstancePageWrapped() {
/* We wrap the component to generate formFields with a setter depending on the domain
if formFields() is used inside the same component that is re-rendered with their state,
inputs get re-created on every change, causing them to lose focus, and bad performance
*/
let [_match, {domain}] = useRoute(`${base}/:domain`);
if (domain == "view") { // from form field submission
let realDomain = (new URL(document.location)).searchParams.get("domain");
if (realDomain == undefined) {
return <Redirect to={base}/>;
} else {
domain = realDomain;
}
}
function alterDomain([key, val]) {
return adminActions.updateDomainBlockVal([domain, key, val]);
}
const fields = formFields(alterDomain, (state) => state.admin.newInstanceBlocks[domain]);
return <InstancePage domain={domain} Form={fields} />;
}
function InstancePage({domain, Form}) {
const dispatch = Redux.useDispatch();
const entry = Redux.useSelector(state => state.admin.newInstanceBlocks[domain]);
const [_location, setLocation] = useLocation();
React.useEffect(() => {
if (entry == undefined) {
dispatch(api.admin.getEditableDomainBlock(domain));
}
}, []);
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
if (entry == undefined) {
return "Loading...";
}
const updateBlock = submit(
() => dispatch(api.admin.updateDomainBlock(domain)),
{setStatus, setError}
);
const removeBlock = submit(
() => dispatch(api.admin.removeDomainBlock(domain)),
{setStatus, setError, startStatus: "Removing", successStatus: "Removed!", onSuccess: () => {
setLocation(base);
}}
);
return (
<div>
<h1><BackButton/> Federation settings for: {domain}</h1>
{entry.new && "No stored block yet, you can add one below:"}
<Form.TextArea
id="public_comment"
name="Public comment"
/>
<Form.TextArea
id="private_comment"
name="Private comment"
/>
<Form.Checkbox
id="obfuscate"
name="Obfuscate domain? "
/>
<div className="messagebutton">
<button type="submit" onClick={updateBlock}>{entry.new ? "Add block" : "Save block"}</button>
{!entry.new &&
<button className="danger" onClick={removeBlock}>Remove block</button>
}
{errorMsg.length > 0 &&
<div className="error accent">{errorMsg}</div>
}
{statusMsg.length > 0 &&
<div className="accent">{statusMsg}</div>
}
</div>
</div>
);
}

View file

@ -0,0 +1,110 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const Submit = require("../components/submit");
const api = require("../lib/api");
const submit = require("../lib/submit");
const adminActions = require("../redux/reducers/instances").actions;
const {
TextInput,
TextArea,
File
} = require("../components/form-fields").formFields(adminActions.setAdminSettingsVal, (state) => state.instances.adminSettings);
module.exports = function AdminSettings() {
const dispatch = Redux.useDispatch();
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
const updateSettings = submit(
() => dispatch(api.admin.updateInstance()),
{setStatus, setError}
);
return (
<div>
<h1>Instance Settings</h1>
<TextInput
id="title"
name="Title"
placeHolder="My GoToSocial instance"
/>
<TextArea
id="short_description"
name="Short description"
placeHolder="A small testing instance for the GoToSocial alpha."
/>
<TextArea
id="description"
name="Full description"
placeHolder="A small testing instance for the GoToSocial alpha."
/>
<TextInput
id="contact_account.username"
name="Contact user (local account username)"
placeHolder="admin"
/>
<TextInput
id="email"
name="Contact email"
placeHolder="admin@example.com"
/>
<TextArea
id="terms"
name="Terms & Conditions"
placeHolder=""
/>
{/* <div className="file-upload">
<h3>Instance avatar</h3>
<div>
<img className="preview avatar" src={instance.avatar} alt={instance.avatar ? `Avatar image for the instance` : "No instance avatar image set"} />
<File
id="avatar"
fileType="image/*"
/>
</div>
</div>
<div className="file-upload">
<h3>Instance header</h3>
<div>
<img className="preview header" src={instance.header} alt={instance.header ? `Header image for the instance` : "No instance header image set"} />
<File
id="header"
fileType="image/*"
/>
</div>
</div> */}
<Submit onClick={updateSettings} label="Save" errorMsg={errorMsg} statusMsg={statusMsg} />
</div>
);
};

View file

@ -0,0 +1,45 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
module.exports = function ErrorFallback({error, resetErrorBoundary}) {
return (
<div className="error">
<p>
{"An error occured, please report this on the "}
<a href="https://github.com/superseriousbusiness/gotosocial/issues">GoToSocial issue tracker</a>
{" or "}
<a href="https://matrix.to/#/#gotosocial-help:superseriousbusiness.org">Matrix support room</a>.
<br/>Include the details below:
</p>
<pre>
{error.name}: {error.message}
</pre>
<pre>
{error.stack}
</pre>
<p>
<button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a>
</p>
</div>
);
};

View file

@ -0,0 +1,43 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const React = require("react");
const Redux = require("react-redux");
module.exports = function FakeToot({children}) {
const account = Redux.useSelector((state) => state.user.profile);
return (
<div className="toot expanded">
<div className="contentgrid">
<span className="avatar">
<img src={account.avatar} alt=""/>
</span>
<span className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</span>
<span className="username">@{account.username}</span>
<div className="text">
<div className="content">
{children}
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,167 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const React = require("react");
const Redux = require("react-redux");
const d = require("dotty");
const prettierBytes = require("prettier-bytes");
function eventListeners(dispatch, setter, obj) {
return {
onTextChange: function (key) {
return function (e) {
dispatch(setter([key, e.target.value]));
};
},
onCheckChange: function (key) {
return function (e) {
dispatch(setter([key, e.target.checked]));
};
},
onFileChange: function (key, withPreview) {
return function (e) {
let file = e.target.files[0];
if (withPreview) {
let old = d.get(obj, key);
if (old != undefined) {
URL.revokeObjectURL(old); // no error revoking a non-Object URL as provided by instance
}
let objectURL = URL.createObjectURL(file);
dispatch(setter([key, objectURL]));
}
dispatch(setter([`${key}File`, file]));
};
}
};
}
function get(state, id, defaultVal) {
let value;
if (id.includes(".")) {
value = d.get(state, id);
} else {
value = state[id];
}
if (value == undefined) {
value = defaultVal;
}
return value;
}
// function removeFile(name) {
// return function(e) {
// e.preventDefault();
// dispatch(user.setProfileVal([name, ""]));
// dispatch(user.setProfileVal([`${name}File`, ""]));
// };
// }
module.exports = {
formFields: function formFields(setter, selector) {
function FormField({
type, id, name, className="", placeHolder="", fileType="", children=null,
options=null, inputProps={}, withPreview=true, showSize=false, maxSize=Infinity
}) {
const dispatch = Redux.useDispatch();
let state = Redux.useSelector(selector);
let {
onTextChange,
onCheckChange,
onFileChange
} = eventListeners(dispatch, setter, state);
let field;
let defaultLabel = true;
if (type == "text") {
field = <input type="text" id={id} value={get(state, id, "")} placeholder={placeHolder} className={className} onChange={onTextChange(id)} {...inputProps}/>;
} else if (type == "textarea") {
field = <textarea type="text" id={id} value={get(state, id, "")} placeholder={placeHolder} className={className} onChange={onTextChange(id)} rows={8} {...inputProps}/>;
} else if (type == "checkbox") {
field = <input type="checkbox" id={id} checked={get(state, id, false)} className={className} onChange={onCheckChange(id)} {...inputProps}/>;
} else if (type == "select") {
field = (
<select id={id} value={get(state, id, "")} className={className} onChange={onTextChange(id)} {...inputProps}>
{options}
</select>
);
} else if (type == "file") {
defaultLabel = false;
let file = get(state, `${id}File`);
let size = null;
if (showSize && file) {
size = `(${prettierBytes(file.size)})`;
if (file.size > maxSize) {
size = <span className="error-text">{size}</span>;
}
}
field = (
<>
<label htmlFor={id} className="file-input button">Browse</label>
<span>
{file ? file.name : "no file selected"} {size}
</span>
{/* <a onClick={removeFile("header")}>remove</a> */}
<input className="hidden" id={id} type="file" accept={fileType} onChange={onFileChange(id, withPreview)} {...inputProps}/>
</>
);
} else {
defaultLabel = false;
field = `unsupported FormField ${type}, this is a developer error`;
}
let label = <label htmlFor={id}>{name}</label>;
return (
<div className={`form-field ${type}`}>
{defaultLabel ? label : null} {field}
{children}
</div>
);
}
return {
TextInput: function(props) {
return <FormField type="text" {...props} />;
},
TextArea: function(props) {
return <FormField type="textarea" {...props} />;
},
Checkbox: function(props) {
return <FormField type="checkbox" {...props} />;
},
Select: function(props) {
return <FormField type="select" {...props} />;
},
File: function(props) {
return <FormField type="file" {...props} />;
},
};
},
eventListeners
};

View file

@ -1,19 +1,19 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
GoToSocial
Copyright (C) 2021-2022 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 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.
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/>.
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/>.
*/
"use strict";

View file

@ -0,0 +1,102 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const { setInstance } = require("../redux/reducers/oauth").actions;
const api = require("../lib/api");
module.exports = function Login({error}) {
const dispatch = Redux.useDispatch();
const [ instanceField, setInstanceField ] = React.useState("");
const [ errorMsg, setErrorMsg ] = React.useState();
const instanceFieldRef = React.useRef("");
React.useEffect(() => {
// check if current domain runs an instance
let currentDomain = window.location.origin;
Promise.try(() => {
return dispatch(api.instance.fetchWithoutStore(currentDomain));
}).then(() => {
if (instanceFieldRef.current.length == 0) { // user hasn't started typing yet
dispatch(setInstance(currentDomain));
instanceFieldRef.current = currentDomain;
setInstanceField(currentDomain);
}
}).catch((e) => {
console.log("Current domain does not host a valid instance: ", e);
});
}, []);
function tryInstance() {
let domain = instanceFieldRef.current;
Promise.try(() => {
return dispatch(api.instance.fetchWithoutStore(domain)).catch((e) => {
// TODO: clearer error messages for common errors
console.log(e);
throw e;
});
}).then(() => {
dispatch(setInstance(domain));
return dispatch(api.oauth.register()).catch((e) => {
console.log(e);
throw e;
});
}).then(() => {
return dispatch(api.oauth.authorize()); // will send user off-page
}).catch((e) => {
setErrorMsg(
<>
<b>{e.type}</b>
<span>{e.message}</span>
</>
);
});
}
function updateInstanceField(e) {
if (e.key == "Enter") {
tryInstance(instanceField);
} else {
setInstanceField(e.target.value);
instanceFieldRef.current = e.target.value;
}
}
return (
<section className="login">
<h1>OAUTH Login:</h1>
{error}
<form onSubmit={(e) => e.preventDefault()}>
<label htmlFor="instance">Instance: </label>
<input value={instanceField} onChange={updateInstanceField} id="instance"/>
{errorMsg &&
<div className="error">
{errorMsg}
</div>
}
<button onClick={tryInstance}>Authenticate</button>
</form>
</section>
);
};

View file

@ -0,0 +1,33 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const React = require("react");
const { Link, useRoute } = require("wouter");
module.exports = function NavButton({href, name}) {
const [isActive] = useRoute(`${href}/:anything?`);
return (
<Link href={href}>
<a className={isActive ? "active" : ""} data-content={name}>
{name}
</a>
</Link>
);
};

View file

@ -0,0 +1,35 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const React = require("react");
module.exports = function Submit({onClick, label, errorMsg, statusMsg}) {
return (
<div className="messagebutton">
<button type="submit" onClick={onClick}>{ label }</button>
{errorMsg.length > 0 &&
<div className="error accent">{errorMsg}</div>
}
{statusMsg.length > 0 &&
<div className="accent">{statusMsg}</div>
}
</div>
);
};

View file

@ -0,0 +1,178 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const ReactDom = require("react-dom/client");
const Redux = require("react-redux");
const { Switch, Route, Redirect } = require("wouter");
const { Provider } = require("react-redux");
const { PersistGate } = require("redux-persist/integration/react");
const { store, persistor } = require("./redux");
const api = require("./lib/api");
const oauth = require("./redux/reducers/oauth").actions;
const { AuthenticationError } = require("./lib/errors");
const Login = require("./components/login");
require("./style.css");
// TODO: nested categories?
const nav = {
"User": {
"Profile": require("./user/profile.js"),
"Settings": require("./user/settings.js"),
},
"Admin": {
adminOnly: true,
"Instance Settings": require("./admin/settings.js"),
"Actions": require("./admin/actions"),
"Federation": require("./admin/federation.js"),
"Custom Emoji": require("./admin/emoji.js"),
}
};
const { sidebar, panelRouter } = require("./lib/get-views")(nav);
function App() {
const dispatch = Redux.useDispatch();
const { loginState, isAdmin } = Redux.useSelector((state) => state.oauth);
const reduxTempStatus = Redux.useSelector((state) => state.temporary.status);
const [errorMsg, setErrorMsg] = React.useState();
const [tokenChecked, setTokenChecked] = React.useState(false);
React.useEffect(() => {
if (loginState == "login" || loginState == "callback") {
Promise.try(() => {
// Process OAUTH authorization token from URL if available
if (loginState == "callback") {
let urlParams = new URLSearchParams(window.location.search);
let code = urlParams.get("code");
if (code == undefined) {
setErrorMsg(new Error("Waiting for OAUTH callback but no ?code= provided. You can try logging in again:"));
} else {
return dispatch(api.oauth.tokenize(code));
}
}
}).then(() => {
// Fetch current instance info
return dispatch(api.instance.fetch());
}).then(() => {
// Check currently stored auth token for validity if available
return dispatch(api.user.fetchAccount());
}).then(() => {
setTokenChecked(true);
return dispatch(api.oauth.checkIfAdmin());
}).catch((e) => {
if (e instanceof AuthenticationError) {
dispatch(oauth.remove());
e.message = "Stored OAUTH token no longer valid, please log in again.";
}
setErrorMsg(e);
console.error(e);
});
}
}, []);
let ErrorElement = null;
if (errorMsg != undefined) {
ErrorElement = (
<div className="error">
<b>{errorMsg.type}</b>
<span>{errorMsg.message}</span>
</div>
);
}
const LogoutElement = (
<button className="logout" onClick={() => { dispatch(api.oauth.logout()); }}>
Log out
</button>
);
if (reduxTempStatus != undefined) {
return (
<section>
{reduxTempStatus}
</section>
);
} else if (tokenChecked && loginState == "login") {
return (
<>
<div className="sidebar">
{sidebar.all}
{isAdmin && sidebar.admin}
{LogoutElement}
</div>
<section className="with-sidebar">
{ErrorElement}
<Switch>
{panelRouter.all}
{isAdmin && panelRouter.admin}
<Route> {/* default route */}
<Redirect to="/settings/user" />
</Route>
</Switch>
</section>
</>
);
} else if (loginState == "none") {
return (
<Login error={ErrorElement} />
);
} else {
let status;
if (loginState == "login") {
status = "Verifying stored login...";
} else if (loginState == "callback") {
status = "Processing OAUTH callback...";
}
return (
<section>
<div>
{status}
</div>
{ErrorElement}
{LogoutElement}
</section>
);
}
}
function Main() {
return (
<Provider store={store}>
<PersistGate loading={"loading..."} persistor={persistor}>
<App />
</PersistGate>
</Provider>
);
}
const root = ReactDom.createRoot(document.getElementById("root"));
root.render(<React.StrictMode><Main /></React.StrictMode>);

View file

@ -0,0 +1,192 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const isValidDomain = require("is-valid-domain");
const instance = require("../../redux/reducers/instances").actions;
const admin = require("../../redux/reducers/admin").actions;
module.exports = function ({ apiCall, getChanges }) {
const adminAPI = {
updateInstance: function updateInstance() {
return function (dispatch, getState) {
return Promise.try(() => {
const state = getState().instances.adminSettings;
const update = getChanges(state, {
formKeys: ["title", "short_description", "description", "contact_account.username", "email", "terms"],
renamedKeys: {"contact_account.username": "contact_username"},
// fileKeys: ["avatar", "header"]
});
return dispatch(apiCall("PATCH", "/api/v1/instance", update, "form"));
}).then((data) => {
return dispatch(instance.setInstanceInfo(data));
});
};
},
fetchDomainBlocks: function fetchDomainBlocks() {
return function (dispatch, _getState) {
return Promise.try(() => {
return dispatch(apiCall("GET", "/api/v1/admin/domain_blocks"));
}).then((data) => {
return dispatch(admin.setBlockedInstances(data));
});
};
},
updateDomainBlock: function updateDomainBlock(domain) {
return function (dispatch, getState) {
return Promise.try(() => {
const state = getState().admin.newInstanceBlocks[domain];
const update = getChanges(state, {
formKeys: ["domain", "obfuscate", "public_comment", "private_comment"],
});
return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks", update, "form"));
}).then((block) => {
return Promise.all([
dispatch(admin.newDomainBlock([domain, block])),
dispatch(admin.setDomainBlock([domain, block]))
]);
});
};
},
getEditableDomainBlock: function getEditableDomainBlock(domain) {
return function (dispatch, getState) {
let data = getState().admin.blockedInstances[domain];
return dispatch(admin.newDomainBlock([domain, data]));
};
},
bulkDomainBlock: function bulkDomainBlock() {
return function (dispatch, getState) {
let invalidDomains = [];
let success = 0;
return Promise.try(() => {
const state = getState().admin.bulkBlock;
let list = state.list;
let domains;
let fields = getChanges(state, {
formKeys: ["obfuscate", "public_comment", "private_comment"]
});
let defaultDate = new Date().toUTCString();
if (list[0] == "[") {
domains = JSON.parse(state.list);
} else {
domains = list.split("\n").map((line_) => {
let line = line_.trim();
if (line.length == 0) {
return null;
}
if (!isValidDomain(line, {wildcard: true, allowUnicode: true})) {
invalidDomains.push(line);
return null;
}
return {
domain: line,
created_at: defaultDate,
...fields
};
}).filter((a) => a != null);
}
if (domains.length == 0) {
return;
}
const update = {
domains: new Blob([JSON.stringify(domains)], {type: "application/json"})
};
return dispatch(apiCall("POST", "/api/v1/admin/domain_blocks?import=true", update, "form"));
}).then((blocks) => {
if (blocks != undefined) {
return Promise.each(blocks, (block) => {
success += 1;
return dispatch(admin.setDomainBlock([block.domain, block]));
});
}
}).then(() => {
return {
success,
invalidDomains
};
});
};
},
removeDomainBlock: function removeDomainBlock(domain) {
return function (dispatch, getState) {
return Promise.try(() => {
const id = getState().admin.blockedInstances[domain].id;
return dispatch(apiCall("DELETE", `/api/v1/admin/domain_blocks/${id}`));
}).then((removed) => {
return dispatch(admin.removeDomainBlock(removed.domain));
});
};
},
mediaCleanup: function mediaCleanup(days) {
return function (dispatch, _getState) {
return Promise.try(() => {
return dispatch(apiCall("POST", `/api/v1/admin/media_cleanup?remote_cache_days=${days}`));
});
};
},
fetchCustomEmoji: function fetchCustomEmoji() {
return function (dispatch, _getState) {
return Promise.try(() => {
return dispatch(apiCall("GET", "/api/v1/custom_emojis"));
}).then((emoji) => {
return dispatch(admin.setEmoji(emoji));
});
};
},
newEmoji: function newEmoji() {
return function (dispatch, getState) {
return Promise.try(() => {
const state = getState().admin.newEmoji;
const update = getChanges(state, {
formKeys: ["shortcode"],
fileKeys: ["image"]
});
return dispatch(apiCall("POST", "/api/v1/admin/custom_emojis", update, "form"));
}).then((emoji) => {
return dispatch(admin.addEmoji(emoji));
});
};
}
};
return adminAPI;
};

View file

@ -0,0 +1,185 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const { isPlainObject } = require("is-plain-object");
const d = require("dotty");
const { APIError, AuthenticationError } = require("../errors");
const { setInstanceInfo, setNamedInstanceInfo } = require("../../redux/reducers/instances").actions;
const oauth = require("../../redux/reducers/oauth").actions;
function apiCall(method, route, payload, type = "json") {
return function (dispatch, getState) {
const state = getState();
let base = state.oauth.instance;
let auth = state.oauth.token;
console.log(method, base, route, "auth:", auth != undefined);
return Promise.try(() => {
let url = new URL(base);
let [path, query] = route.split("?");
url.pathname = path;
if (query != undefined) {
url.search = query;
}
let body;
let headers = {
"Accept": "application/json",
};
if (payload != undefined) {
if (type == "json") {
headers["Content-Type"] = "application/json";
body = JSON.stringify(payload);
} else if (type == "form") {
const formData = new FormData();
Object.entries(payload).forEach(([key, val]) => {
if (isPlainObject(val)) {
Object.entries(val).forEach(([key2, val2]) => {
if (val2 != undefined) {
formData.set(`${key}[${key2}]`, val2);
}
});
} else {
if (val != undefined) {
formData.set(key, val);
}
}
});
body = formData;
}
}
if (auth != undefined) {
headers["Authorization"] = auth;
}
return fetch(url.toString(), {
method,
headers,
body
});
}).then((res) => {
// try parse json even with error
let json = res.json().catch((e) => {
throw new APIError(`JSON parsing error: ${e.message}`);
});
return Promise.all([res, json]);
}).then(([res, json]) => {
if (!res.ok) {
if (auth != undefined && (res.status == 401 || res.status == 403)) {
// stored access token is invalid
throw new AuthenticationError("401: Authentication error", {json, status: res.status});
} else {
throw new APIError(json.error, { json });
}
} else {
return json;
}
});
};
}
function getChanges(state, keys) {
const { formKeys = [], fileKeys = [], renamedKeys = {} } = keys;
const update = {};
formKeys.forEach((key) => {
let value = d.get(state, key);
if (value == undefined) {
return;
}
if (renamedKeys[key]) {
key = renamedKeys[key];
}
d.put(update, key, value);
});
fileKeys.forEach((key) => {
let file = d.get(state, `${key}File`);
if (file != undefined) {
if (renamedKeys[key]) {
key = renamedKeys[key];
}
d.put(update, key, file);
}
});
return update;
}
function getCurrentUrl() {
return `${window.location.origin}${window.location.pathname}`;
}
function fetchInstanceWithoutStore(domain) {
return function (dispatch, getState) {
return Promise.try(() => {
let lookup = getState().instances.info[domain];
if (lookup != undefined) {
return lookup;
}
// apiCall expects to pull the domain from state,
// but we don't want to store it there yet
// so we mock the API here with our function argument
let fakeState = {
oauth: { instance: domain }
};
return apiCall("GET", "/api/v1/instance")(dispatch, () => fakeState);
}).then((json) => {
if (json && json.uri) { // TODO: validate instance json more?
dispatch(setNamedInstanceInfo([domain, json]));
return json;
}
});
};
}
function fetchInstance() {
return function (dispatch, _getState) {
return Promise.try(() => {
return dispatch(apiCall("GET", "/api/v1/instance"));
}).then((json) => {
if (json && json.uri) {
dispatch(setInstanceInfo(json));
return json;
}
});
};
}
let submoduleArgs = { apiCall, getCurrentUrl, getChanges };
module.exports = {
instance: {
fetchWithoutStore: fetchInstanceWithoutStore,
fetch: fetchInstance
},
oauth: require("./oauth")(submoduleArgs),
user: require("./user")(submoduleArgs),
admin: require("./admin")(submoduleArgs),
apiCall,
getChanges
};

View file

@ -0,0 +1,124 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const { OAUTHError, AuthenticationError } = require("../errors");
const oauth = require("../../redux/reducers/oauth").actions;
const temporary = require("../../redux/reducers/temporary").actions;
const admin = require("../../redux/reducers/admin").actions;
module.exports = function oauthAPI({ apiCall, getCurrentUrl }) {
return {
register: function register(scopes = []) {
return function (dispatch, _getState) {
return Promise.try(() => {
return dispatch(apiCall("POST", "/api/v1/apps", {
client_name: "GoToSocial Settings",
scopes: scopes.join(" "),
redirect_uris: getCurrentUrl(),
website: getCurrentUrl()
}));
}).then((json) => {
json.scopes = scopes;
dispatch(oauth.setRegistration(json));
});
};
},
authorize: function authorize() {
return function (dispatch, getState) {
let state = getState();
let reg = state.oauth.registration;
let base = new URL(state.oauth.instance);
base.pathname = "/oauth/authorize";
base.searchParams.set("client_id", reg.client_id);
base.searchParams.set("redirect_uri", getCurrentUrl());
base.searchParams.set("response_type", "code");
base.searchParams.set("scope", reg.scopes.join(" "));
dispatch(oauth.setLoginState("callback"));
dispatch(temporary.setStatus("Redirecting to instance login..."));
// send user to instance's login flow
window.location.assign(base.href);
};
},
tokenize: function tokenize(code) {
return function (dispatch, getState) {
let reg = getState().oauth.registration;
return Promise.try(() => {
if (reg == undefined || reg.client_id == undefined) {
throw new OAUTHError("Callback code present, but no client registration is available from localStorage. \nNote: localStorage is unavailable in Private Browsing.");
}
return dispatch(apiCall("POST", "/oauth/token", {
client_id: reg.client_id,
client_secret: reg.client_secret,
redirect_uri: getCurrentUrl(),
grant_type: "authorization_code",
code: code
}));
}).then((json) => {
window.history.replaceState({}, document.title, window.location.pathname);
return dispatch(oauth.login(json));
});
};
},
checkIfAdmin: function checkIfAdmin() {
return function (dispatch, getState) {
const state = getState();
let stored = state.oauth.isAdmin;
if (stored != undefined) {
return stored;
}
// newer GoToSocial version will include a `role` in the Account data, check that first
// TODO: check account data for admin status
// no role info, try fetching an admin-only route and see if we get an error
return Promise.try(() => {
return dispatch(apiCall("GET", "/api/v1/admin/domain_blocks"));
}).then((data) => {
return Promise.all([
dispatch(oauth.setAdmin(true)),
dispatch(admin.setBlockedInstances(data))
]);
}).catch(AuthenticationError, () => {
return dispatch(oauth.setAdmin(false));
});
};
},
logout: function logout() {
return function (dispatch, _getState) {
// TODO: GoToSocial does not have a logout API route yet
return dispatch(oauth.remove());
};
}
};
};

View file

@ -0,0 +1,67 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const user = require("../../redux/reducers/user").actions;
module.exports = function ({ apiCall, getChanges }) {
function updateCredentials(selector, keys) {
return function (dispatch, getState) {
return Promise.try(() => {
const state = selector(getState());
const update = getChanges(state, keys);
return dispatch(apiCall("PATCH", "/api/v1/accounts/update_credentials", update, "form"));
}).then((account) => {
return dispatch(user.setAccount(account));
});
};
}
return {
fetchAccount: function fetchAccount() {
return function (dispatch, _getState) {
return Promise.try(() => {
return dispatch(apiCall("GET", "/api/v1/accounts/verify_credentials"));
}).then((account) => {
return dispatch(user.setAccount(account));
});
};
},
updateProfile: function updateProfile() {
const formKeys = ["display_name", "locked", "source", "custom_css", "source.note"];
const renamedKeys = {
"source.note": "note"
};
const fileKeys = ["header", "avatar"];
return updateCredentials((state) => state.user.profile, {formKeys, renamedKeys, fileKeys});
},
updateSettings: function updateProfile() {
const formKeys = ["source"];
return updateCredentials((state) => state.user.settings, {formKeys});
}
};
};

View file

@ -0,0 +1,27 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const createError = require("create-error");
module.exports = {
APIError: createError("APIError"),
OAUTHError: createError("OAUTHError"),
AuthenticationError: createError("AuthenticationError"),
};

View file

@ -0,0 +1,102 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const React = require("react");
const Redux = require("react-redux");
const { Link, Route, Switch, Redirect } = require("wouter");
const { ErrorBoundary } = require("react-error-boundary");
const ErrorFallback = require("../components/error");
const NavButton = require("../components/nav-button");
function urlSafe(str) {
return str.toLowerCase().replace(/\s+/g, "-");
}
module.exports = function getViews(struct) {
const sidebar = {
all: [],
admin: [],
};
const panelRouter = {
all: [],
admin: [],
};
Object.entries(struct).forEach(([name, entries]) => {
let sidebarEl = sidebar.all;
let panelRouterEl = panelRouter.all;
if (entries.adminOnly) {
sidebarEl = sidebar.admin;
panelRouterEl = panelRouter.admin;
delete entries.adminOnly;
}
let base = `/settings/${urlSafe(name)}`;
let links = [];
let firstRoute;
Object.entries(entries).forEach(([name, ViewComponent]) => {
let url = `${base}/${urlSafe(name)}`;
if (firstRoute == undefined) {
firstRoute = url;
}
panelRouterEl.push((
<Route path={`${url}/:page?`} key={url}>
<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => { }}>
{/* FIXME: implement onReset */}
<ViewComponent />
</ErrorBoundary>
</Route>
));
links.push(
<NavButton key={url} href={url} name={name} />
);
});
panelRouterEl.push(
<Route key={base} path={base}>
<Redirect to={firstRoute} />
</Route>
);
sidebarEl.push(
<React.Fragment key={name}>
<Link href={firstRoute}>
<a>
<h2>{name}</h2>
</a>
</Link>
<nav>
{links}
</nav>
</React.Fragment>
);
});
return { sidebar, panelRouter };
};

View file

@ -1,19 +1,19 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
GoToSocial
Copyright (C) 2021-2022 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 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.
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/>.
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/>.
*/
"use strict";

View file

@ -0,0 +1,48 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
module.exports = function submit(func, {
setStatus, setError,
startStatus="PATCHing", successStatus="Saved!",
onSuccess,
onError
}) {
return function() {
setStatus(startStatus);
setError("");
return Promise.try(() => {
return func();
}).then(() => {
setStatus(successStatus);
if (onSuccess != undefined) {
return onSuccess();
}
}).catch((e) => {
setError(e.message);
setStatus("");
console.error(e);
if (onError != undefined) {
onError(e);
}
});
};
};

View file

@ -0,0 +1,48 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const { createStore, combineReducers, applyMiddleware } = require("redux");
const { persistStore, persistReducer } = require("redux-persist");
const thunk = require("redux-thunk").default;
const { composeWithDevTools } = require("redux-devtools-extension");
const persistConfig = {
key: "gotosocial-settings",
storage: require("redux-persist/lib/storage").default,
stateReconciler: require("redux-persist/lib/stateReconciler/autoMergeLevel2").default,
whitelist: ["oauth"],
blacklist: ["temporary"]
};
const combinedReducers = combineReducers({
oauth: require("./reducers/oauth").reducer,
instances: require("./reducers/instances").reducer,
temporary: require("./reducers/temporary").reducer,
user: require("./reducers/user").reducer,
admin: require("./reducers/admin").reducer,
});
const persistedReducer = persistReducer(persistConfig, combinedReducers);
const composedEnhancer = composeWithDevTools(applyMiddleware(thunk));
const store = createStore(persistedReducer, composedEnhancer);
const persistor = persistStore(store);
module.exports = { store, persistor };

View file

@ -0,0 +1,131 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const { createSlice } = require("@reduxjs/toolkit");
const defaultValue = require("default-value");
function sortBlocks(blocks) {
return blocks.sort((a, b) => { // alphabetical sort
return a.domain.localeCompare(b.domain);
});
}
function emptyBlock() {
return {
public_comment: "",
private_comment: "",
obfuscate: false
};
}
function emptyEmojiForm() {
return {
shortcode: ""
};
}
module.exports = createSlice({
name: "admin",
initialState: {
loadedBlockedInstances: false,
blockedInstances: undefined,
bulkBlock: {
list: "",
exportType: "plain",
...emptyBlock()
},
newInstanceBlocks: {},
emoji: {},
newEmoji: emptyEmojiForm()
},
reducers: {
setBlockedInstances: (state, { payload }) => {
state.blockedInstances = {};
sortBlocks(payload).forEach((entry) => {
state.blockedInstances[entry.domain] = entry;
});
state.loadedBlockedInstances = true;
},
newDomainBlock: (state, { payload: [domain, data] }) => {
if (data == undefined) {
data = {
new: true,
domain,
...emptyBlock()
};
}
state.newInstanceBlocks[domain] = data;
},
setDomainBlock: (state, { payload: [domain, data = {}] }) => {
state.blockedInstances[domain] = data;
},
removeDomainBlock: (state, {payload: domain}) => {
delete state.blockedInstances[domain];
},
updateDomainBlockVal: (state, { payload: [domain, key, val] }) => {
state.newInstanceBlocks[domain][key] = val;
},
updateBulkBlockVal: (state, { payload: [key, val] }) => {
state.bulkBlock[key] = val;
},
resetBulkBlockVal: (state, { _payload }) => {
state.bulkBlock = {
list: "",
exportType: "plain",
...emptyBlock()
};
},
exportToField: (state, { _payload }) => {
state.bulkBlock.list = Object.values(state.blockedInstances).map((entry) => {
return entry.domain;
}).join("\n");
},
setEmoji: (state, {payload}) => {
state.emoji = {};
payload.forEach((emoji) => {
if (emoji.category == undefined) {
emoji.category = "Unsorted";
}
state.emoji[emoji.category] = defaultValue(state.emoji[emoji.category], []);
state.emoji[emoji.category].push(emoji);
});
},
updateNewEmojiVal: (state, { payload: [key, val] }) => {
state.newEmoji[key] = val;
},
addEmoji: (state, {payload: emoji}) => {
if (emoji.category == undefined) {
emoji.category = "Unsorted";
}
state.emoji[emoji.category] = defaultValue(state.emoji[emoji.category], []);
state.emoji[emoji.category].push(emoji);
},
}
});

View file

@ -0,0 +1,42 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const {createSlice} = require("@reduxjs/toolkit");
const d = require("dotty");
module.exports = createSlice({
name: "instances",
initialState: {
info: {},
},
reducers: {
setNamedInstanceInfo: (state, {payload}) => {
let [key, info] = payload;
state.info[key] = info;
},
setInstanceInfo: (state, {payload}) => {
state.current = payload;
state.adminSettings = payload;
},
setAdminSettingsVal: (state, {payload: [key, val]}) => {
d.put(state.adminSettings, key, val);
}
}
});

View file

@ -0,0 +1,52 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const {createSlice} = require("@reduxjs/toolkit");
module.exports = createSlice({
name: "oauth",
initialState: {
loginState: 'none',
},
reducers: {
setInstance: (state, {payload}) => {
state.instance = payload;
},
setRegistration: (state, {payload}) => {
state.registration = payload;
},
setLoginState: (state, {payload}) => {
state.loginState = payload;
},
login: (state, {payload}) => {
state.token = `${payload.token_type} ${payload.access_token}`;
state.loginState = "login";
},
remove: (state, {_payload}) => {
delete state.token;
delete state.registration;
delete state.isAdmin;
state.loginState = "none";
},
setAdmin: (state, {payload}) => {
state.isAdmin = payload;
}
}
});

View file

@ -0,0 +1,32 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const {createSlice} = require("@reduxjs/toolkit");
module.exports = createSlice({
name: "temporary",
initialState: {
},
reducers: {
setStatus: function(state, {payload}) {
state.status = payload;
}
}
});

View file

@ -0,0 +1,51 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const { createSlice } = require("@reduxjs/toolkit");
const d = require("dotty");
const defaultValue = require("default-value");
module.exports = createSlice({
name: "user",
initialState: {
profile: {},
settings: {}
},
reducers: {
setAccount: (state, { payload }) => {
payload.source = defaultValue(payload.source, {});
payload.source.language = defaultValue(payload.source.language.toUpperCase(), "EN");
payload.source.status_format = defaultValue(payload.source.status_format, "plain");
payload.source.sensitive = defaultValue(payload.source.sensitive, false);
state.profile = payload;
// /user/settings only needs a copy of the 'source' obj
state.settings = {
source: payload.source
};
},
setProfileVal: (state, { payload: [key, val] }) => {
d.put(state.profile, key, val);
},
setSettingsVal: (state, { payload: [key, val] }) => {
d.put(state.settings, key, val);
}
}
});

View file

@ -0,0 +1,498 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
body {
grid-template-rows: auto 1fr;
}
.content {
grid-column: 1 / span 3; /* stretch entire width, to fit panel + sidebar nav */
}
section {
grid-column: 2;
}
#root {
display: grid;
grid-template-columns: 1fr 90ch 1fr;
width: 100vw;
max-width: 100vw;
box-sizing: border-box;
section.with-sidebar {
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
& > div {
border-left: 0.2rem solid $border-accent;
padding-left: 0.4rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 2rem 0;
h2 {
margin: 0;
margin-bottom: 0.5rem;
}
&:only-child {
border-left: none;
}
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
}
.sidebar {
align-self: start;
justify-self: end;
background: $settings-nav-bg;
border: $boxshadow-border;
box-shadow: $boxshadow;
border-radius: $br;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
display: flex;
flex-direction: column;
min-width: 12rem;
a {
text-decoration: none;
}
a:first-child h2 {
border-top-left-radius: $br;
}
h2 {
margin: 0;
padding: 0.5rem;
font-size: 0.9rem;
font-weight: bold;
text-transform: uppercase;
color: $settings-nav-header-fg;
background: $settings-nav-header-bg;
}
nav {
display: flex;
flex-direction: column;
a {
padding: 1rem;
text-decoration: none;
transition: 0.1s;
color: $fg;
&:hover {
color: $settings-nav-fg-hover;
background: $settings-nav-bg-hover;
}
&.active {
color: $settings-nav-fg-active;
background: $settings-nav-bg-active;
font-weight: bold;
text-decoration: underline;
}
/* reserve space for bold version of the element, so .active doesn't
change container size */
&::after {
font-weight: bold;
text-decoration: underline;
display: block;
content: attr(data-content);
height: 1px;
color: transparent;
overflow: hidden;
visibility: hidden;
}
}
}
nav:last-child a:last-child {
border-bottom-left-radius: $br;
border-bottom: none;
}
}
}
.capitalize {
text-transform: capitalize;
}
section {
margin-bottom: 1rem;
}
input, select, textarea {
box-sizing: border-box;
}
.error {
color: $error-fg;
background: $error-bg;
border: 0.02rem solid $error-fg;
border-radius: $br;
font-weight: bold;
padding: 0.5rem;
white-space: pre-wrap;
a {
color: $error-link;
}
pre {
background: $bg;
color: $fg;
padding: 1rem;
overflow: auto;
margin: 0;
}
}
.hidden {
display: none;
}
.messagebutton, .messagebutton > div {
display: flex;
align-items: center;
flex-wrap: wrap;
div.padded {
margin-left: 1rem;
}
button, .button {
white-space: nowrap;
margin-right: 1rem;
}
}
.messagebutton > div {
button, .button {
margin-top: 1rem;
}
}
.notImplemented {
border: 2px solid rgb(70, 79, 88);
background: repeating-linear-gradient(
-45deg,
#525c66,
#525c66 10px,
rgb(70, 79, 88) 10px,
rgb(70, 79, 88) 20px
) !important;
}
section.with-sidebar > div {
display: flex;
flex-direction: column;
gap: 1rem;
input, textarea {
width: 100%;
line-height: 1.5rem;
}
input[type=checkbox] {
justify-self: start;
width: initial;
}
input:read-only {
border: none;
}
input:invalid {
border-color: red;
}
textarea {
width: 100%;
}
h1 {
margin-bottom: 0.5rem;
}
.moreinfolink {
font-size: 0.9em;
}
.labelinput .border {
border-radius: 0.2rem;
border: 0.15rem solid $border_accent;
padding: 0.3rem;
display: flex;
flex-direction: column;
}
.file-input.button {
display: inline-block;
font-size: 1rem;
font-weight: normal;
padding: 0.3rem 0.3rem;
align-self: flex-start;
margin-right: 0.2rem;
}
.labelinput, .labelselect {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.labelcheckbox {
display: flex;
gap: 0.4rem;
}
.titlesave {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
}
.file-upload > div {
display: flex;
gap: 1rem;
img {
height: 8rem;
border: 0.2rem solid $border-accent;
}
img.avatar {
width: 8rem;
}
img.header {
width: 24rem;
}
}
.user-profile {
.overview {
display: grid;
grid-template-columns: 70% 30%;
.basic {
margin-top: -4.5rem;
.avatar {
height: 5rem;
width: 5rem;
}
.displayname {
font-size: 1.3rem;
padding-top: 0;
padding-bottom: 0;
margin-top: 0.7rem;
}
}
.files {
width: 100%;
margin: 1rem;
margin-right: 0;
display: flex;
flex-direction: column;
justify-content: center;
div.form-field {
width: 100%;
display: flex;
span {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0.3rem 0;
}
}
h3 {
margin-top: 0;
margin-bottom: 0.5rem;
}
div:first-child {
margin-bottom: 1rem;
}
span {
font-style: italic;
}
}
}
}
.form-field label {
font-weight: bold;
}
.list {
display: flex;
flex-direction: column;
margin-top: 0.5rem;
max-height: 40rem;
overflow: auto;
.entry {
display: flex;
flex-wrap: wrap;
background: $settings-entry-bg;
&:hover {
background: $settings-entry-hover-bg;
}
}
}
.instance-list {
.filter {
display: flex;
gap: 0.5rem;
input {
width: auto;
flex: 1 1 auto;
}
}
.entry {
padding: 0.3rem;
margin: 0.2rem 0;
#domain {
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.bulk h2 {
display: flex;
justify-content: space-between;
}
.emoji-list {
background: $settings-entry-bg;
.entry {
padding: 0.5rem;
flex-direction: column;
.emoji-group {
display: flex;
a {
border-radius: $br;
padding: 0.4rem;
line-height: 0;
img {
height: 2rem;
width: 2rem;
object-fit: contain;
vertical-align: middle;
}
&:hover {
background: $settings-entry-hover-bg;
}
}
}
&:hover {
background: inherit;
}
}
}
.toot {
padding-top: 0.5rem;
.contentgrid {
padding: 0 0.5rem;
}
}
@media screen and (max-width: 100ch) {
#root {
padding: 1rem;
grid-template-columns: 100%;
grid-template-rows: auto auto;
.sidebar {
justify-self: auto;
margin-bottom: 2rem;
}
.sidebar, section.with-sidebar {
border-top-left-radius: $br;
border-top-right-radius: $br;
border-bottom-left-radius: $br;
border-bottom-right-radius: $br;
}
.sidebar a:first-child h2 {
border-top-right-radius: $br;
}
}
section {
grid-column: 1;
}
.user-profile .overview {
grid-template-columns: 100%;
grid-template-rows: auto auto;
.files {
margin: 0;
margin-top: 1rem;
}
}
main section {
padding: 0.75rem;
}
.instance-list .filter {
flex-direction: column;
}
}

View file

@ -0,0 +1,113 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const Submit = require("../components/submit");
const api = require("../lib/api");
const user = require("../redux/reducers/user").actions;
const submit = require("../lib/submit");
const { formFields } = require("../components/form-fields");
const {
TextInput,
TextArea,
Checkbox,
File
} = formFields(user.setProfileVal, (state) => state.user.profile);
module.exports = function UserProfile() {
const dispatch = Redux.useDispatch();
const account = Redux.useSelector(state => state.user.profile);
const instance = Redux.useSelector(state => state.instances.current);
const allowCustomCSS = instance.configuration.accounts.allow_custom_css;
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
const saveProfile = submit(
() => dispatch(api.user.updateProfile()),
{setStatus, setError}
);
return (
<div className="user-profile">
<h1>Profile</h1>
<div className="overview">
<div className="profile">
<div className="headerimage">
<img className="headerpreview" src={account.header} alt={account.header ? `header image for ${account.username}` : "None set"} />
</div>
<div className="basic">
<div id="profile-basic-filler2"></div>
<span className="avatar"><img className="avatarpreview" src={account.avatar} alt={account.avatar ? `avatar image for ${account.username}` : "None set"} /></span>
<div className="displayname">{account.display_name.trim().length > 0 ? account.display_name : account.username}</div>
<div className="username"><span>@{account.username}</span></div>
</div>
</div>
<div className="files">
<div>
<h3>Header</h3>
<File
id="header"
fileType="image/*"
/>
</div>
<div>
<h3>Avatar</h3>
<File
id="avatar"
fileType="image/*"
/>
</div>
</div>
</div>
<TextInput
id="display_name"
name="Name"
placeHolder="A GoToSocial user"
/>
<TextArea
id="source.note"
name="Bio"
placeHolder="Just trying out GoToSocial, my pronouns are they/them and I like sloths."
/>
<Checkbox
id="locked"
name="Manually approve follow requests? "
/>
{ !allowCustomCSS ? null :
<TextArea
id="custom_css"
name="Custom CSS"
className="monospace"
>
<a href="https://docs.gotosocial.org/en/latest/user_guide/custom_css" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about custom profile CSS (opens in a new tab)</a>
</TextArea>
}
<Submit onClick={saveProfile} label="Save profile info" errorMsg={errorMsg} statusMsg={statusMsg} />
</div>
);
};

View file

@ -0,0 +1,140 @@
/*
GoToSocial
Copyright (C) 2021-2022 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/>.
*/
"use strict";
const Promise = require("bluebird");
const React = require("react");
const Redux = require("react-redux");
const api = require("../lib/api");
const user = require("../redux/reducers/user").actions;
const submit = require("../lib/submit");
const Languages = require("../components/languages");
const Submit = require("../components/submit");
const {
Checkbox,
Select,
} = require("../components/form-fields").formFields(user.setSettingsVal, (state) => state.user.settings);
module.exports = function UserSettings() {
const dispatch = Redux.useDispatch();
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
const updateSettings = submit(
() => dispatch(api.user.updateSettings()),
{setStatus, setError}
);
return (
<>
<div className="user-settings">
<h1>Post settings</h1>
<Select id="source.language" name="Default post language" options={
<Languages/>
}>
</Select>
<Select id="source.privacy" name="Default post privacy" options={
<>
<option value="private">Private / followers-only</option>
<option value="unlisted">Unlisted</option>
<option value="public">Public</option>
</>
}>
<a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#privacy-settings" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post privacy settings (opens in a new tab)</a>
</Select>
<Select id="source.status_format" name="Default post format" options={
<>
<option value="plain">Plain (default)</option>
<option value="markdown">Markdown</option>
</>
}>
<a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#input-types" target="_blank" className="moreinfolink" rel="noreferrer">Learn more about post format settings (opens in a new tab)</a>
</Select>
<Checkbox
id="source.sensitive"
name="Mark my posts as sensitive by default"
/>
<Submit onClick={updateSettings} label="Save post settings" errorMsg={errorMsg} statusMsg={statusMsg}/>
</div>
<div>
<PasswordChange/>
</div>
</>
);
};
function PasswordChange() {
const dispatch = Redux.useDispatch();
const [errorMsg, setError] = React.useState("");
const [statusMsg, setStatus] = React.useState("");
const [oldPassword, setOldPassword] = React.useState("");
const [newPassword, setNewPassword] = React.useState("");
const [newPasswordConfirm, setNewPasswordConfirm] = React.useState("");
function changePassword() {
if (newPassword !== newPasswordConfirm) {
setError("New password and confirm new password did not match!");
return;
}
setStatus("PATCHing");
setError("");
return Promise.try(() => {
let data = {
old_password: oldPassword,
new_password: newPassword
};
return dispatch(api.apiCall("POST", "/api/v1/user/password_change", data, "form"));
}).then(() => {
setStatus("Saved!");
setOldPassword("");
setNewPassword("");
setNewPasswordConfirm("");
}).catch((e) => {
setError(e.message);
setStatus("");
});
}
return (
<>
<h1>Change password</h1>
<div className="labelinput">
<label htmlFor="password">Current password</label>
<input name="password" id="password" type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
</div>
<div className="labelinput">
<label htmlFor="new-password">New password</label>
<input name="new-password" id="new-password" type="password" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
</div>
<div className="labelinput">
<label htmlFor="confirm-new-password">Confirm new password</label>
<input name="confirm-new-password" id="confirm-new-password" type="password" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} />
</div>
<Submit onClick={changePassword} label="Save new password" errorMsg={errorMsg} statusMsg={statusMsg}/>
</>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
{{ template "header.tmpl" .}}
<main class="lightgray">
<div id="root"></div>
<div id="root">
</div>
</main>
{{ template "footer.tmpl" .}}

View file

@ -1,5 +1,5 @@
{{ template "header.tmpl" .}}
<section class="excerpt_top">
<section class="excerpt-top">
home to <span class="count">{{.instance.Stats.user_count}}</span> users
who posted <span class="count">{{.instance.Stats.status_count}}</span> statuses,
federating with <span class="count">{{.instance.Stats.domain_count}}</span> other instances.

View file

@ -2,6 +2,10 @@
<a href="{{.Account.URL}}" class="avatar"><img src="{{.Account.Avatar}}" alt=""></a>
<a href="{{.Account.URL}}" class="displayname">{{if .Account.DisplayName}}{{emojify .Account.Emojis (escape .Account.DisplayName)}}{{else}}{{.Account.Username}}{{end}}</a>
<a href="{{.Account.URL}}" class="username">@{{.Account.Acct}}</a>
<div class="not-expanded">
<span class="visibility">{{.Visibility | visibilityIcon}}</span>
<span class="date">{{.CreatedAt | timestamp}}</span>
</div>
<div class="text">
{{if .SpoilerText}}
<input class="spoiler" id="hideSpoiler-{{.ID}}" type="checkbox" style="display: none" aria-hidden="true" checked="true" />
@ -43,7 +47,6 @@
<div class="info">
<div id="date">{{.CreatedAt | timestamp}}</div>
<div class="stats">
<div id="visibility">{{.Visibility | visibilityIcon}}</div>
<div id="replies"><i aria-label="Replies" class="fa fa-reply-all"></i> {{.RepliesCount}}</div>
<div id="boosts"><i aria-label="Boosts" class="fa fa-retweet"></i> {{.ReblogsCount}}</div>
<div id="favorites"><i aria-label="Favorites" class="fa fa-star"></i> {{.FavouritesCount}}</div>