Compare commits

...

12 commits

Author SHA1 Message Date
Vyr Cossont 1d02afb3f2
Merge 4c195cf4b7 into aecf74951c 2024-04-26 00:30:20 +02:00
tobi aecf74951c
[chore] Settings refactor 2: the re-refactoring-ing (#2866)
* [chore] Bit more refactoring of settings panel

* fix up some remaining things

* groovy baby yeah!

* remove unused Suspense
2024-04-25 18:24:24 +02:00
tobi 7a1e639483
[chore] Refactor settings panel routing (and other fixes) (#2864) 2024-04-24 11:12:47 +01:00
tobi 4c195cf4b7
Merge branch 'main' into filters-v2 2024-04-22 10:57:09 +02:00
Vyr Cossont 7ebbbb4063 APIFilterActionToFilterAction not used yet 2024-03-31 23:44:18 -07:00
Vyr Cossont 6f37930963 lint: rename ErrHideStatus 2024-03-31 23:16:46 -07:00
Vyr Cossont 5f03f630aa Add missing filter check for notification 2024-03-31 23:12:51 -07:00
Vyr Cossont fbd5e44d8f Remove TODOs that don't need to be done now 2024-03-31 23:02:43 -07:00
Vyr Cossont 36a156cc13 Make matching case-insensitive 2024-03-31 18:29:34 -07:00
Vyr Cossont be78b7e701 status.filtered is an array 2024-03-31 15:04:45 -07:00
Vyr Cossont 125b4331ed Filter statuses when converting to frontend representation 2024-03-31 14:24:57 -07:00
Vyr Cossont fc2b641a0d Remove dead code 2024-03-31 14:02:33 -07:00
90 changed files with 3455 additions and 2108 deletions

13
.vscode/settings.json vendored
View file

@ -10,5 +10,14 @@
},
"eslint.workingDirectories": ["web/source"],
"eslint.lintTask.enable": true,
"eslint.lintTask.options": "${workspaceFolder}/web/source"
}
"eslint.lintTask.options": "${workspaceFolder}/web/source",
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}

View file

@ -0,0 +1,34 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package model
// FilterResult is returned along with a filtered status to explain why it was filtered.
//
// swagger:model filterResult
//
// ---
// tags:
// - filters
type FilterResult struct {
// The filter that was matched.
Filter FilterV2 `json:"filter"`
// The keywords within the filter that were matched.
KeywordMatches []string `json:"keyword_matches"`
// The status IDs within the filter that were matched.
StatusMatches []string `json:"status_matches"`
}

View file

@ -0,0 +1,106 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package model
// FilterV2 represents a user-defined filter for determining which statuses should not be shown to the user.
// v2 filters have names and can include multiple phrases and status IDs to filter.
//
// swagger:model filterV2
//
// ---
// tags:
// - filters
type FilterV2 struct {
// The ID of the filter in the database.
ID string `json:"id"`
// The name of the filter.
//
// Example: Linux Words
Title string `json:"title"`
// The contexts in which the filter should be applied.
//
// Minimum items: 1
// Unique: true
// Enum:
// - home
// - notifications
// - public
// - thread
// - account
// Example: ["home", "public"]
Context []FilterContext `json:"context"`
// When the filter should no longer be applied. Null if the filter does not expire.
//
// Example: 2024-02-01T02:57:49Z
ExpiresAt *string `json:"expires_at"`
// The action to be taken when a status matches this filter.
// Enum:
// - warn
// - hide
FilterAction FilterAction `json:"filter_action"`
// The keywords grouped under this filter.
Keywords []FilterKeyword `json:"keywords"`
// The statuses grouped under this filter.
Statuses []FilterStatus `json:"statuses"`
}
// FilterAction is the action to apply to statuses matching a filter.
type FilterAction string
const (
// FilterActionWarn filters will include this status in API results with a warning.
FilterActionWarn FilterAction = "warn"
// FilterActionHide filters will remove this status from API results.
FilterActionHide FilterAction = "hide"
FilterActionNumValues = 2
)
// FilterKeyword represents text to filter within a v2 filter.
//
// swagger:model filterKeyword
//
// ---
// tags:
// - filters
type FilterKeyword struct {
// The ID of the filter keyword entry in the database.
ID string `json:"id"`
// The text to be filtered.
//
// Example: fnord
Keyword string `json:"keyword"`
// Should the filter consider word boundaries?
//
// Example: true
WholeWord bool `json:"whole_word"`
}
// FilterStatus represents a single status to filter within a v2 filter.
//
// swagger:model filterStatus
//
// ---
// tags:
// - filters
type FilterStatus struct {
// The ID of the filter status entry in the database.
ID string `json:"id"`
// The status ID to be filtered.
StatusID string `json:"phrase"`
}

View file

@ -100,6 +100,8 @@ type Status struct {
// so the user may redraft from the source text without the client having to reverse-engineer
// the original text from the HTML content.
Text string `json:"text,omitempty"`
// A list of filters that matched this status and why they matched, if there are any such filters.
Filtered []FilterResult `json:"filtered,omitempty"`
// Additional fields not exposed via JSON
// (used only internally for templating etc).

View file

@ -0,0 +1,45 @@
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
// Package custom represents custom filters managed by the user through the API.
package custom
import (
"errors"
)
// ErrHideStatus indicates that a status has been filtered and should not be returned at all.
var ErrHideStatus = errors.New("hide status")
// FilterContext determines the filters that apply to a given status or list of statuses.
type FilterContext string
const (
// FilterContextNone means no filters should be applied.
// There are no filters with this context; it's for internal use only.
FilterContextNone FilterContext = ""
// FilterContextHome means this status is being filtered as part of a home or list timeline.
FilterContextHome FilterContext = "home"
// FilterContextNotifications means this status is being filtered as part of the notifications timeline.
FilterContextNotifications FilterContext = "notifications"
// FilterContextPublic means this status is being filtered as part of a public or tag timeline.
FilterContextPublic FilterContext = "public"
// FilterContextThread means this status is being filtered as part of a thread's context.
FilterContextThread FilterContext = "thread"
// FilterContextAccount means this status is being filtered as part of an account's statuses.
FilterContextAccount FilterContext = "account"
)

View file

@ -23,6 +23,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -74,7 +75,7 @@ func (p *Processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmode
}
// Convert the status.
item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount)
item, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, custom.FilterContextNone, nil)
if err != nil {
log.Errorf(ctx, "error converting bookmarked status to api: %s", err)
continue

View file

@ -24,6 +24,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -96,9 +97,15 @@ func (p *Processor) StatusesGet(
return nil, gtserror.NewErrorInternalError(err)
}
filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
for _, s := range filtered {
// Convert filtered statuses to API statuses.
item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount)
item, err := p.converter.StatusToAPIStatus(ctx, s, requestingAccount, custom.FilterContextAccount, filters)
if err != nil {
log.Errorf(ctx, "error convering to api status: %v", err)
continue

View file

@ -184,7 +184,7 @@ func (p *Processor) GetAPIStatus(
apiStatus *apimodel.Status,
errWithCode gtserror.WithCode,
) {
apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, "", nil)
if err != nil {
err = gtserror.Newf("error converting status: %w", err)
return nil, gtserror.NewErrorInternalError(err)
@ -192,87 +192,6 @@ func (p *Processor) GetAPIStatus(
return apiStatus, nil
}
// GetVisibleAPIStatuses converts an array of gtsmodel.Status (inputted by next function) into
// API model statuses, checking first for visibility. Please note that all errors will be
// logged at ERROR level, but will not be returned. Callers are likely to run into show-stopping
// errors in the lead-up to this function, whereas calling this should not be a show-stopper.
func (p *Processor) GetVisibleAPIStatuses(
ctx context.Context,
requester *gtsmodel.Account,
next func(int) *gtsmodel.Status,
length int,
) []*apimodel.Status {
return p.getVisibleAPIStatuses(ctx, 3, requester, next, length)
}
// GetVisibleAPIStatusesPaged is functionally equivalent to GetVisibleAPIStatuses(),
// except the statuses are returned as a converted slice of statuses as interface{}.
func (p *Processor) GetVisibleAPIStatusesPaged(
ctx context.Context,
requester *gtsmodel.Account,
next func(int) *gtsmodel.Status,
length int,
) []interface{} {
statuses := p.getVisibleAPIStatuses(ctx, 3, requester, next, length)
if len(statuses) == 0 {
return nil
}
items := make([]interface{}, len(statuses))
for i, status := range statuses {
items[i] = status
}
return items
}
func (p *Processor) getVisibleAPIStatuses(
ctx context.Context,
calldepth int, // used to skip wrapping func above these's names
requester *gtsmodel.Account,
next func(int) *gtsmodel.Status,
length int,
) []*apimodel.Status {
// Start new log entry with
// the above calling func's name.
l := log.
WithContext(ctx).
WithField("caller", log.Caller(calldepth+1))
// Preallocate slice according to expected length.
statuses := make([]*apimodel.Status, 0, length)
for i := 0; i < length; i++ {
// Get next status.
status := next(i)
if status == nil {
continue
}
// Check whether this status is visible to requesting account.
visible, err := p.filter.StatusVisible(ctx, requester, status)
if err != nil {
l.Errorf("error checking status visibility: %v", err)
continue
}
if !visible {
// Not visible to requester.
continue
}
// Convert the status to an API model representation.
apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requester)
if err != nil {
l.Errorf("error converting status: %v", err)
continue
}
// Append API model to return slice.
statuses = append(statuses, apiStatus)
}
return statuses
}
// InvalidateTimelinedStatus is a shortcut function for invalidating the cached
// representation one status in the home timeline and all list timelines of the
// given accountID. It should only be called in cases where a status update

View file

@ -113,7 +113,7 @@ func (p *Processor) packageStatuses(
continue
}
apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, status, requestingAccount, "", nil)
if err != nil {
log.Debugf(ctx, "skipping status %s because it couldn't be converted to its api representation: %s", status.ID, err)
continue

View file

@ -23,6 +23,7 @@ import (
"strings"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
@ -280,7 +281,15 @@ func TopoSort(apiStatuses []*apimodel.Status, targetAccountID string) {
// ContextGet returns the context (previous and following posts) from the given status ID.
func (p *Processor) ContextGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode) {
return p.contextGet(ctx, requestingAccount, targetStatusID, p.converter.StatusToAPIStatus)
filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
convert := func(ctx context.Context, status *gtsmodel.Status, requestingAccount *gtsmodel.Account) (*apimodel.Status, error) {
return p.converter.StatusToAPIStatus(ctx, status, requestingAccount, custom.FilterContextThread, filters)
}
return p.contextGet(ctx, requestingAccount, targetStatusID, convert)
}
// WebContextGet is like ContextGet, but is explicitly

View file

@ -24,6 +24,7 @@ import (
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/stream"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
)
@ -39,7 +40,7 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() {
suite.NoError(errWithCode)
editedStatus := suite.testStatuses["remote_account_1_status_1"]
apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(context.Background(), editedStatus, account)
apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(context.Background(), editedStatus, account, custom.FilterContextNotifications, nil)
suite.NoError(err)
suite.streamProcessor.StatusUpdate(context.Background(), account, apiStatus, stream.TimelineHome)

View file

@ -24,6 +24,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
@ -54,7 +55,7 @@ func (p *Processor) FavedTimelineGet(ctx context.Context, authed *oauth.Auth, ma
continue
}
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, authed.Account, custom.FilterContextNone, nil)
if err != nil {
log.Errorf(ctx, "error convering to api status: %v", err)
continue

View file

@ -23,6 +23,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -98,7 +99,13 @@ func HomeTimelineStatusPrepare(state *state.State, converter *typeutils.Converte
return nil, err
}
return converter.StatusToAPIStatus(ctx, status, requestingAccount)
filters, err := state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, err
}
return converter.StatusToAPIStatus(ctx, status, requestingAccount, custom.FilterContextHome, filters)
}
}

View file

@ -23,6 +23,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -110,7 +111,13 @@ func ListTimelineStatusPrepare(state *state.State, converter *typeutils.Converte
return nil, err
}
return converter.StatusToAPIStatus(ctx, status, requestingAccount)
filters, err := state.DB.GetFiltersForAccountID(ctx, requestingAccount.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAccount.ID, err)
return nil, err
}
return converter.StatusToAPIStatus(ctx, status, requestingAccount, custom.FilterContextHome, filters)
}
}

View file

@ -43,6 +43,12 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
return util.EmptyPageableResponse(), nil
}
filters, err := p.state.DB.GetFiltersForAccountID(ctx, authed.Account.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", authed.Account.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
var (
items = make([]interface{}, 0, count)
nextMaxIDValue string
@ -70,7 +76,7 @@ func (p *Processor) NotificationsGet(ctx context.Context, authed *oauth.Auth, ma
continue
}
item, err := p.converter.NotificationToAPINotification(ctx, n)
item, err := p.converter.NotificationToAPINotification(ctx, n, filters)
if err != nil {
log.Debugf(ctx, "skipping notification %s because it couldn't be converted to its api representation: %s", n.ID, err)
continue
@ -104,7 +110,13 @@ func (p *Processor) NotificationGet(ctx context.Context, account *gtsmodel.Accou
return nil, gtserror.NewErrorNotFound(err)
}
apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif)
filters, err := p.state.DB.GetFiltersForAccountID(ctx, account.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", account.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
apiNotif, err := p.converter.NotificationToAPINotification(ctx, notif, filters)
if err != nil {
if errors.Is(err, db.ErrNoEntries) {
return nil, gtserror.NewErrorNotFound(err)

View file

@ -24,6 +24,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -46,6 +47,16 @@ func (p *Processor) PublicTimelineGet(
items = make([]any, 0, limit)
)
var filters []*gtsmodel.Filter
if requester != nil {
var err error
filters, err = p.state.DB.GetFiltersForAccountID(ctx, requester.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requester.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
}
// Try a few times to select appropriate public
// statuses from the db, paging up or down to
// reattempt if nothing suitable is found.
@ -87,7 +98,10 @@ outer:
continue inner
}
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requester, custom.FilterContextPublic, filters)
if errors.Is(err, custom.ErrHideStatus) {
continue
}
if err != nil {
log.Errorf(ctx, "error converting to api status: %v", err)
continue inner

View file

@ -24,6 +24,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@ -111,6 +112,12 @@ func (p *Processor) packageTagResponse(
prevMinIDValue = statuses[0].ID
)
filters, err := p.state.DB.GetFiltersForAccountID(ctx, requestingAcct.ID)
if err != nil {
err = gtserror.Newf("couldn't retrieve filters for account %s: %w", requestingAcct.ID, err)
return nil, gtserror.NewErrorInternalError(err)
}
for _, s := range statuses {
timelineable, err := p.filter.StatusTagTimelineable(ctx, requestingAcct, s)
if err != nil {
@ -122,7 +129,10 @@ func (p *Processor) packageTagResponse(
continue
}
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct)
apiStatus, err := p.converter.StatusToAPIStatus(ctx, s, requestingAcct, custom.FilterContextPublic, filters)
if errors.Is(err, custom.ErrHideStatus) {
continue
}
if err != nil {
log.Errorf(ctx, "error converting to api status: %v", err)
continue

View file

@ -28,6 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/messages"
@ -150,6 +151,8 @@ func (suite *FromClientAPITestSuite) statusJSON(
ctx,
status,
requestingAccount,
custom.FilterContextNone,
nil,
)
if err != nil {
suite.FailNow(err.Error())
@ -245,7 +248,7 @@ func (suite *FromClientAPITestSuite) TestProcessCreateStatusWithNotification() {
suite.FailNow("timed out waiting for new status notification")
}
apiNotif, err := suite.typeconverter.NotificationToAPINotification(ctx, notif)
apiNotif, err := suite.typeconverter.NotificationToAPINotification(ctx, notif, nil)
if err != nil {
suite.FailNow(err.Error())
}

View file

@ -428,8 +428,13 @@ func (s *surface) notify(
return gtserror.Newf("error putting notification in database: %w", err)
}
filters, err := s.state.DB.GetFiltersForAccountID(ctx, targetAccount.ID)
if err != nil {
return gtserror.Newf("couldn't retrieve filters for account %s: %w", targetAccount.ID, err)
}
// Stream notification to the user.
apiNotif, err := s.converter.NotificationToAPINotification(ctx, notif)
apiNotif, err := s.converter.NotificationToAPINotification(ctx, notif, filters)
if err != nil {
return gtserror.Newf("error converting notification to api representation: %w", err)
}

View file

@ -22,6 +22,7 @@ import (
"errors"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -111,6 +112,11 @@ func (s *surface) timelineAndNotifyStatusForFollowers(
continue
}
filters, err := s.state.DB.GetFiltersForAccountID(ctx, follow.AccountID)
if err != nil {
return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err)
}
// Add status to any relevant lists
// for this follow, if applicable.
s.listTimelineStatusForFollow(
@ -118,6 +124,7 @@ func (s *surface) timelineAndNotifyStatusForFollowers(
status,
follow,
&errs,
filters,
)
// Add status to home timeline for owner
@ -129,6 +136,7 @@ func (s *surface) timelineAndNotifyStatusForFollowers(
follow.Account,
status,
stream.TimelineHome,
filters,
)
if err != nil {
errs.Appendf("error home timelining status: %w", err)
@ -180,6 +188,7 @@ func (s *surface) listTimelineStatusForFollow(
status *gtsmodel.Status,
follow *gtsmodel.Follow,
errs *gtserror.MultiError,
filters []*gtsmodel.Filter,
) {
// To put this status in appropriate list timelines,
// we need to get each listEntry that pertains to
@ -222,6 +231,7 @@ func (s *surface) listTimelineStatusForFollow(
follow.Account,
status,
stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
filters,
); err != nil {
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
// implicit continue
@ -332,6 +342,7 @@ func (s *surface) timelineStatus(
account *gtsmodel.Account,
status *gtsmodel.Status,
streamType string,
filters []*gtsmodel.Filter,
) (bool, error) {
// Ingest status into given timeline using provided function.
if inserted, err := ingest(ctx, timelineID, status); err != nil {
@ -343,7 +354,7 @@ func (s *surface) timelineStatus(
}
// The status was inserted so stream it to the user.
apiStatus, err := s.converter.StatusToAPIStatus(ctx, status, account)
apiStatus, err := s.converter.StatusToAPIStatus(ctx, status, account, custom.FilterContextHome, filters)
if err != nil {
err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
return true, err
@ -457,6 +468,11 @@ func (s *surface) timelineStatusUpdateForFollowers(
continue
}
filters, err := s.state.DB.GetFiltersForAccountID(ctx, follow.AccountID)
if err != nil {
return gtserror.Newf("couldn't retrieve filters for account %s: %w", follow.AccountID, err)
}
// Add status to any relevant lists
// for this follow, if applicable.
s.listTimelineStatusUpdateForFollow(
@ -464,6 +480,7 @@ func (s *surface) timelineStatusUpdateForFollowers(
status,
follow,
&errs,
filters,
)
// Add status to home timeline for owner
@ -473,6 +490,7 @@ func (s *surface) timelineStatusUpdateForFollowers(
follow.Account,
status,
stream.TimelineHome,
filters,
)
if err != nil {
errs.Appendf("error home timelining status: %w", err)
@ -490,6 +508,7 @@ func (s *surface) listTimelineStatusUpdateForFollow(
status *gtsmodel.Status,
follow *gtsmodel.Follow,
errs *gtserror.MultiError,
filters []*gtsmodel.Filter,
) {
// To put this status in appropriate list timelines,
// we need to get each listEntry that pertains to
@ -530,6 +549,7 @@ func (s *surface) listTimelineStatusUpdateForFollow(
follow.Account,
status,
stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list
filters,
); err != nil {
errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err)
// implicit continue
@ -544,8 +564,13 @@ func (s *surface) timelineStreamStatusUpdate(
account *gtsmodel.Account,
status *gtsmodel.Status,
streamType string,
filters []*gtsmodel.Filter,
) error {
apiStatus, err := s.converter.StatusToAPIStatus(ctx, status, account)
apiStatus, err := s.converter.StatusToAPIStatus(ctx, status, account, custom.FilterContextHome, filters)
if errors.Is(err, custom.ErrHideStatus) {
// Don't put this status in the stream.
return nil
}
if err != nil {
err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
return err

View file

@ -24,6 +24,7 @@ import (
"codeberg.org/gruf/go-kv"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log"
)
@ -121,6 +122,12 @@ func (t *timeline) prepareXBetweenIDs(ctx context.Context, amount int, behindID
for e, entry := range toPrepare {
prepared, err := t.prepareFunction(ctx, t.timelineID, entry.itemID)
if err != nil {
if errors.Is(err, custom.ErrHideStatus) {
// This item has been filtered out by the requesting user's filters.
// Remove it and skip past it.
t.items.data.Remove(e)
continue
}
if errors.Is(err, db.ErrNoEntries) {
// ErrNoEntries means something has been deleted,
// so we'll likely not be able to ever prepare this.

View file

@ -473,16 +473,19 @@ const (
type TypeUtilsTestSuite struct {
suite.Suite
db db.DB
state state.State
testAccounts map[string]*gtsmodel.Account
testStatuses map[string]*gtsmodel.Status
testAttachments map[string]*gtsmodel.MediaAttachment
testPeople map[string]vocab.ActivityStreamsPerson
testEmojis map[string]*gtsmodel.Emoji
testReports map[string]*gtsmodel.Report
testMentions map[string]*gtsmodel.Mention
testPollVotes map[string]*gtsmodel.PollVote
db db.DB
state state.State
testAccounts map[string]*gtsmodel.Account
testStatuses map[string]*gtsmodel.Status
testAttachments map[string]*gtsmodel.MediaAttachment
testPeople map[string]vocab.ActivityStreamsPerson
testEmojis map[string]*gtsmodel.Emoji
testReports map[string]*gtsmodel.Report
testMentions map[string]*gtsmodel.Mention
testPollVotes map[string]*gtsmodel.PollVote
testFilters map[string]*gtsmodel.Filter
testFilterKeywords map[string]*gtsmodel.FilterKeyword
testFilterStatues map[string]*gtsmodel.FilterStatus
typeconverter *typeutils.Converter
}
@ -506,6 +509,9 @@ func (suite *TypeUtilsTestSuite) SetupTest() {
suite.testReports = testrig.NewTestReports()
suite.testMentions = testrig.NewTestMentions()
suite.testPollVotes = testrig.NewTestPollVotes()
suite.testFilters = testrig.NewTestFilters()
suite.testFilterKeywords = testrig.NewTestFilterKeywords()
suite.testFilterStatues = testrig.NewTestFilterStatuses()
suite.typeconverter = typeutils.NewConverter(&suite.state)
testrig.StandardDBSetup(suite.db, nil)

View file

@ -22,12 +22,15 @@ import (
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/language"
@ -684,12 +687,19 @@ func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistor
// (frontend) representation for serialization on the API.
//
// Requesting account can be nil.
//
// Filter context can be the empty string if these statuses are not being filtered.
//
// If there is a matching "hide" filter, the returned status will be nil with a ErrHideStatus error;
// callers need to handle that case by excluding it from results.
func (c *Converter) StatusToAPIStatus(
ctx context.Context,
s *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
filterContext custom.FilterContext,
filters []*gtsmodel.Filter,
) (*apimodel.Status, error) {
apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount)
apiStatus, err := c.statusToFrontend(ctx, s, requestingAccount, filterContext, filters)
if err != nil {
return nil, err
}
@ -704,6 +714,143 @@ func (c *Converter) StatusToAPIStatus(
return apiStatus, nil
}
// statusToAPIFilterResults applies filters to a status and returns an API filter result object.
// The result may be nil if no filters matched.
// If the status should not be returned at all, it returns the ErrHideStatus error.
func (c *Converter) statusToAPIFilterResults(
ctx context.Context,
s *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
filterContext custom.FilterContext,
filters []*gtsmodel.Filter,
) ([]apimodel.FilterResult, error) {
if filterContext == "" || len(filters) == 0 || s.AccountID == requestingAccount.ID {
return nil, nil
}
filterResults := make([]apimodel.FilterResult, 0, len(filters))
now := time.Now()
for _, filter := range filters {
if !filterAppliesInContext(filter, filterContext) {
// Filter doesn't apply to this context.
continue
}
if !filter.ExpiresAt.IsZero() && filter.ExpiresAt.Before(now) {
// Filter is expired.
continue
}
// List all matching keywords.
keywordMatches := make([]string, 0, len(filter.Keywords))
fields := filterableTextFields(s)
for _, filterKeyword := range filter.Keywords {
wholeWord := util.PtrValueOr(filterKeyword.WholeWord, false)
wordBreak := ``
if wholeWord {
wordBreak = `\b`
}
re, err := regexp.Compile(`(?i)` + wordBreak + regexp.QuoteMeta(filterKeyword.Keyword) + wordBreak)
if err != nil {
return nil, err
}
var isMatch bool
for _, field := range fields {
if re.MatchString(field) {
isMatch = true
break
}
}
if isMatch {
keywordMatches = append(keywordMatches, filterKeyword.Keyword)
}
}
// A status has only one ID. Not clear why this is a list in the Mastodon API.
statusMatches := make([]string, 0, 1)
for _, filterStatus := range filter.Statuses {
if s.ID == filterStatus.StatusID {
statusMatches = append(statusMatches, filterStatus.StatusID)
break
}
}
if len(keywordMatches) > 0 || len(statusMatches) > 0 {
switch filter.Action {
case gtsmodel.FilterActionWarn:
// Record what matched.
apiFilter, err := c.FilterToAPIFilterV2(ctx, filter)
if err != nil {
return nil, err
}
filterResults = append(filterResults, apimodel.FilterResult{
Filter: *apiFilter,
KeywordMatches: keywordMatches,
StatusMatches: statusMatches,
})
case gtsmodel.FilterActionHide:
// Don't show this status. Immediate return.
return nil, custom.ErrHideStatus
}
}
}
return filterResults, nil
}
// filterableTextFields returns all text from a status that we might want to filter on:
// - content
// - content warning
// - media descriptions
// - poll options
func filterableTextFields(s *gtsmodel.Status) []string {
fieldCount := 2 + len(s.Attachments)
if s.Poll != nil {
fieldCount += len(s.Poll.Options)
}
fields := make([]string, 0, fieldCount)
if s.Content != "" {
// TODO: (Vyr) convert this HTML field to plain text before returning
fields = append(fields, s.Content)
}
if s.ContentWarning != "" {
fields = append(fields, s.ContentWarning)
}
for _, attachment := range s.Attachments {
if attachment.Description != "" {
fields = append(fields, attachment.Description)
}
}
if s.Poll != nil {
for _, option := range s.Poll.Options {
if option != "" {
fields = append(fields, option)
}
}
}
return fields
}
// filterAppliesInContext returns whether a given filter applies in a given context.
func filterAppliesInContext(filter *gtsmodel.Filter, filterContext custom.FilterContext) bool {
switch filterContext {
case custom.FilterContextHome:
return util.PtrValueOr(filter.ContextHome, false)
case custom.FilterContextNotifications:
return util.PtrValueOr(filter.ContextNotifications, false)
case custom.FilterContextPublic:
return util.PtrValueOr(filter.ContextPublic, false)
case custom.FilterContextThread:
return util.PtrValueOr(filter.ContextThread, false)
case custom.FilterContextAccount:
return util.PtrValueOr(filter.ContextAccount, false)
}
return false
}
// StatusToWebStatus converts a gts model status into an
// api representation suitable for serving into a web template.
//
@ -713,7 +860,7 @@ func (c *Converter) StatusToWebStatus(
s *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
) (*apimodel.Status, error) {
webStatus, err := c.statusToFrontend(ctx, s, requestingAccount)
webStatus, err := c.statusToFrontend(ctx, s, requestingAccount, custom.FilterContextNone, nil)
if err != nil {
return nil, err
}
@ -815,6 +962,8 @@ func (c *Converter) statusToFrontend(
ctx context.Context,
s *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
filterContext custom.FilterContext,
filters []*gtsmodel.Filter,
) (*apimodel.Status, error) {
// Try to populate status struct pointer fields.
// We can continue in many cases of partial failure,
@ -913,7 +1062,11 @@ func (c *Converter) statusToFrontend(
}
if s.BoostOf != nil {
reblog, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount)
reblog, err := c.StatusToAPIStatus(ctx, s.BoostOf, requestingAccount, filterContext, filters)
if errors.Is(err, custom.ErrHideStatus) {
// If we'd hide the original status, hide the boost.
return nil, err
}
if err != nil {
return nil, gtserror.Newf("error converting boosted status: %w", err)
}
@ -977,6 +1130,13 @@ func (c *Converter) statusToFrontend(
s.URL = s.URI
}
// Apply filters.
filterResults, err := c.statusToAPIFilterResults(ctx, s, requestingAccount, filterContext, filters)
if err != nil {
return nil, fmt.Errorf("error applying filters: %w", err)
}
apiStatus.Filtered = filterResults
return apiStatus, nil
}
@ -1252,7 +1412,7 @@ func (c *Converter) RelationshipToAPIRelationship(ctx context.Context, r *gtsmod
}
// NotificationToAPINotification converts a gts notification into a api notification
func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification) (*apimodel.Notification, error) {
func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmodel.Notification, filters []*gtsmodel.Filter) (*apimodel.Notification, error) {
if n.TargetAccount == nil {
tAccount, err := c.state.DB.GetAccountByID(ctx, n.TargetAccountID)
if err != nil {
@ -1293,7 +1453,7 @@ func (c *Converter) NotificationToAPINotification(ctx context.Context, n *gtsmod
}
var err error
apiStatus, err = c.StatusToAPIStatus(ctx, n.Status, n.TargetAccount)
apiStatus, err = c.StatusToAPIStatus(ctx, n.Status, n.TargetAccount, custom.FilterContextNotifications, filters)
if err != nil {
return nil, fmt.Errorf("NotificationToapi: error converting status to api: %s", err)
}
@ -1446,7 +1606,7 @@ func (c *Converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo
}
}
for _, s := range r.Statuses {
status, err := c.StatusToAPIStatus(ctx, s, requestingAccount)
status, err := c.StatusToAPIStatus(ctx, s, requestingAccount, "", nil)
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err)
}
@ -1687,6 +1847,55 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor
}
filter := filterKeyword.Filter
return &apimodel.FilterV1{
// v1 filters have a single keyword each, so we use the filter keyword ID as the v1 filter ID.
ID: filterKeyword.ID,
Phrase: filterKeyword.Keyword,
Context: filterToAPIFilterContexts(filter),
WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
ExpiresAt: filterExpiresAtToAPIFilterExpiresAt(filter.ExpiresAt),
Irreversible: filter.Action == gtsmodel.FilterActionHide,
}, nil
}
// FilterToAPIFilterV2 converts one GTS model filter into an API v2 filter.
func (c *Converter) FilterToAPIFilterV2(ctx context.Context, filter *gtsmodel.Filter) (*apimodel.FilterV2, error) {
apiFilterKeywords := make([]apimodel.FilterKeyword, 0, len(filter.Keywords))
for _, filterKeyword := range filter.Keywords {
apiFilterKeywords = append(apiFilterKeywords, apimodel.FilterKeyword{
ID: filterKeyword.ID,
Keyword: filterKeyword.Keyword,
WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
})
}
apiFilterStatuses := make([]apimodel.FilterStatus, 0, len(filter.Keywords))
for _, filterStatus := range filter.Statuses {
apiFilterStatuses = append(apiFilterStatuses, apimodel.FilterStatus{
ID: filterStatus.ID,
StatusID: filterStatus.StatusID,
})
}
return &apimodel.FilterV2{
ID: filter.ID,
Title: filter.Title,
Context: filterToAPIFilterContexts(filter),
ExpiresAt: filterExpiresAtToAPIFilterExpiresAt(filter.ExpiresAt),
FilterAction: filterActionToAPIFilterAction(filter.Action),
Keywords: apiFilterKeywords,
Statuses: apiFilterStatuses,
}, nil
}
func filterExpiresAtToAPIFilterExpiresAt(expiresAt time.Time) *string {
if expiresAt.IsZero() {
return nil
}
return util.Ptr(util.FormatISO8601(expiresAt))
}
func filterToAPIFilterContexts(filter *gtsmodel.Filter) []apimodel.FilterContext {
apiContexts := make([]apimodel.FilterContext, 0, apimodel.FilterContextNumValues)
if util.PtrValueOr(filter.ContextHome, false) {
apiContexts = append(apiContexts, apimodel.FilterContextHome)
@ -1703,21 +1912,17 @@ func (c *Converter) FilterKeywordToAPIFilterV1(ctx context.Context, filterKeywor
if util.PtrValueOr(filter.ContextAccount, false) {
apiContexts = append(apiContexts, apimodel.FilterContextAccount)
}
return apiContexts
}
var expiresAt *string
if !filter.ExpiresAt.IsZero() {
expiresAt = util.Ptr(util.FormatISO8601(filter.ExpiresAt))
func filterActionToAPIFilterAction(m gtsmodel.FilterAction) apimodel.FilterAction {
switch m {
case gtsmodel.FilterActionWarn:
return apimodel.FilterActionWarn
case gtsmodel.FilterActionHide:
return apimodel.FilterActionHide
}
return &apimodel.FilterV1{
// v1 filters have a single keyword each, so we use the filter keyword ID as the v1 filter ID.
ID: filterKeyword.ID,
Phrase: filterKeyword.Keyword,
Context: apiContexts,
WholeWord: util.PtrValueOr(filterKeyword.WholeWord, false),
ExpiresAt: expiresAt,
Irreversible: filter.Action == gtsmodel.FilterActionHide,
}, nil
return ""
}
// convertEmojisToAPIEmojis will convert a slice of GTS model emojis to frontend API model emojis, falling back to IDs if no GTS models supplied.

View file

@ -25,6 +25,7 @@ import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/custom"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@ -427,7 +428,7 @@ func (suite *InternalToFrontendTestSuite) TestLocalInstanceAccountToFrontendBloc
func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
testStatus := suite.testStatuses["admin_account_status_1"]
requestingAccount := suite.testAccounts["local_account_1"]
apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount)
apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, "", nil)
suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ")
@ -537,11 +538,186 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
}`, string(b))
}
// Test that a status which is filtered with a warn filter by the requesting user has `filtered` set correctly.
func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() {
testStatus := suite.testStatuses["admin_account_status_1"]
testStatus.Content += " fnord"
testStatus.Text += " fnord"
requestingAccount := suite.testAccounts["local_account_1"]
expectedMatchingFilter := suite.testFilters["local_account_1_filter_1"]
expectedMatchingFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"]
expectedMatchingFilterKeyword.Filter = expectedMatchingFilter
expectedMatchingFilter.Keywords = []*gtsmodel.FilterKeyword{expectedMatchingFilterKeyword}
requestingAccountFilters := []*gtsmodel.Filter{expectedMatchingFilter}
apiStatus, err := suite.typeconverter.StatusToAPIStatus(
context.Background(),
testStatus,
requestingAccount,
custom.FilterContextHome,
requestingAccountFilters,
)
suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ")
suite.NoError(err)
suite.Equal(`{
"id": "01F8MH75CBF9JFX4ZAD54N0W0R",
"created_at": "2021-10-20T11:36:45.000Z",
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": false,
"spoiler_text": "",
"visibility": "public",
"language": "en",
"uri": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
"url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
"replies_count": 1,
"reblogs_count": 0,
"favourites_count": 1,
"favourited": true,
"reblogged": false,
"muted": false,
"bookmarked": true,
"pinned": false,
"content": "hello world! #welcome ! first post on the instance :rainbow: ! fnord",
"reblog": null,
"application": {
"name": "superseriousbusiness",
"website": "https://superserious.business"
},
"account": {
"id": "01F8MH17FWEB39HZJ76B6VXSKF",
"username": "admin",
"acct": "admin",
"display_name": "",
"locked": false,
"discoverable": true,
"bot": false,
"created_at": "2022-05-17T13:10:59.000Z",
"note": "",
"url": "http://localhost:8080/@admin",
"avatar": "",
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.png",
"header_static": "http://localhost:8080/assets/default_header.png",
"followers_count": 1,
"following_count": 1,
"statuses_count": 4,
"last_status_at": "2021-10-20T10:41:37.000Z",
"emojis": [],
"fields": [],
"enable_rss": true,
"role": {
"name": "admin"
}
},
"media_attachments": [
{
"id": "01F8MH6NEM8D7527KZAECTCR76",
"type": "image",
"url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
"text_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
"preview_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpg",
"remote_url": null,
"preview_remote_url": null,
"meta": {
"original": {
"width": 1200,
"height": 630,
"size": "1200x630",
"aspect": 1.9047619
},
"small": {
"width": 256,
"height": 134,
"size": "256x134",
"aspect": 1.9104477
},
"focus": {
"x": 0,
"y": 0
}
},
"description": "Black and white image of some 50's style text saying: Welcome On Board",
"blurhash": "LNJRdVM{00Rj%Mayt7j[4nWBofRj"
}
],
"mentions": [],
"tags": [
{
"name": "welcome",
"url": "http://localhost:8080/tags/welcome"
}
],
"emojis": [
{
"shortcode": "rainbow",
"url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
"static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
"visible_in_picker": true,
"category": "reactions"
}
],
"card": null,
"poll": null,
"text": "hello world! #welcome ! first post on the instance :rainbow: ! fnord",
"filtered": [
{
"filter": {
"id": "01HN26VM6KZTW1ANNRVSBMA461",
"title": "fnord",
"context": [
"home",
"public"
],
"expires_at": null,
"filter_action": "warn",
"keywords": [
{
"id": "01HN272TAVWAXX72ZX4M8JZ0PS",
"keyword": "fnord",
"whole_word": true
}
],
"statuses": []
},
"keyword_matches": [
"fnord"
],
"status_matches": []
}
]
}`, string(b))
}
// Test that a status which is filtered with a hide filter by the requesting user results in the ErrHideStatus error.
func (suite *InternalToFrontendTestSuite) TestHideFilteredStatusToFrontend() {
testStatus := suite.testStatuses["admin_account_status_1"]
testStatus.Content += " fnord"
testStatus.Text += " fnord"
requestingAccount := suite.testAccounts["local_account_1"]
expectedMatchingFilter := suite.testFilters["local_account_1_filter_1"]
expectedMatchingFilter.Action = gtsmodel.FilterActionHide
expectedMatchingFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"]
expectedMatchingFilterKeyword.Filter = expectedMatchingFilter
expectedMatchingFilter.Keywords = []*gtsmodel.FilterKeyword{expectedMatchingFilterKeyword}
requestingAccountFilters := []*gtsmodel.Filter{expectedMatchingFilter}
_, err := suite.typeconverter.StatusToAPIStatus(
context.Background(),
testStatus,
requestingAccount,
custom.FilterContextHome,
requestingAccountFilters,
)
suite.ErrorIs(err, custom.ErrHideStatus)
}
func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments() {
testStatus := suite.testStatuses["remote_account_2_status_1"]
requestingAccount := suite.testAccounts["admin_account"]
apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount)
apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, "", nil)
suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ")
@ -774,7 +950,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage()
*testStatus = *suite.testStatuses["admin_account_status_1"]
testStatus.Language = ""
requestingAccount := suite.testAccounts["local_account_1"]
apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount)
apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, "", nil)
suite.NoError(err)
b, err := json.MarshalIndent(apiStatus, "", " ")

View file

@ -78,7 +78,7 @@ skulk({
// commonjs here, no need for the typescript preset.
["babelify", {
global: true,
ignore: [/node_modules\/(?!nanoid)/],
ignore: [/node_modules\/(?!(nanoid)|(wouter))/],
}]
],
presets: [
@ -96,4 +96,4 @@ skulk({
}]]
}
}
});
});

View file

@ -13,7 +13,6 @@
"dependencies": {
"@reduxjs/toolkit": "^1.8.6",
"ariakit": "^2.0.0-next.41",
"bluebird": "^3.7.2",
"get-by-dot": "^1.0.2",
"is-valid-domain": "^0.1.6",
"js-file-download": "^0.4.12",
@ -33,26 +32,23 @@
"redux": "^4.2.0",
"redux-persist": "^6.0.0",
"skulk": "^0.0.8-fix",
"split-filter-n": "^1.1.3",
"syncpipe": "^1.0.0",
"wouter": "^2.8.0-alpha.2"
"wouter": "^3.1.0"
},
"devDependencies": {
"@babel/core": "^7.23.0",
"@babel/preset-env": "^7.22.20",
"@babel/preset-react": "^7.22.15",
"@babel/preset-typescript": "^7.23.0",
"@babel/core": "^7.24.4",
"@babel/preset-env": "^7.24.4",
"@babel/preset-react": "^7.24.1",
"@babel/preset-typescript": "^7.24.1",
"@browserify/envify": "^6.0.0",
"@browserify/uglifyify": "^6.0.0",
"@joepie91/eslint-config": "^1.1.1",
"@types/bluebird": "^3.5.39",
"@types/is-valid-domain": "^0.0.2",
"@types/papaparse": "^5.3.9",
"@types/psl": "^1.1.1",
"@types/react-dom": "^18.2.8",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"autoprefixer": "^10.4.13",
"autoprefixer": "^10.4.19",
"babelify": "^10.0.0",
"css-extract": "^2.0.0",
"eslint": "^8.26.0",

View file

@ -1,49 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { Switch, Route } from "wouter";
import DomainPermissionsOverview from "./overview";
import { PermType } from "../../lib/types/domain-permission";
import DomainPermDetail from "./detail";
export default function DomainPermissions({ baseUrl }: { baseUrl: string }) {
return (
<Switch>
<Route path="/settings/admin/domain-permissions/:permType/:domain">
{params => (
<DomainPermDetail
permType={params.permType as PermType}
baseUrl={baseUrl}
domain={params.domain}
/>
)}
</Route>
<Route path="/settings/admin/domain-permissions/:permType">
{params => (
<DomainPermissionsOverview
permType={params.permType as PermType}
baseUrl={baseUrl}
/>
)}
</Route>
</Switch>
);
}

View file

@ -1,96 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
const splitFilterN = require("split-filter-n");
const syncpipe = require('syncpipe');
const { matchSorter } = require("match-sorter");
const ComboBox = require("../../components/combo-box");
const { useListEmojiQuery } = require("../../lib/query/admin/custom-emoji");
function useEmojiByCategory(emoji) {
// split all emoji over an object keyed by the category names (or Unsorted)
return React.useMemo(() => splitFilterN(
emoji,
[],
(entry) => entry.category ?? "Unsorted"
), [emoji]);
}
function CategorySelect({ field, children }) {
const { value, setIsNew } = field;
const {
data: emoji = [],
isLoading,
isSuccess,
error
} = useListEmojiQuery({ filter: "domain:local" });
const emojiByCategory = useEmojiByCategory(emoji);
const categories = React.useMemo(() => new Set(Object.keys(emojiByCategory)), [emojiByCategory]);
// data used by the ComboBox element to select an emoji category
const categoryItems = React.useMemo(() => {
return syncpipe(emojiByCategory, [
(_) => Object.keys(_), // just emoji category names
(_) => matchSorter(_, value, { threshold: matchSorter.rankings.NO_MATCH }), // sorted by complex algorithm
(_) => _.map((categoryName) => [ // map to input value, and selectable element with icon
categoryName,
<>
<img src={emojiByCategory[categoryName][0].static_url} aria-hidden="true"></img>
{categoryName}
</>
])
]);
}, [emojiByCategory, value]);
React.useEffect(() => {
if (value != undefined && isSuccess && value.trim().length > 0) {
setIsNew(!categories.has(value.trim()));
}
}, [categories, value, isSuccess, setIsNew]);
if (error) { // fall back to plain text input, but this would almost certainly have caused a bigger error message elsewhere
return (
<>
<input type="text" placeholder="e.g., reactions" onChange={(e) => { field.value = e.target.value; }} />;
</>
);
} else if (isLoading) {
return <input type="text" value="Loading categories..." disabled={true} />;
}
return (
<ComboBox
field={field}
items={categoryItems}
label="Category"
placeholder="e.g., reactions"
children={children}
/>
);
}
module.exports = {
useEmojiByCategory,
CategorySelect
};

View file

@ -1,153 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
const { Link } = require("wouter");
const syncpipe = require("syncpipe");
const { matchSorter } = require("match-sorter");
const NewEmojiForm = require("./new-emoji").default;
const { useTextInput } = require("../../../lib/form");
const { useEmojiByCategory } = require("../category-select");
const { useBaseUrl } = require("../../../lib/navigation/util");
const Loading = require("../../../components/loading");
const { Error } = require("../../../components/error");
const { TextInput } = require("../../../components/form/inputs");
const { useListEmojiQuery } = require("../../../lib/query/admin/custom-emoji");
module.exports = function EmojiOverview({ }) {
const {
data: emoji = [],
isLoading,
isError,
error
} = useListEmojiQuery({ filter: "domain:local" });
let content = null;
if (isLoading) {
content = <Loading />;
} else if (isError) {
content = <Error error={error} />;
} else {
content = (
<>
<EmojiList emoji={emoji} />
<NewEmojiForm emoji={emoji} />
</>
);
}
return (
<>
<h1>Local Custom Emoji</h1>
<p>
To use custom emoji in your toots they have to be 'local' to the instance.
You can either upload them here directly, or copy from those already
present on other (known) instances through the <Link to={`./remote`}>Remote Emoji</Link> page.
</p>
<p>
<strong>Be warned!</strong> If you upload more than about 300-400 custom emojis in
total on your instance, this may lead to rate-limiting issues for users and clients
if they try to load all the emoji images at once (which is what many clients do).
</p>
{content}
</>
);
};
function EmojiList({ emoji }) {
const filterField = useTextInput("filter");
const filter = filterField.value;
const emojiByCategory = useEmojiByCategory(emoji);
/* Filter emoji based on shortcode match with user input, hiding empty categories */
const { filteredEmoji, hidden } = React.useMemo(() => {
let hidden = emoji.length;
const filteredEmoji = syncpipe(emojiByCategory, [
(_) => Object.entries(emojiByCategory),
(_) => _.map(([category, entries]) => {
let filteredEntries = matchSorter(entries, filter, { keys: ["shortcode"] });
if (filteredEntries.length == 0) {
return null;
} else {
hidden -= filteredEntries.length;
return [category, filteredEntries];
}
}),
(_) => _.filter((value) => value !== null)
]);
return { filteredEmoji, hidden };
}, [filter, emojiByCategory, emoji.length]);
return (
<div>
<h2>Overview</h2>
{emoji.length > 0
? <span>{emoji.length} custom emoji {hidden > 0 && `(${hidden} filtered)`}</span>
: <span>No custom emoji yet, you can add one below.</span>
}
<div className="list emoji-list">
<div className="header">
<TextInput
field={filterField}
name="emoji-shortcode"
placeholder="Search"
/>
</div>
<div className="entries scrolling">
{filteredEmoji.length > 0
? (
<div className="entries scrolling">
{filteredEmoji.map(([category, entries]) => {
return <EmojiCategory key={category} category={category} entries={entries} />;
})}
</div>
)
: <div className="entry">No local emoji matched your filter.</div>
}
</div>
</div>
</div>
);
}
function EmojiCategory({ category, entries }) {
const baseUrl = useBaseUrl();
return (
<div className="entry">
<b>{category}</b>
<div className="emoji-group">
{entries.map((e) => {
return (
<Link key={e.id} to={`${baseUrl}/${e.id}`}>
<a>
<img src={e.url} alt={e.shortcode} title={`:${e.shortcode}:`} />
</a>
</Link>
);
})}
</div>
</div>
);
}

View file

@ -1,174 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { Switch, Route, Link, Redirect, useRoute } from "wouter";
import { useInstanceRulesQuery, useAddInstanceRuleMutation, useUpdateInstanceRuleMutation, useDeleteInstanceRuleMutation } from "../../lib/query";
import FormWithData from "../../lib/form/form-with-data";
import { useBaseUrl } from "../../lib/navigation/util";
import { useValue, useTextInput } from "../../lib/form";
import useFormSubmit from "../../lib/form/submit";
import { TextArea } from "../../components/form/inputs";
import MutationButton from "../../components/form/mutation-button";
import { Error } from "../../components/error";
export default function InstanceRulesData({ baseUrl }) {
return (
<FormWithData
dataQuery={useInstanceRulesQuery}
DataForm={InstanceRules}
{...{baseUrl}}
/>
);
}
function InstanceRules({ baseUrl, data: rules }) {
return (
<Switch>
<Route path={`${baseUrl}/:ruleId`}>
<InstanceRuleDetail rules={rules} />
</Route>
<Route>
<div>
<h1>Instance Rules</h1>
<div>
<p>
The rules for your instance are listed on the about page, and can be selected when submitting reports.
</p>
</div>
<InstanceRuleList rules={rules} />
</div>
</Route>
</Switch>
);
}
function InstanceRuleList({ rules }) {
const newRule = useTextInput("text", {});
const [submitForm, result] = useFormSubmit({ newRule }, useAddInstanceRuleMutation(), {
changedOnly: true,
onFinish: () => newRule.reset()
});
return (
<>
<form onSubmit={submitForm} className="new-rule">
<ol className="instance-rules">
{Object.values(rules).map((rule: any) => (
<InstanceRule key={rule.id} rule={rule} />
))}
</ol>
<TextArea
field={newRule}
label="New instance rule"
/>
<MutationButton
disabled={newRule.value === undefined || newRule.value.length === 0}
label="Add rule"
result={result}
/>
</form>
</>
);
}
function InstanceRule({ rule }) {
const baseUrl = useBaseUrl();
return (
<Link to={`${baseUrl}/${rule.id}`}>
<a className="rule">
<li>
<h2>{rule.text} <i className="fa fa-pencil edit-icon" /></h2>
</li>
<span>{new Date(rule.created_at).toLocaleString()}</span>
</a>
</Link>
);
}
function InstanceRuleDetail({ rules }) {
const baseUrl = useBaseUrl();
let [_match, params] = useRoute(`${baseUrl}/:ruleId`);
if (params?.ruleId == undefined || rules[params.ruleId] == undefined) {
return <Redirect to={baseUrl} />;
} else {
return (
<>
<Link to={baseUrl}><a>&lt; go back</a></Link>
<InstanceRuleForm rule={rules[params.ruleId]} />
</>
);
}
}
function InstanceRuleForm({ rule }) {
const baseUrl = useBaseUrl();
const form = {
id: useValue("id", rule.id),
rule: useTextInput("text", { defaultValue: rule.text })
};
const [submitForm, result] = useFormSubmit(form, useUpdateInstanceRuleMutation());
const [deleteRule, deleteResult] = useDeleteInstanceRuleMutation({ fixedCacheKey: rule.id });
if (result.isSuccess || deleteResult.isSuccess) {
return (
<Redirect to={baseUrl} />
);
}
return (
<div className="rule-detail">
<form onSubmit={submitForm}>
<TextArea
field={form.rule}
/>
<div className="action-buttons row">
<MutationButton
label="Save"
showError={false}
result={result}
disabled={!form.rule.hasChanged()}
/>
<MutationButton
disabled={false}
type="button"
onClick={() => deleteRule(rule.id)}
label="Delete"
className="button danger"
showError={false}
result={deleteResult}
/>
</div>
{result.error && <Error error={result.error} />}
{deleteResult.error && <Error error={deleteResult.error} />}
</form>
</div>
);
}

View file

@ -64,11 +64,11 @@ export function AccountList({
return (
<div className="list">
{data.map(({ account: acc }) => (
{data.map(({ account: acc }) => (
<Link
key={acc.acct}
className="account entry"
href={`/settings/admin/accounts/${acc.id}`}
href={`/${acc.id}`}
>
{acc.display_name?.length > 0
? acc.display_name
@ -79,4 +79,4 @@ export function AccountList({
))}
</div>
);
}
}

View file

@ -17,13 +17,11 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
const { Link } = require("wouter");
import React from "react";
import { Link } from "wouter";
module.exports = function BackButton({ to }) {
export default function BackButton({ to }) {
return (
<Link to={to}>
<a className="button">&lt; back</a>
</Link>
<Link className="button" to={to}>&lt; back</Link>
);
};
}

View file

@ -17,15 +17,11 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
import React from "react";
const {
Combobox,
ComboboxItem,
ComboboxPopover,
} = require("ariakit/combobox");
import { Combobox, ComboboxItem, ComboboxPopover } from "ariakit/combobox";
module.exports = function ComboBox({ field, items, label, children, ...inputProps }) {
export default function ComboBox({ field, items, label, children, ...inputProps }) {
return (
<div className="form-field combobox-wrapper">
<label>
@ -48,4 +44,4 @@ module.exports = function ComboBox({ field, items, label, children, ...inputProp
</ComboboxPopover>
</div>
);
};
}

View file

@ -17,7 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
import React from "react";
function ErrorFallback({ error, resetErrorBoundary }) {
return (
@ -81,4 +81,4 @@ function Error({ error }) {
);
}
module.exports = { ErrorFallback, Error };
export { ErrorFallback, Error };

View file

@ -17,9 +17,9 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
import React from "react";
module.exports = function FakeProfile({ avatar, header, display_name, username, role }) {
export default function FakeProfile({ avatar, header, display_name, username, role }) {
return ( // Keep in sync with web/template/profile.tmpl
<div className="profile">
<div className="profile-header">
@ -49,4 +49,4 @@ module.exports = function FakeProfile({ avatar, header, display_name, username,
</div>
</div>
);
};
}

View file

@ -17,16 +17,15 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
import React from "react";
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
const query = require("../lib/query");
module.exports = function FakeToot({ children }) {
export default function FakeToot({ children }) {
const { data: account = {
avatar: "/assets/default_avatars/GoToSocial_icon1.png",
display_name: "",
username: ""
} } = query.useVerifyCredentialsQuery();
} } = useVerifyCredentialsQuery();
return (
<article className="status expanded">
@ -54,4 +53,4 @@ module.exports = function FakeToot({ children }) {
</section>
</article>
);
};
}

View file

@ -17,10 +17,10 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
const langs = require("langs");
import React from "react";
import { all } from "langs";
const asElements = langs.all().map((l) => {
const asElements = all().map((l) => {
let code = l["1"].toUpperCase();
let name = l.name;
if (l.name != l.local) {
@ -29,6 +29,6 @@ const asElements = langs.all().map((l) => {
return <option key={code} value={code}>{name}</option>;
});
module.exports = function Languages() {
export default function Languages() {
return asElements;
};
}

View file

@ -17,10 +17,10 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
import React from "react";
module.exports = function Loading() {
export default function Loading() {
return (
<i className="fa fa-spin fa-refresh loading-icon" aria-label="Loading" title="Loading" />
);
};
}

View file

@ -17,15 +17,12 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
const Loading = require("./loading");
const {
useVerifyCredentialsQuery,
useLogoutMutation,
} = require("../lib/query/oauth");
const { useInstanceV1Query } = require("../lib/query");
import React from "react";
import Loading from "./loading";
import { useVerifyCredentialsQuery, useLogoutMutation } from "../lib/query/oauth";
import { useInstanceV1Query } from "../lib/query/gts-api";
module.exports = function UserLogoutCard() {
export default function UserLogoutCard() {
const { data: profile, isLoading } = useVerifyCredentialsQuery();
const { data: instance } = useInstanceV1Query();
const [logoutQuery] = useLogoutMutation();
@ -44,4 +41,4 @@ module.exports = function UserLogoutCard() {
</div>
);
}
};
}

View file

@ -1,124 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
const ReactDom = require("react-dom/client");
const { Provider } = require("react-redux");
const { PersistGate } = require("redux-persist/integration/react");
const { store, persistor } = require("./redux/store");
const { createNavigation, Menu, Item } = require("./lib/navigation");
const { Authorization } = require("./components/authorization");
const Loading = require("./components/loading");
const UserLogoutCard = require("./components/user-logout-card");
const { RoleContext } = require("./lib/navigation/util");
const UserProfile = require("./user/profile").default;
const UserSettings = require("./user/settings").default;
const UserMigration = require("./user/migration").default;
const Reports = require("./admin/reports").default;
const Accounts = require("./admin/accounts").default;
const AccountsPending = require("./admin/accounts/pending").default;
const DomainPerms = require("./admin/domain-permissions").default;
const DomainPermsImportExport = require("./admin/domain-permissions/import-export").default;
const AdminMedia = require("./admin/actions/media").default;
const AdminKeys = require("./admin/actions/keys").default;
const LocalEmoji = require("./admin/emoji/local").default;
const RemoteEmoji = require("./admin/emoji/remote").default;
const InstanceSettings = require("./admin/settings").default;
const InstanceRules = require("./admin/settings/rules").default;
require("./style.css");
const { Sidebar, ViewRouter } = createNavigation("/settings", [
Menu("User", [
Item("Profile", { icon: "fa-user" }, UserProfile),
Item("Settings", { icon: "fa-cogs" }, UserSettings),
Item("Migration", { icon: "fa-exchange" }, UserMigration),
]),
Menu("Moderation", {
url: "admin",
permissions: ["admin"]
}, [
Item("Reports", { icon: "fa-flag", wildcard: true }, Reports),
Item("Accounts", { icon: "fa-users", wildcard: true }, [
Item("Overview", { icon: "fa-list", url: "", wildcard: true }, Accounts),
Item("Pending", { icon: "fa-question", url: "pending", wildcard: true }, AccountsPending),
]),
Menu("Domain Permissions", { icon: "fa-hubzilla" }, [
Item("Blocks", { icon: "fa-close", url: "block", wildcard: true }, DomainPerms),
Item("Allows", { icon: "fa-check", url: "allow", wildcard: true }, DomainPerms),
Item("Import/Export", { icon: "fa-floppy-o", url: "import-export", wildcard: true }, DomainPermsImportExport),
]),
]),
Menu("Administration", {
url: "admin",
defaultUrl: "/settings/admin/settings",
permissions: ["admin"]
}, [
Menu("Actions", { icon: "fa-bolt" }, [
Item("Media", { icon: "fa-photo" }, AdminMedia),
Item("Keys", { icon: "fa-key-modern" }, AdminKeys),
]),
Menu("Custom Emoji", { icon: "fa-smile-o" }, [
Item("Local", { icon: "fa-home", wildcard: true }, LocalEmoji),
Item("Remote", { icon: "fa-cloud" }, RemoteEmoji),
]),
Menu("Settings", { icon: "fa-sliders" }, [
Item("Settings", { icon: "fa-sliders", url: "" }, InstanceSettings),
Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, InstanceRules),
]),
])
]);
function App({ account }) {
const permissions = [account.role.name];
return (
<RoleContext.Provider value={permissions}>
<div className="sidebar">
<UserLogoutCard />
<Sidebar />
</div>
<section className="with-sidebar">
<ViewRouter />
</section>
</RoleContext.Provider>
);
}
function Main() {
return (
<Provider store={store}>
<PersistGate loading={<section><Loading /></section>} persistor={persistor}>
<Authorization App={App} />
</PersistGate>
</Provider>
);
}
const root = ReactDom.createRoot(document.getElementById("root"));
root.render(<React.StrictMode><Main /></React.StrictMode>);

View file

@ -0,0 +1,89 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { StrictMode, useMemo } from "react";
import "./style.css";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { store, persistor } from "./redux/store";
import { Authorization } from "./components/authorization";
import Loading from "./components/loading";
import { Account } from "./lib/types/account";
import { BaseUrlContext, RoleContext } from "./lib/navigation/util";
import { SidebarMenu } from "./lib/navigation/menu";
import { Redirect, Route, Router } from "wouter";
import AdminMenu from "./views/admin/menu";
import ModerationMenu from "./views/moderation/menu";
import UserMenu from "./views/user/menu";
import UserRouter from "./views/user/router";
import { ErrorBoundary } from "./lib/navigation/error";
import ModerationRouter from "./views/moderation/router";
import AdminRouter from "./views/admin/router";
interface AppProps {
account: Account;
}
export function App({ account }: AppProps) {
const roles: string[] = useMemo(() => [ account.role.name ], [account]);
return (
<RoleContext.Provider value={roles}>
<BaseUrlContext.Provider value={"/settings"}>
<SidebarMenu>
<UserMenu />
<ModerationMenu />
<AdminMenu />
</SidebarMenu>
<section className="with-sidebar">
<Router base="/settings">
<ErrorBoundary>
<UserRouter />
<ModerationRouter />
<AdminRouter />
{/*
Redirect to first part of UserRouter if
just the bare settings page is open, so
user isn't greeted with a blank page.
*/}
<Route><Redirect to="/user/profile" /></Route>
</ErrorBoundary>
</Router>
</section>
</BaseUrlContext.Provider>
</RoleContext.Provider>
);
}
function Main() {
return (
<Provider store={store}>
<PersistGate
loading={<section><Loading /></section>}
persistor={persistor}
>
<Authorization App={App} />
</PersistGate>
</Provider>
);
}
const root = createRoot(document.getElementById("root") as HTMLElement);
root.render(<StrictMode><Main /></StrictMode>);

View file

@ -1,201 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
const { Link, Route, Redirect, Switch, useLocation, useRouter } = require("wouter");
const syncpipe = require("syncpipe");
const {
RoleContext,
useHasPermission,
checkPermission,
BaseUrlContext
} = require("./util");
const ActiveRouteCtx = React.createContext();
function useActiveRoute() {
return React.useContext(ActiveRouteCtx);
}
function Sidebar(menuTree, routing) {
const components = menuTree.map((m) => m.MenuEntry);
return function SidebarComponent() {
const router = useRouter();
const [location] = useLocation();
let activeRoute = routing.find((l) => {
let [match] = router.matcher(l.routingUrl, location);
return match;
})?.routingUrl;
return (
<nav className="menu-tree">
<ul className="top-level">
<ActiveRouteCtx.Provider value={activeRoute}>
{components}
</ActiveRouteCtx.Provider>
</ul>
</nav>
);
};
}
function ViewRouter(routing, defaultRoute) {
return function ViewRouterComponent() {
const permissions = React.useContext(RoleContext);
const filteredRoutes = React.useMemo(() => {
return syncpipe(routing, [
(_) => _.filter((route) => checkPermission(route.permissions, permissions)),
(_) => _.map((route) => {
return (
<Route path={route.routingUrl} key={route.key}>
<ErrorBoundary>
{/* FIXME: implement reset */}
<BaseUrlContext.Provider value={route.url}>
{route.view}
</BaseUrlContext.Provider>
</ErrorBoundary>
</Route>
);
})
]);
}, [permissions]);
return (
<Switch>
{filteredRoutes}
<Redirect to={defaultRoute} />
</Switch>
);
};
}
function MenuComponent({ type, name, url, icon, permissions, links, level, children }) {
const activeRoute = useActiveRoute();
if (!useHasPermission(permissions)) {
return null;
}
const classes = [type];
if (level == 0) {
classes.push("top-level");
} else if (level == 1) {
classes.push("expanding");
} else {
classes.push("nested");
}
const isActive = links.includes(activeRoute);
if (isActive) {
classes.push("active");
}
const className = classes.join(" ");
return (
<li className={className}>
<Link href={url}>
<a tabIndex={level == 0 ? "-1" : null} className="title">
{icon && <i className={`icon fa fa-fw ${icon}`} aria-hidden="true" />}
{name}
</a>
</Link>
{(type == "category" && (level == 0 || isActive) && children?.length > 0) &&
<ul>
{children}
</ul>
}
</li>
);
}
class ErrorBoundary extends React.Component {
constructor() {
super();
this.state = {};
this.resetErrorBoundary = () => {
this.setState({});
};
}
static getDerivedStateFromError(error) {
return { hadError: true, error };
}
componentDidCatch(_e, info) {
this.setState({
...this.state,
componentStack: info.componentStack
});
}
render() {
if (this.state.hadError) {
return (
<ErrorFallback
error={this.state.error}
componentStack={this.state.componentStack}
resetErrorBoundary={this.resetErrorBoundary}
/>
);
} else {
return this.props.children;
}
}
}
function ErrorFallback({ error, componentStack, 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>
<div className="details">
<pre>
{error.name}: {error.message}
{componentStack && [
"\n\nComponent trace:",
componentStack
]}
{["\n\nError trace: ", error.stack]}
</pre>
</div>
<p>
<button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a>
</p>
</div>
);
}
module.exports = {
Sidebar,
ViewRouter,
MenuComponent
};

View file

@ -0,0 +1,98 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { Component, ReactNode } from "react";
interface ErrorBoundaryProps {
children?: ReactNode;
}
interface ErrorBoundaryState {
hadError?: boolean;
componentStack?;
error?;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
resetErrorBoundary: () => void;
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {};
this.resetErrorBoundary = () => {
this.setState({});
};
}
static getDerivedStateFromError(error) {
return { hadError: true, error };
}
componentDidCatch(_e, info) {
this.setState({
...this.state,
componentStack: info.componentStack
});
}
render() {
if (this.state.hadError) {
return (
<ErrorFallback
error={this.state.error}
componentStack={this.state.componentStack}
resetErrorBoundary={this.resetErrorBoundary}
/>
);
} else {
return this.props.children;
}
}
}
function ErrorFallback({ error, componentStack, 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>
<div className="details">
<pre>
{error.name}: {error.message}
{componentStack && [
"\n\nComponent trace:",
componentStack
]}
{["\n\nError trace: ", error.stack]}
</pre>
</div>
<p>
<button onClick={resetErrorBoundary}>Try again</button> or <a href="">refresh the page</a>
</p>
</div>
);
}
export { ErrorBoundary };

View file

@ -1,136 +0,0 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
const { nanoid } = require("nanoid");
const { Redirect } = require("wouter");
const { urlSafe } = require("./util");
const {
Sidebar,
ViewRouter,
MenuComponent
} = require("./components");
function createNavigation(rootUrl, menus) {
const root = {
url: rootUrl,
links: [],
};
const routing = [];
const menuTree = menus.map((creatorFunc) =>
creatorFunc(root, routing)
);
return {
Sidebar: Sidebar(menuTree, routing),
ViewRouter: ViewRouter(routing, root.redirectUrl)
};
}
function MenuEntry(name, opts, contents) {
if (contents == undefined) { // opts argument is optional
contents = opts;
opts = {};
}
return function createMenuEntry(root, routing) {
const type = Array.isArray(contents) ? "category" : "view";
let urlParts = [root.url];
if (opts.url != "") {
urlParts.push(opts.url ?? urlSafe(name));
}
const url = urlParts.join("/");
let routingUrl = url;
if (opts.wildcard) {
routingUrl += "/:wildcard*";
}
const entry = {
name, type,
url, routingUrl,
key: nanoid(),
permissions: opts.permissions ?? false,
icon: opts.icon,
links: [routingUrl],
level: (root.level ?? -1) + 1,
redirectUrl: opts.defaultUrl
};
if (type == "category") {
let entries = contents.map((creatorFunc) => creatorFunc(entry, routing));
let routes = [];
entries.forEach((e) => {
// move empty wildcard routes to end of category, to prevent overlap
if (e.url == entry.url) {
routes.unshift(e);
} else {
routes.push(e);
}
});
routes.reverse();
routing.push(...routes);
if (opts.redirectUrl != entry.url) {
routing.push({
key: entry.key,
url: entry.url,
permissions: entry.permissions,
routingUrl: entry.redirectUrl + "/:fallback*",
view: React.createElement(Redirect, { to: entry.redirectUrl })
});
entry.url = entry.redirectUrl;
}
root.links.push(...entry.links);
entry.MenuEntry = React.createElement(
MenuComponent,
entry,
entries.map((e) => e.MenuEntry)
);
} else {
entry.links.push(routingUrl);
root.links.push(routingUrl);
entry.view = React.createElement(contents, { baseUrl: url });
entry.MenuEntry = React.createElement(MenuComponent, entry);
}
if (root.redirectUrl == undefined) {
root.redirectUrl = entry.url;
}
return entry;
};
}
module.exports = {
createNavigation,
Menu: MenuEntry,
Item: MenuEntry
};

View file

@ -0,0 +1,175 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { PropsWithChildren } from "react";
import { Link, useRoute } from "wouter";
import {
BaseUrlContext,
MenuLevelContext,
useBaseUrl,
useHasPermission,
useMenuLevel,
} from "./util";
import UserLogoutCard from "../../components/user-logout-card";
import { nanoid } from "nanoid";
export interface MenuItemProps {
/**
* Name / title of this menu item.
*/
name?: string;
/**
* Url path component for this menu item.
*/
itemUrl: string;
/**
* If this menu item is a category containing
* children, which child should be selected by
* default when category title is clicked.
*
* Optional, use for categories only.
*/
defaultChild?: string;
/**
* Permissions required to access this
* menu item (none, "moderator", "admin").
*/
permissions?: string[];
/**
* Fork-awesome string to render
* icon for this menu item.
*/
icon?: string;
}
export function MenuItem(props: PropsWithChildren<MenuItemProps>) {
const {
name,
itemUrl,
defaultChild,
permissions,
icon,
children,
} = props;
// Derive where this item is
// in terms of URL routing.
const baseUrl = useBaseUrl();
const thisUrl = [ baseUrl, itemUrl ].join('/');
// Derive where this item is in
// terms of nesting within the menu.
const thisLevel = useMenuLevel();
const nextLevel = thisLevel+1;
const topLevel = thisLevel === 0;
// Check whether this item is currently active
// (ie., user has selected it in the menu).
//
// This uses a wildcard to mark both parent
// and relevant child as active.
//
// See:
// https://github.com/molefrog/wouter?tab=readme-ov-file#useroute-route-matching-and-parameters
const [isActive] = useRoute([ thisUrl, "*?" ].join("/"));
// Don't render item if logged-in user
// doesn't have permissions to use it.
if (!useHasPermission(permissions)) {
return null;
}
// Check whether this item has children.
const hasChildren = children !== undefined;
const childrenArray = hasChildren && Array.isArray(children);
// Class name of the item varies depending
// on where it is in the menu, and whether
// it has children beneath it or not.
const classNames: string[] = [];
if (topLevel) {
classNames.push("category", "top-level");
} else {
if (thisLevel === 1 && hasChildren) {
classNames.push("category", "expanding");
} else if (thisLevel === 1 && !hasChildren) {
classNames.push("view", "expanding");
} else if (thisLevel === 2) {
classNames.push("view", "nested");
}
}
if (isActive) {
classNames.push("active");
}
let content: React.JSX.Element | null;
if ((isActive || topLevel) && childrenArray) {
// Render children as a nested list.
content = <ul>{children}</ul>;
} else if (isActive && hasChildren) {
// Render child as solo element.
content = <>{children}</>;
} else {
// Not active: hide children.
content = null;
}
// If a default child is defined, this item should point to that.
const href = defaultChild ? [ thisUrl, defaultChild ].join("/") : thisUrl;
return (
<li key={nanoid()} className={classNames.join(" ")}>
<Link href={href} className="title">
<span>
{icon && <i className={`icon fa fa-fw ${icon}`} aria-hidden="true" />}
{name}
</span>
</Link>
{ content &&
<BaseUrlContext.Provider value={thisUrl}>
<MenuLevelContext.Provider value={nextLevel}>
{content}
</MenuLevelContext.Provider>
</BaseUrlContext.Provider>
}
</li>
);
}
export interface SidebarMenuProps{}
export function SidebarMenu({ children }: PropsWithChildren<SidebarMenuProps>) {
return (
<div className="sidebar">
<UserLogoutCard />
<nav className="menu-tree">
<MenuLevelContext.Provider value={0}>
<ul className="top-level">
{children}
</ul>
</MenuLevelContext.Provider>
</nav>
</div>
);
}

View file

@ -18,37 +18,62 @@
*/
import { createContext, useContext } from "react";
const RoleContext = createContext([]);
const RoleContext = createContext<string[]>([]);
const BaseUrlContext = createContext<string>("");
const MenuLevelContext = createContext<number>(0);
function urlSafe(str) {
function urlSafe(str: string) {
return str.toLowerCase().replace(/[\s/]+/g, "-");
}
function useHasPermission(permissions) {
const roles = useContext(RoleContext);
function useHasPermission(permissions: string[] | undefined) {
const roles = useContext<string[]>(RoleContext);
return checkPermission(permissions, roles);
}
function checkPermission(requiredPermissisons, user) {
// requiredPermissions can be 'false', in which case there are no restrictions
if (requiredPermissisons === false) {
// checkPermission returns true if the user's roles
// include requiredPermissions, or false otherwise.
function checkPermission(requiredPermissions: string[] | undefined, userRoles: string[]): boolean {
if (requiredPermissions === undefined) {
// No perms defined, so user
// implicitly has permission.
return true;
}
// or an array of roles, check if one of the user's roles is sufficient
return user.some((role) => requiredPermissisons.includes(role));
if (requiredPermissions.length === 0) {
// No perms defined, so user
// implicitly has permission.
return true;
}
// Check if one of the user's
// roles is sufficient.
return userRoles.some((role) => {
if (role === "admin") {
// Admins can
// see everything.
return true;
}
return requiredPermissions.includes(role);
});
}
function useBaseUrl() {
return useContext(BaseUrlContext);
}
function useMenuLevel() {
return useContext(MenuLevelContext);
}
export {
urlSafe,
RoleContext,
useHasPermission,
checkPermission,
BaseUrlContext,
useBaseUrl
useBaseUrl,
MenuLevelContext,
useMenuLevel,
};

View file

@ -21,6 +21,7 @@ import { replaceCacheOnMutation, removeFromCacheOnMutation } from "../query-modi
import { gtsApi } from "../gts-api";
import { listToKeyedObject } from "../transforms";
import { AdminAccount, HandleSignupParams, SearchAccountParams } from "../../types/account";
import { InstanceRule, MappedRules } from "../../types/rules";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
@ -120,14 +121,14 @@ const extended = gtsApi.injectEndpoints({
],
}),
instanceRules: build.query({
instanceRules: build.query<MappedRules, void>({
query: () => ({
url: `/api/v1/admin/instance/rules`
}),
transformResponse: listToKeyedObject<any>("id")
transformResponse: listToKeyedObject<InstanceRule>("id")
}),
addInstanceRule: build.mutation({
addInstanceRule: build.mutation<MappedRules, any>({
query: (formData) => ({
method: "POST",
url: `/api/v1/admin/instance/rules`,
@ -135,11 +136,7 @@ const extended = gtsApi.injectEndpoints({
body: formData,
discardEmpty: true
}),
transformResponse: (data) => {
return {
[data.id]: data
};
},
transformResponse: listToKeyedObject<InstanceRule>("id"),
...replaceCacheOnMutation("instanceRules"),
}),

View file

@ -20,7 +20,15 @@
export interface CustomEmoji {
id?: string;
shortcode: string;
url: string;
static_url: string;
visible_in_picker: boolean;
category?: string;
disabled: boolean;
updated_at: string;
total_file_size: number;
content_type: string;
uri: string;
}
/**

View file

@ -17,9 +17,13 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
module.exports = {
...require("./gts-api"),
...require("./oauth"),
...require("./user"),
...require("./admin")
};
export interface InstanceRule {
id: string;
created_at: string;
updated_at: string;
text: string;
}
export interface MappedRules {
[key: string]: InstanceRule;
}

View file

@ -53,21 +53,13 @@ ul li::before {
& > div,
& > form {
border-left: 0.2rem solid $border-accent;
padding-left: 0.4rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1rem 0;
h1, h2 {
h1, h2, h3, h4, h5 {
margin: 0;
margin-top: 0.1rem;
}
&:only-child {
border-left: none;
padding-left: none;
}
&:first-child {
@ -77,12 +69,6 @@ ul li::before {
&:last-child {
margin-bottom: 0;
}
&.without-border,
.without-border {
border-left: 0;
padding-left: 0;
}
}
& > .error {
@ -305,7 +291,8 @@ input, select, textarea {
) !important;
}
section.with-sidebar > div, section.with-sidebar > form {
section.with-sidebar > div,
section.with-sidebar > form {
display: flex;
flex-direction: column;
gap: 1rem;
@ -348,10 +335,6 @@ section.with-sidebar > div, section.with-sidebar > form {
display: flex;
flex-direction: column;
gap: 0.2rem;
h3 {
margin: 0;
}
}
.labelinput .border {
@ -618,31 +601,33 @@ span.form-info {
@media screen and (max-width: 60rem) {
/* vertical layout */
#root {
padding: 1rem;
padding: 0.5rem;
margin: 0;
grid-template-columns: 100%;
grid-template-rows: auto auto;
.sidebar {
div.sidebar {
justify-self: auto;
margin-bottom: 2rem;
margin-bottom: 0;
}
.sidebar, section.with-sidebar {
div.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 {
section.with-sidebar {
grid-column: 1;
padding: 1rem;
}
div.sidebar a:first-child h2 {
border-top-right-radius: $br;
}
}
section {
grid-column: 1;
}
.user-profile .overview {
grid-template-columns: auto;
grid-template-rows: auto 1fr;

View file

@ -18,13 +18,10 @@
*/
import React from "react";
import { useInstanceKeysExpireMutation } from "../../../lib/query";
import { useTextInput } from "../../../lib/form";
import { TextInput } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import { TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { useTextInput } from "../../../../lib/form";
import { useInstanceKeysExpireMutation } from "../../../../lib/query/admin";
export default function ExpireRemote({}) {
const domainField = useTextInput("domain");
@ -54,7 +51,7 @@ export default function ExpireRemote({}) {
placeholder="example.org"
/>
<MutationButton
disabled={false}
disabled={!domainField.value}
label="Expire keys"
result={expireResult}
/>

View file

@ -19,12 +19,10 @@
import React from "react";
import { useMediaCleanupMutation } from "../../../lib/query";
import { useTextInput } from "../../../lib/form";
import { TextInput } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import { useTextInput } from "../../../../lib/form";
import { TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { useMediaCleanupMutation } from "../../../../lib/query/admin";
export default function Cleanup({}) {
const daysField = useTextInput("days", { defaultValue: "30" });
@ -52,7 +50,7 @@ export default function Cleanup({}) {
placeholder="30"
/>
<MutationButton
disabled={false}
disabled={!daysField.value}
label="Remove old media"
result={mediaCleanupResult}
/>

View file

@ -0,0 +1,134 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useMemo, useEffect, PropsWithChildren, ReactElement } from "react";
import { matchSorter } from "match-sorter";
import ComboBox from "../../../components/combo-box";
import { useListEmojiQuery } from "../../../lib/query/admin/custom-emoji";
import { CustomEmoji } from "../../../lib/types/custom-emoji";
import { ComboboxFormInputHook } from "../../../lib/form/types";
import Loading from "../../../components/loading";
import { Error } from "../../../components/error";
/**
* Sort all emoji into a map keyed by
* the category names (or "Unsorted").
*/
export function useEmojiByCategory(emojis: CustomEmoji[]) {
return useMemo(() => {
const byCategory = new Map<string, CustomEmoji[]>();
emojis.forEach((emoji) => {
const key = emoji.category ?? "Unsorted";
const value = byCategory.get(key) ?? [];
value.push(emoji);
byCategory.set(key, value);
});
return byCategory;
}, [emojis]);
}
interface CategorySelectProps {
field: ComboboxFormInputHook;
}
/**
*
* Renders a cute lil searchable "category select" dropdown.
*/
export function CategorySelect({ field, children }: PropsWithChildren<CategorySelectProps>) {
// Get all local emojis.
const {
data: emoji = [],
isLoading,
isSuccess,
isError,
error,
} = useListEmojiQuery({ filter: "domain:local" });
const emojiByCategory = useEmojiByCategory(emoji);
const categories = useMemo(() => new Set(emojiByCategory.keys()), [emojiByCategory]);
const { value, setIsNew } = field;
// Data used by the ComboBox element
// to select an emoji category.
const categoryItems = useMemo(() => {
const categoriesArr = Array.from(categories);
// Sorted by complex algorithm.
const categoryNames = matchSorter(
categoriesArr,
value ?? "",
{ threshold: matchSorter.rankings.NO_MATCH },
);
// Map each category to the static image
// of the first emoji it contains.
const categoryItems: [string, ReactElement][] = [];
categoryNames.forEach((categoryName) => {
let src: string | undefined;
const items = emojiByCategory.get(categoryName);
if (items && items.length > 0) {
src = items[0].static_url;
}
categoryItems.push([
categoryName,
<>
<img
src={src}
aria-hidden="true"
/>
{categoryName}
</>
]);
});
return categoryItems;
}, [emojiByCategory, categories, value]);
// New category if something has been entered
// and we don't have it in categories yet.
useEffect(() => {
if (value !== undefined) {
const trimmed = value.trim();
if (trimmed.length > 0) {
setIsNew(!categories.has(trimmed));
}
}
}, [categories, value, isSuccess, setIsNew]);
if (isLoading) {
return <Loading />;
} else if (isError) {
return <Error error={error} />;
} else {
return (
<ComboBox
field={field}
items={categoryItems}
label="Category"
placeholder="e.g., reactions"
>
{children}
</ComboBox>
);
}
}

View file

@ -18,36 +18,29 @@
*/
import React, { useEffect } from "react";
import { useRoute, Link, Redirect } from "wouter";
import { useComboBoxInput, useFileInput, useValue } from "../../../lib/form";
import { Redirect, useParams } from "wouter";
import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form";
import useFormSubmit from "../../../../lib/form/submit";
import { useBaseUrl } from "../../../../lib/navigation/util";
import FakeToot from "../../../../components/fake-toot";
import FormWithData from "../../../../lib/form/form-with-data";
import Loading from "../../../../components/loading";
import { FileInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { Error } from "../../../../components/error";
import { useGetEmojiQuery, useEditEmojiMutation, useDeleteEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
import { CategorySelect } from "../category-select";
import BackButton from "../../../../components/back-button";
import useFormSubmit from "../../../lib/form/submit";
import { useBaseUrl } from "../../../lib/navigation/util";
import FakeToot from "../../../components/fake-toot";
import FormWithData from "../../../lib/form/form-with-data";
import Loading from "../../../components/loading";
import { FileInput } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import { Error } from "../../../components/error";
import { useGetEmojiQuery, useEditEmojiMutation, useDeleteEmojiMutation } from "../../../lib/query/admin/custom-emoji";
export default function EmojiDetailRoute({ }) {
export default function EmojiDetail() {
const baseUrl = useBaseUrl();
let [_match, params] = useRoute(`${baseUrl}/:emojiId`);
if (params?.emojiId == undefined) {
return <Redirect to={baseUrl} />;
} else {
return (
<div className="emoji-detail">
<Link to={baseUrl}><a>&lt; go back</a></Link>
<FormWithData dataQuery={useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
</div>
);
}
const params = useParams();
return (
<div className="emoji-detail">
<BackButton to={`~${baseUrl}/local`} />
<FormWithData dataQuery={useGetEmojiQuery} queryArg={params.emojiId} DataForm={EmojiDetailForm} />
</div>
);
}
function EmojiDetailForm({ data: emoji }) {
@ -77,7 +70,7 @@ function EmojiDetailForm({ data: emoji }) {
const [deleteEmoji, deleteResult] = useDeleteEmojiMutation();
if (deleteResult.isSuccess) {
return <Redirect to={baseUrl} />;
return <Redirect to={`~${baseUrl}/local`} />;
}
return (
@ -93,6 +86,7 @@ function EmojiDetailForm({ data: emoji }) {
className="danger"
showError={false}
result={deleteResult}
disabled={false}
/>
</div>
</div>
@ -110,6 +104,7 @@ function EmojiDetailForm({ data: emoji }) {
result={result}
showError={false}
style={{ visibility: (form.category.isNew ? "initial" : "hidden") }}
disabled={!form.category.value}
/>
</CategorySelect>
</div>
@ -126,12 +121,13 @@ function EmojiDetailForm({ data: emoji }) {
label="Replace image"
showError={false}
result={result}
disabled={!form.image.value}
/>
<FakeToot>
Look at this new custom emoji <img
className="emoji"
src={form.image.previewURL ?? emoji.url}
src={form.image.previewValue ?? emoji.url}
title={`:${emoji.shortcode}:`}
alt={emoji.shortcode}
/> isn&apos;t it cool?

View file

@ -18,19 +18,15 @@
*/
import React, { useMemo, useEffect } from "react";
import { useFileInput, useComboBoxInput } from "../../../lib/form";
import { useFileInput, useComboBoxInput } from "../../../../lib/form";
import useShortcode from "./use-shortcode";
import useFormSubmit from "../../../lib/form/submit";
import { TextInput, FileInput } from "../../../components/form/inputs";
import useFormSubmit from "../../../../lib/form/submit";
import { TextInput, FileInput } from "../../../../components/form/inputs";
import { CategorySelect } from '../category-select';
import FakeToot from "../../../components/fake-toot";
import MutationButton from "../../../components/form/mutation-button";
import { useAddEmojiMutation } from "../../../lib/query/admin/custom-emoji";
import { useInstanceV1Query } from "../../../lib/query";
import FakeToot from "../../../../components/fake-toot";
import MutationButton from "../../../../components/form/mutation-button";
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
import { useInstanceV1Query } from "../../../../lib/query/gts-api";
export default function NewEmojiForm() {
const shortcode = useShortcode();
@ -113,4 +109,4 @@ export default function NewEmojiForm() {
</form>
</div>
);
}
}

View file

@ -0,0 +1,173 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useMemo, useState } from "react";
import { Link } from "wouter";
import { matchSorter } from "match-sorter";
import NewEmojiForm from "./new-emoji";
import { useTextInput } from "../../../../lib/form";
import { useEmojiByCategory } from "../category-select";
import Loading from "../../../../components/loading";
import { Error } from "../../../../components/error";
import { TextInput } from "../../../../components/form/inputs";
import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
import { CustomEmoji } from "../../../../lib/types/custom-emoji";
export default function EmojiOverview() {
const { data: emoji = [], isLoading, isError, error } = useListEmojiQuery({ filter: "domain:local" });
let content: React.JSX.Element;
if (isLoading) {
content = <Loading />;
} else if (isError) {
content = <Error error={error} />;
} else {
content = (
<>
<EmojiList emoji={emoji} />
<NewEmojiForm />
</>
);
}
return (
<>
<h1>Local Custom Emoji</h1>
<p>
To use custom emoji in your toots they have to be 'local' to the instance.
You can either upload them here directly, or copy from those already
present on other (known) instances through the <Link to={`/remote`}>Remote Emoji</Link> page.
</p>
<p>
<strong>Be warned!</strong> If you upload more than about 300-400 custom emojis in
total on your instance, this may lead to rate-limiting issues for users and clients
if they try to load all the emoji images at once (which is what many clients do).
</p>
{content}
</>
);
}
interface EmojiListParams {
emoji: CustomEmoji[];
}
function EmojiList({ emoji }: EmojiListParams) {
const filterField = useTextInput("filter");
const filter = filterField.value ?? "";
const emojiByCategory = useEmojiByCategory(emoji);
// Filter emoji based on shortcode match
// with user input, hiding empty categories.
const { filteredEmojis, filteredCount } = useMemo(() => {
// Amount of emojis removed by the filter.
// Start with the length of the array since
// that's the max that can be filtered out.
let filteredCount = emoji.length;
// Results of the filtering.
const filteredEmojis: [string, CustomEmoji[]][] = [];
// Filter from emojis in this category.
emojiByCategory.forEach((entries, category) => {
const filteredEntries = matchSorter(entries, filter, {
keys: ["shortcode"]
});
if (filteredEntries.length == 0) {
// Nothing left in this category, don't
// bother adding it to filteredEmojis.
return;
}
filteredCount -= filteredEntries.length;
filteredEmojis.push([category, filteredEntries]);
});
return { filteredEmojis, filteredCount };
}, [filter, emojiByCategory, emoji.length]);
return (
<>
<h2>Overview</h2>
{emoji.length > 0
? <span>{emoji.length} custom emoji {filteredCount > 0 && `(${filteredCount} filtered)`}</span>
: <span>No custom emoji yet, you can add one below.</span>
}
<div className="list emoji-list">
<div className="header">
<TextInput
field={filterField}
name="emoji-shortcode"
placeholder="Search"
/>
</div>
<div className="entries scrolling">
{filteredEmojis.length > 0
? (
<div className="entries scrolling">
{filteredEmojis.map(([category, emojis]) => {
return <EmojiCategory key={category} category={category} emojis={emojis} />;
})}
</div>
)
: <div className="entry">No local emoji matched your filter.</div>
}
</div>
</div>
</>
);
}
interface EmojiCategoryProps {
category: string;
emojis: CustomEmoji[];
}
function EmojiCategory({ category, emojis }: EmojiCategoryProps) {
return (
<div className="entry">
<b>{category}</b>
<div className="emoji-group">
{emojis.map((emoji) => {
return (
<Link key={emoji.id} to={`/local/${emoji.id}`} >
<EmojiPreview emoji={emoji} />
</Link>
);
})}
</div>
</div>
);
}
function EmojiPreview({ emoji }) {
const [ animate, setAnimate ] = useState(false);
return (
<img
onMouseEnter={() => { setAnimate(true); }}
onMouseLeave={() => { setAnimate(false); }}
src={animate ? emoji.url : emoji.static_url}
alt={emoji.shortcode}
title={emoji.shortcode}
loading="lazy"
/>
);
}

View file

@ -17,19 +17,19 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
import { useMemo } from "react";
const { useTextInput } = require("../../../lib/form");
const { useListEmojiQuery } = require("../../../lib/query/admin/custom-emoji");
import { useTextInput } from "../../../../lib/form";
import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
const shortcodeRegex = /^\w{2,30}$/;
module.exports = function useShortcode() {
export default function useShortcode() {
const { data: emoji = [] } = useListEmojiQuery({
filter: "domain:local"
});
const emojiCodes = React.useMemo(() => {
const emojiCodes = useMemo(() => {
return new Set(emoji.map((e) => e.shortcode));
}, [emoji]);
@ -53,4 +53,4 @@ module.exports = function useShortcode() {
return "";
}
});
};
}

View file

@ -19,36 +19,28 @@
import React, { useMemo } from "react";
import ParseFromToot from "./parse-from-toot";
import StealThisLook from "./steal-this-look";
import Loading from "../../../components/loading";
import { Error } from "../../../components/error";
import { useListEmojiQuery } from "../../../lib/query/admin/custom-emoji";
import Loading from "../../../../components/loading";
import { Error } from "../../../../components/error";
import { useListEmojiQuery } from "../../../../lib/query/admin/custom-emoji";
export default function RemoteEmoji() {
// local emoji are queried for shortcode collision detection
// Local emoji are queried for
// shortcode collision detection
const {
data: emoji = [],
isLoading,
error
} = useListEmojiQuery({ filter: "domain:local" });
const emojiCodes = useMemo(() => {
return new Set(emoji.map((e) => e.shortcode));
}, [emoji]);
const emojiCodes = useMemo(() => new Set(emoji.map((e) => e.shortcode)), [emoji]);
return (
<>
<h1>Custom Emoji (remote)</h1>
{error &&
<Error error={error} />
}
{isLoading
? <Loading />
: <>
<ParseFromToot emojiCodes={emojiCodes} />
</>
}
{error && <Error error={error} />}
{isLoading ? <Loading /> : <StealThisLook emojiCodes={emojiCodes} />}
</>
);
}

View file

@ -19,19 +19,19 @@
import React, { useCallback, useEffect } from "react";
import { useTextInput, useComboBoxInput, useCheckListInput } from "../../../lib/form";
import { useTextInput, useComboBoxInput, useCheckListInput } from "../../../../lib/form";
import useFormSubmit from "../../../lib/form/submit";
import useFormSubmit from "../../../../lib/form/submit";
import CheckList from "../../../components/check-list";
import CheckList from "../../../../components/check-list";
import { CategorySelect } from '../category-select';
import { TextInput } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import { Error } from "../../../components/error";
import { useSearchItemForEmojiMutation, usePatchRemoteEmojisMutation } from "../../../lib/query/admin/custom-emoji";
import { TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
import { Error } from "../../../../components/error";
import { useSearchItemForEmojiMutation, usePatchRemoteEmojisMutation } from "../../../../lib/query/admin/custom-emoji";
export default function ParseFromToot({ emojiCodes }) {
export default function StealThisLook({ emojiCodes }) {
const [searchStatus, result] = useSearchItemForEmojiMutation();
const urlField = useTextInput("url");
@ -48,7 +48,7 @@ export default function ParseFromToot({ emojiCodes }) {
<form onSubmit={submitSearch}>
<div className="form-field text">
<label htmlFor="url">
Link to a toot:
Link to a status:
</label>
<div className="row">
<input
@ -85,13 +85,13 @@ function SearchResult({ result, localEmojiCodes }) {
if (error == "NONE_FOUND") {
return "No results found";
} else if (error == "LOCAL_INSTANCE") {
return <b>This is a local user/toot, all referenced emoji are already on your instance</b>;
return <b>This is a local user/status, all referenced emoji are already on your instance</b>;
} else if (error != undefined) {
return <Error error={result.error} />;
}
if (data.list.length == 0) {
return <b>This {data.type == "statuses" ? "toot" : "account"} doesn't use any custom emoji</b>;
return <b>This {data.type == "statuses" ? "status" : "account"} doesn't use any custom emoji</b>;
}
return (
@ -143,7 +143,7 @@ function CopyEmojiForm({ localEmojiCodes, type, emojiList }) {
return (
<div className="parsed">
<span>This {type == "statuses" ? "toot" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
<span>This {type == "statuses" ? "status" : "account"} uses the following custom emoji, select the ones you want to copy/disable:</span>
<form onSubmit={formSubmit}>
<CheckList
field={form.selectedEmoji}
@ -232,4 +232,4 @@ function EmojiEntry({ entry: emoji, onChange, extraProps: { localEmojiCodes } })
/>
</>
);
}
}

View file

@ -0,0 +1,103 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { Redirect, useParams } from "wouter";
import { useBaseUrl } from "../../../lib/navigation/util";
import { useValue, useTextInput } from "../../../lib/form";
import useFormSubmit from "../../../lib/form/submit";
import { TextArea } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import BackButton from "../../../components/back-button";
import Loading from "../../../components/loading";
import { useDeleteInstanceRuleMutation, useInstanceRulesQuery, useUpdateInstanceRuleMutation } from "../../../lib/query/admin";
import { Error } from "../../../components/error";
export default function InstanceRuleDetail() {
const baseUrl = useBaseUrl();
const params: { ruleId: string } = useParams();
const { data: rules, isLoading, isError, error } = useInstanceRulesQuery();
if (isLoading) {
return <Loading />;
} else if (isError) {
return <Error error={error} />;
}
if (rules === undefined) {
throw "undefined rules";
}
return (
<>
<BackButton to={`~${baseUrl}/rules`} />
<EditInstanceRuleForm rule={rules[params.ruleId]} />
</>
);
}
function EditInstanceRuleForm({ rule }) {
const baseUrl = useBaseUrl();
const form = {
id: useValue("id", rule.id),
rule: useTextInput("text", { defaultValue: rule.text })
};
const [submitForm, result] = useFormSubmit(form, useUpdateInstanceRuleMutation());
const [deleteRule, deleteResult] = useDeleteInstanceRuleMutation({ fixedCacheKey: rule.id });
if (result.isSuccess || deleteResult.isSuccess) {
return (
<Redirect to={`~${baseUrl}/rules`} />
);
}
return (
<div className="rule-detail">
<form onSubmit={submitForm}>
<TextArea
field={form.rule}
/>
<div className="action-buttons row">
<MutationButton
label="Save"
showError={false}
result={result}
disabled={!form.rule.hasChanged()}
/>
<MutationButton
disabled={false}
type="button"
onClick={() => deleteRule(rule.id)}
label="Delete"
className="button danger"
showError={false}
result={deleteResult}
/>
</div>
{result.error && <Error error={result.error} />}
{deleteResult.error && <Error error={deleteResult.error} />}
</form>
</div>
);
}

View file

@ -0,0 +1,75 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { Link } from "wouter";
import { useInstanceRulesQuery, useAddInstanceRuleMutation } from "../../../lib/query/admin";
import { useBaseUrl } from "../../../lib/navigation/util";
import { useTextInput } from "../../../lib/form";
import useFormSubmit from "../../../lib/form/submit";
import { TextArea } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import { InstanceRule, MappedRules } from "../../../lib/types/rules";
import FormWithData from "../../../lib/form/form-with-data";
export default function InstanceRules() {
return (
<>
<h1>Instance Rules</h1>
<FormWithData
dataQuery={useInstanceRulesQuery}
DataForm={InstanceRulesForm}
/>
</>
);
}
function InstanceRulesForm({ data: rules }: { data: MappedRules }) {
const baseUrl = useBaseUrl();
const newRule = useTextInput("text");
const [submitForm, result] = useFormSubmit({ newRule }, useAddInstanceRuleMutation(), {
changedOnly: true,
onFinish: () => newRule.reset()
});
return (
<form onSubmit={submitForm} className="new-rule">
<ol className="instance-rules">
{Object.values(rules).map((rule: InstanceRule) => (
<Link key={"link-"+rule.id} className="rule" to={`~${baseUrl}/rules/${rule.id}`}>
<li key={rule.id}>
<h2>{rule.text} <i className="fa fa-pencil edit-icon" /></h2>
</li>
<span>{new Date(rule.created_at).toLocaleString()}</span>
</Link>
))}
</ol>
<TextArea
field={newRule}
label="New instance rule"
/>
<MutationButton
disabled={newRule.value === undefined || newRule.value.length === 0}
label="Add rule"
result={result}
/>
</form>
);
}

View file

@ -19,33 +19,29 @@
import React from "react";
import { useTextInput, useFileInput } from "../../lib/form";
import { useTextInput, useFileInput } from "../../../lib/form";
import { TextInput, TextArea, FileInput } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import { useInstanceV1Query } from "../../../lib/query/gts-api";
import { useUpdateInstanceMutation } from "../../../lib/query/admin";
import { InstanceV1 } from "../../../lib/types/instance";
import FormWithData from "../../../lib/form/form-with-data";
import useFormSubmit from "../../../lib/form/submit";
const useFormSubmit = require("../../lib/form/submit").default;
import { TextInput, TextArea, FileInput } from "../../components/form/inputs";
const FormWithData = require("../../lib/form/form-with-data").default;
import MutationButton from "../../components/form/mutation-button";
import { useInstanceV1Query } from "../../lib/query";
import { useUpdateInstanceMutation } from "../../lib/query/admin";
import { InstanceV1 } from "../../lib/types/instance";
export default function AdminSettings() {
export default function InstanceSettings() {
return (
<FormWithData
dataQuery={useInstanceV1Query}
DataForm={AdminSettingsForm}
DataForm={InstanceSettingsForm}
/>
);
}
interface AdminSettingsFormProps{
interface InstanceSettingsFormProps{
data: InstanceV1;
}
function AdminSettingsForm({ data: instance }: AdminSettingsFormProps) {
function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
const titleLimit = 40;
const shortDescLimit = 500;
const descLimit = 5000;

View file

@ -0,0 +1,129 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { MenuItem } from "../../lib/navigation/menu";
import React from "react";
import { useHasPermission } from "../../lib/navigation/util";
/*
EXPORTED COMPONENTS
*/
/**
* - /settings/admin/instance/settings
* - /settings/admin/instance/rules
* - /settings/admin/instance/rules/:ruleId
* - /settings/admin/emojis
* - /settings/admin/emojis/local
* - /settings/admin/emojis/local/:emojiId
* - /settings/admin/emojis/remote
* - /settings/admin/actions
* - /settings/admin/actions/media
* - /settings/admin/actions/keys
*/
export default function AdminMenu() {
const permissions = ["admin"];
const admin = useHasPermission(permissions);
if (!admin) {
return null;
}
return (
<MenuItem
name="Administration"
itemUrl="admin"
defaultChild="actions"
permissions={permissions}
>
<AdminInstanceMenu />
<AdminEmojisMenu />
<AdminActionsMenu />
</MenuItem>
);
}
/*
INTERNAL COMPONENTS
*/
function AdminInstanceMenu() {
return (
<MenuItem
name="Instance"
itemUrl="instance"
defaultChild="settings"
icon="fa-sitemap"
>
<MenuItem
name="Settings"
itemUrl="settings"
icon="fa-sliders"
/>
<MenuItem
name="Rules"
itemUrl="rules"
icon="fa-dot-circle-o"
/>
</MenuItem>
);
}
function AdminActionsMenu() {
return (
<MenuItem
name="Actions"
itemUrl="actions"
defaultChild="media"
icon="fa-bolt"
>
<MenuItem
name="Media"
itemUrl="media"
icon="fa-photo"
/>
<MenuItem
name="Keys"
itemUrl="keys"
icon="fa-key-modern"
/>
</MenuItem>
);
}
function AdminEmojisMenu() {
return (
<MenuItem
name="Custom Emoji"
itemUrl="emojis"
defaultChild="local"
icon="fa-smile-o"
>
<MenuItem
name="Local"
itemUrl="local"
icon="fa-home"
/>
<MenuItem
name="Remote"
itemUrl="remote"
icon="fa-cloud"
/>
</MenuItem>
);
}

View file

@ -0,0 +1,151 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { BaseUrlContext, useBaseUrl, useHasPermission } from "../../lib/navigation/util";
import { Redirect, Route, Router, Switch } from "wouter";
import { ErrorBoundary } from "../../lib/navigation/error";
import InstanceSettings from "./instance/settings";
import InstanceRules from "./instance/rules";
import InstanceRuleDetail from "./instance/ruledetail";
import Media from "./actions/media";
import Keys from "./actions/keys";
import EmojiOverview from "./emoji/local/overview";
import EmojiDetail from "./emoji/local/detail";
import RemoteEmoji from "./emoji/remote";
/*
EXPORTED COMPONENTS
*/
/**
* - /settings/instance/settings
* - /settings/instance/rules
* - /settings/instance/rules/:ruleId
* - /settings/admin/emojis
* - /settings/admin/emojis/local
* - /settings/admin/emojis/local/:emojiId
* - /settings/admin/emojis/remote
* - /settings/admin/actions
* - /settings/admin/actions/media
* - /settings/admin/actions/keys
*/
export default function AdminRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/admin";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<AdminInstanceRouter />
<AdminEmojisRouter />
<AdminActionsRouter />
</Router>
</BaseUrlContext.Provider>
);
}
/*
INTERNAL COMPONENTS
*/
/**
* - /settings/admin/emojis
* - /settings/admin/emojis/local
* - /settings/admin/emojis/local/:emojiId
* - /settings/admin/emojis/remote
*/
function AdminEmojisRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/emojis";
const absBase = parentUrl + thisBase;
const permissions = ["admin"];
const admin = useHasPermission(permissions);
if (!admin) {
return null;
}
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/local" component={EmojiOverview} />
<Route path="/local/:emojiId" component={EmojiDetail} />
<Route path="/remote" component={RemoteEmoji} />
<Route><Redirect to="/local" /></Route>
</Switch>
</ErrorBoundary>
</Router>
</BaseUrlContext.Provider>
);
}
/**
* - /settings/admin/actions
* - /settings/admin/actions/media
* - /settings/admin/actions/keys
*/
function AdminActionsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/actions";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/media" component={Media} />
<Route path="/keys" component={Keys} />
<Route><Redirect to="/media" /></Route>
</Switch>
</ErrorBoundary>
</Router>
</BaseUrlContext.Provider>
);
}
/**
* - /settings/instance/settings
* - /settings/instance/rules
* - /settings/instance/rules/:ruleId
*/
function AdminInstanceRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/instance";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/settings" component={InstanceSettings}/>
<Route path="/rules" component={InstanceRules} />
<Route path="/rules/:ruleId" component={InstanceRuleDetail} />
<Route><Redirect to="/settings" /></Route>
</Switch>
</ErrorBoundary>
</Router>
</BaseUrlContext.Provider>
);
}

View file

@ -19,19 +19,16 @@
import React from "react";
import { useActionAccountMutation } from "../../../lib/query";
import MutationButton from "../../../components/form/mutation-button";
import useFormSubmit from "../../../lib/form/submit";
import { useActionAccountMutation } from "../../../../lib/query/admin";
import MutationButton from "../../../../components/form/mutation-button";
import useFormSubmit from "../../../../lib/form/submit";
import {
useValue,
useTextInput,
useBoolInput,
} from "../../../lib/form";
import { Checkbox, TextInput } from "../../../components/form/inputs";
import { AdminAccount } from "../../../lib/types/account";
} from "../../../../lib/form";
import { Checkbox, TextInput } from "../../../../components/form/inputs";
import { AdminAccount } from "../../../../lib/types/account";
export interface AccountActionsProps {
account: AdminAccount,

View file

@ -19,27 +19,23 @@
import React from "react";
import { useLocation } from "wouter";
import { useHandleSignupMutation } from "../../../lib/query";
import MutationButton from "../../../components/form/mutation-button";
import useFormSubmit from "../../../lib/form/submit";
import { useHandleSignupMutation } from "../../../../lib/query/admin";
import MutationButton from "../../../../components/form/mutation-button";
import useFormSubmit from "../../../../lib/form/submit";
import {
useValue,
useTextInput,
useBoolInput,
} from "../../../lib/form";
import { Checkbox, Select, TextInput } from "../../../components/form/inputs";
import { AdminAccount } from "../../../lib/types/account";
} from "../../../../lib/form";
import { Checkbox, Select, TextInput } from "../../../../components/form/inputs";
import { AdminAccount } from "../../../../lib/types/account";
export interface HandleSignupProps {
account: AdminAccount,
accountsBaseUrl: string,
backLocation: string,
}
export function HandleSignup({account, accountsBaseUrl}: HandleSignupProps) {
export function HandleSignup({account, backLocation}: HandleSignupProps) {
const form = {
id: useValue("id", account.id),
approveOrReject: useTextInput("approve_or_reject", { defaultValue: "approve" }),
@ -67,7 +63,7 @@ export function HandleSignup({account, accountsBaseUrl}: HandleSignupProps) {
if (res.data) {
// "reject" successful,
// redirect to accounts page.
setLocation(accountsBaseUrl);
setLocation(backLocation);
}
}
});

View file

@ -18,51 +18,36 @@
*/
import React from "react";
import { useRoute, Redirect } from "wouter";
import { useGetAccountQuery } from "../../../lib/query";
import FormWithData from "../../../lib/form/form-with-data";
import { useBaseUrl } from "../../../lib/navigation/util";
import FakeProfile from "../../../components/fake-profile";
import { AdminAccount } from "../../../lib/types/account";
import { useGetAccountQuery } from "../../../../lib/query/admin";
import FormWithData from "../../../../lib/form/form-with-data";
import FakeProfile from "../../../../components/fake-profile";
import { AdminAccount } from "../../../../lib/types/account";
import { HandleSignup } from "./handlesignup";
import { AccountActions } from "./actions";
import BackButton from "../../../components/back-button";
import { useParams } from "wouter";
export default function AccountDetail() {
// /settings/admin/accounts
const accountsBaseUrl = useBaseUrl();
let [_match, params] = useRoute(`${accountsBaseUrl}/:accountId`);
if (params?.accountId == undefined) {
return <Redirect to={accountsBaseUrl} />;
} else {
return (
<div className="account-detail">
<h1 className="text-cutoff">
<BackButton to={accountsBaseUrl} /> Account Details
</h1>
<FormWithData
dataQuery={useGetAccountQuery}
queryArg={params.accountId}
DataForm={AccountDetailForm}
{...{accountsBaseUrl}}
/>
</div>
);
}
const params: { accountID: string } = useParams();
return (
<div className="account-detail">
<h1>Account Details</h1>
<FormWithData
dataQuery={useGetAccountQuery}
queryArg={params.accountID}
DataForm={AccountDetailForm}
/>
</div>
);
}
interface AccountDetailFormProps {
accountsBaseUrl: string,
backLocation: string,
data: AdminAccount,
}
function AccountDetailForm({ data: adminAcct, accountsBaseUrl }: AccountDetailFormProps) {
function AccountDetailForm({ data: adminAcct, backLocation }: AccountDetailFormProps) {
let yesOrNo = (b: boolean) => {
return b ? "yes" : "no";
};
@ -169,7 +154,7 @@ function AccountDetailForm({ data: adminAcct, accountsBaseUrl }: AccountDetailFo
?
<HandleSignup
account={adminAcct}
accountsBaseUrl={accountsBaseUrl}
backLocation={backLocation}
/>
:
<AccountActions account={adminAcct} />

View file

@ -18,23 +18,9 @@
*/
import React from "react";
import { Switch, Route } from "wouter";
import AccountDetail from "./detail";
import { AccountSearchForm } from "./search";
export default function Accounts({ baseUrl }) {
return (
<Switch>
<Route path={`${baseUrl}/:accountId`}>
<AccountDetail />
</Route>
<AccountOverview />
</Switch>
);
}
function AccountOverview({ }) {
export default function AccountsOverview({ }) {
return (
<div className="accounts-view">
<h1>Accounts Overview</h1>

View file

@ -18,8 +18,8 @@
*/
import React from "react";
import { useSearchAccountsQuery } from "../../../lib/query";
import { AccountList } from "../../../components/account-list";
import { useSearchAccountsQuery } from "../../../../lib/query/admin";
import { AccountList } from "../../../../components/account-list";
export default function AccountsPending() {
const searchRes = useSearchAccountsQuery({status: "pending"});

View file

@ -19,17 +19,14 @@
import React from "react";
import { useLazySearchAccountsQuery } from "../../../lib/query";
import { useTextInput } from "../../../lib/form";
import { AccountList } from "../../../components/account-list";
import { SearchAccountParams } from "../../../lib/types/account";
import { Select, TextInput } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import { useLazySearchAccountsQuery } from "../../../../lib/query/admin";
import { useTextInput } from "../../../../lib/form";
import { AccountList } from "../../../../components/account-list";
import { SearchAccountParams } from "../../../../lib/types/account";
import { Select, TextInput } from "../../../../components/form/inputs";
import MutationButton from "../../../../components/form/mutation-button";
export function AccountSearchForm() {
const [searchAcct, searchRes] = useLazySearchAccountsQuery();
const form = {
origin: useTextInput("origin"),
status: useTextInput("status"),
@ -55,14 +52,20 @@ export function AccountSearchForm() {
// Remove any nulls.
return kv || [];
});
const params: SearchAccountParams = Object.fromEntries(entries);
searchAcct(params);
}
const [ searchAcct, searchRes ] = useLazySearchAccountsQuery();
return (
<>
<form onSubmit={submitSearch}>
<form
onSubmit={submitSearch}
// Prevent password managers trying
// to fill in username/email fields.
autoComplete="off"
>
<TextInput
field={form.username}
label={"(Optional) username (without leading '@' symbol)"}
@ -88,6 +91,8 @@ export function AccountSearchForm() {
field={form.email}
label={"(Optional) email address (local accounts only)"}
placeholder={"someone@example.org"}
// Get email validation for free.
{...{type: "email"}}
/>
<TextInput
field={form.ip}

View file

@ -20,31 +20,35 @@
import React from "react";
import { useMemo } from "react";
import { useLocation } from "wouter";
import { useLocation, useParams, useSearch } from "wouter";
import { useTextInput, useBoolInput } from "../../lib/form";
import { useTextInput, useBoolInput } from "../../../lib/form";
import useFormSubmit from "../../lib/form/submit";
import useFormSubmit from "../../../lib/form/submit";
import { TextInput, Checkbox, TextArea } from "../../components/form/inputs";
import { TextInput, Checkbox, TextArea } from "../../../components/form/inputs";
import Loading from "../../components/loading";
import BackButton from "../../components/back-button";
import MutationButton from "../../components/form/mutation-button";
import Loading from "../../../components/loading";
import BackButton from "../../../components/back-button";
import MutationButton from "../../../components/form/mutation-button";
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../lib/query/admin/domain-permissions/get";
import { useAddDomainAllowMutation, useAddDomainBlockMutation, useRemoveDomainAllowMutation, useRemoveDomainBlockMutation } from "../../lib/query/admin/domain-permissions/update";
import { DomainPerm, PermType } from "../../lib/types/domain-permission";
import { NoArg } from "../../lib/types/query";
import { Error } from "../../components/error";
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
import { useAddDomainAllowMutation, useAddDomainBlockMutation, useRemoveDomainAllowMutation, useRemoveDomainBlockMutation } from "../../../lib/query/admin/domain-permissions/update";
import { DomainPerm, PermType } from "../../../lib/types/domain-permission";
import { NoArg } from "../../../lib/types/query";
import { Error } from "../../../components/error";
import { useBaseUrl } from "../../../lib/navigation/util";
export interface DomainPermDetailProps {
baseUrl: string;
permType: PermType;
domain: string;
}
export default function DomainPermDetail({ baseUrl, permType, domain }: DomainPermDetailProps) {
export default function DomainPermDetail() {
const baseUrl = useBaseUrl();
// Parse perm type from routing params.
let params = useParams();
if (params.permType !== "blocks" && params.permType !== "allows") {
throw "unrecognized perm type " + params.permType;
}
const permType = params.permType.slice(0, -1) as PermType;
const { data: domainBlocks = {}, isLoading: isLoadingDomainBlocks } = useDomainBlocksQuery(NoArg, { skip: permType !== "block" });
const { data: domainAllows = {}, isLoading: isLoadingDomainAllows } = useDomainAllowsQuery(NoArg, { skip: permType !== "allow" });
@ -60,13 +64,19 @@ export default function DomainPermDetail({ baseUrl, permType, domain }: DomainPe
throw "perm type unknown";
}
if (domain == "view") {
// Retrieve domain from form field submission.
domain = (new URL(document.location.toString())).searchParams.get("domain")?? "unknown";
}
// Parse domain from routing params.
let domain = params.domain ?? "unknown";
if (domain == "unknown") {
throw "unknown domain";
const search = useSearch();
if (domain === "view") {
// Retrieve domain from form field submission.
const searchParams = new URLSearchParams(search);
const searchDomain = searchParams.get("domain");
if (!searchDomain) {
throw "empty view domain";
}
domain = searchDomain;
}
// Normalize / decode domain (it may be URL-encoded).
@ -98,13 +108,12 @@ export default function DomainPermDetail({ baseUrl, permType, domain }: DomainPe
return (
<div>
<h1 className="text-cutoff"><BackButton to={baseUrl} /> Domain {permType} for: <span title={domain}>{domain}</span></h1>
<h1 className="text-cutoff"><BackButton to={`~${baseUrl}/${permType}s`}/> Domain {permType} for: <span title={domain}>{domain}</span></h1>
{infoContent}
<DomainPermForm
defaultDomain={domain}
perm={existingPerm}
permType={permType}
baseUrl={baseUrl}
/>
</div>
);
@ -114,10 +123,9 @@ interface DomainPermFormProps {
defaultDomain: string;
perm?: DomainPerm;
permType: PermType;
baseUrl: string;
}
function DomainPermForm({ defaultDomain, perm, permType, baseUrl }: DomainPermFormProps) {
function DomainPermForm({ defaultDomain, perm, permType }: DomainPermFormProps) {
const isExistingPerm = perm !== undefined;
const disabledForm = isExistingPerm
? {
@ -186,7 +194,7 @@ function DomainPermForm({ defaultDomain, perm, permType, baseUrl }: DomainPermFo
// but if domain input changes, that doesn't match anymore
// and causes issues later on so, before submitting the form,
// silently change url, and THEN submit.
let correctUrl = `${baseUrl}/${form.domain.value}`;
let correctUrl = `/${permType}s/${form.domain.value}`;
if (location != correctUrl) {
setLocation(correctUrl);
}

View file

@ -17,11 +17,11 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const React = require("react");
import React from "react";
module.exports = function ExportFormatTable() {
export default function ExportFormatTable() {
return (
<div className="export-format-table-wrapper without-border">
<div className="export-format-table-wrapper">
<table className="export-format-table">
<thead>
<tr>
@ -44,7 +44,7 @@ module.exports = function ExportFormatTable() {
</table>
</div>
);
};
}
function Format({ name, info }) {
return (

View file

@ -20,19 +20,15 @@
import React from "react";
import { useEffect } from "react";
import { useExportDomainListMutation } from "../../lib/query/admin/domain-permissions/export";
import useFormSubmit from "../../lib/form/submit";
import { useExportDomainListMutation } from "../../../lib/query/admin/domain-permissions/export";
import useFormSubmit from "../../../lib/form/submit";
import {
RadioGroup,
TextArea,
Select,
} from "../../components/form/inputs";
import MutationButton from "../../components/form/mutation-button";
import { Error } from "../../components/error";
} from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import { Error } from "../../../components/error";
import ExportFormatTable from "./export-format-table";
import type {
@ -40,7 +36,7 @@ import type {
FormSubmitResult,
RadioFormInputHook,
TextFormInputHook,
} from "../../lib/form/types";
} from "../../../lib/form/types";
export interface ImportExportFormProps {
form: {

View file

@ -20,20 +20,19 @@
import React from "react";
import { Switch, Route, Redirect, useLocation } from "wouter";
import { useProcessDomainPermissionsMutation } from "../../lib/query/admin/domain-permissions/process";
import { useTextInput, useRadioInput } from "../../lib/form";
import useFormSubmit from "../../lib/form/submit";
import { useProcessDomainPermissionsMutation } from "../../../lib/query/admin/domain-permissions/process";
import { useTextInput, useRadioInput } from "../../../lib/form";
import useFormSubmit from "../../../lib/form/submit";
import { ProcessImport } from "./process";
import ImportExportForm from "./form";
export default function ImportExport({ baseUrl }) {
export default function ImportExport() {
const form = {
domains: useTextInput("domains"),
exportType: useTextInput("exportType", { defaultValue: "plain", dontReset: true }),
exportType: useTextInput("exportType", {
defaultValue: "plain",
dontReset: true,
}),
permType: useRadioInput("permType", {
options: {
block: "Domain blocks",
@ -43,12 +42,11 @@ export default function ImportExport({ baseUrl }) {
};
const [submitParse, parseResult] = useFormSubmit(form, useProcessDomainPermissionsMutation(), { changedOnly: false });
const [_location, setLocation] = useLocation();
return (
<Switch>
<Route path={`${baseUrl}/process`}>
<Route path={"/process"}>
{
parseResult.isSuccess
? (
@ -58,7 +56,7 @@ export default function ImportExport({ baseUrl }) {
className="button"
onClick={() => {
parseResult.reset();
setLocation(baseUrl);
setLocation("");
}}
>
&lt; back
@ -71,13 +69,13 @@ export default function ImportExport({ baseUrl }) {
/>
</>
)
: <Redirect to={baseUrl} />
: <Redirect to={""} />
}
</Route>
<Route>
{
parseResult.isSuccess
? <Redirect to={`${baseUrl}/process`} />
? <Redirect to={"/process"} />
: <ImportExportForm
form={form}
submitParse={submitParse}

View file

@ -20,29 +20,22 @@
import React from "react";
import { useMemo } from "react";
import { Link, useLocation } from "wouter";
import { Link, useLocation, useParams } from "wouter";
import { matchSorter } from "match-sorter";
import { useTextInput } from "../../../lib/form";
import { TextInput } from "../../../components/form/inputs";
import Loading from "../../../components/loading";
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
import type { MappedDomainPerms, PermType } from "../../../lib/types/domain-permission";
import { NoArg } from "../../../lib/types/query";
import { useTextInput } from "../../lib/form";
import { TextInput } from "../../components/form/inputs";
import Loading from "../../components/loading";
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../lib/query/admin/domain-permissions/get";
import type { MappedDomainPerms, PermType } from "../../lib/types/domain-permission";
import { NoArg } from "../../lib/types/query";
export interface DomainPermissionsOverviewProps {
// Params injected by
// the wouter router.
permType: PermType;
baseUrl: string,
}
export default function DomainPermissionsOverview({ permType, baseUrl }: DomainPermissionsOverviewProps) {
if (permType !== "block" && permType !== "allow") {
throw "unrecognized perm type " + permType;
export default function DomainPermissionsOverview() {
// Parse perm type from routing params.
let params = useParams();
if (params.permType !== "blocks" && params.permType !== "allows") {
throw "unrecognized perm type " + params.permType;
}
const permType = params.permType.slice(0, -1) as PermType;
// Uppercase first letter of given permType.
const permTypeUpper = useMemo(() => {
@ -69,30 +62,28 @@ export default function DomainPermissionsOverview({ permType, baseUrl }: DomainP
}
return (
<div>
<>
<h1>Domain {permTypeUpper}s</h1>
{ permType == "block" ? <BlockHelperText/> : <AllowHelperText/> }
<DomainPermsList
data={data}
baseUrl={baseUrl}
permType={permType}
permTypeUpper={permTypeUpper}
/>
<Link to="/settings/admin/domain-permissions/import-export">
<a>Or use the bulk import/export interface</a>
Or use the bulk import/export interface
</Link>
</div>
</>
);
}
interface DomainPermsListProps {
data: MappedDomainPerms;
baseUrl: string;
permType: PermType;
permTypeUpper: string;
}
function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPermsListProps) {
function DomainPermsList({ data, permType, permTypeUpper }: DomainPermsListProps) {
// Format perms into a list.
const perms = useMemo(() => {
return Object.values(data);
@ -103,7 +94,7 @@ function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPerms
function filterFormSubmit(e) {
e.preventDefault();
setLocation(`${baseUrl}/${filter}`);
setLocation(`/${filter}`);
}
const filter = filterField.value ?? "";
@ -120,11 +111,13 @@ function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPerms
const entries = filteredPerms.map((entry) => {
return (
<Link key={entry.domain} to={`${baseUrl}/${entry.domain}`}>
<a className="entry nounderline">
<span id="domain">{entry.domain}</span>
<span id="date">{new Date(entry.created_at ?? "").toLocaleString()}</span>
</a>
<Link
className="entry nounderline"
key={entry.domain}
to={`/${permType}s/${entry.domain}`}
>
<span id="domain">{entry.domain}</span>
<span id="date">{new Date(entry.created_at ?? "").toLocaleString()}</span>
</Link>
);
});
@ -137,8 +130,11 @@ function DomainPermsList({ data, baseUrl, permType, permTypeUpper }: DomainPerms
placeholder="example.org"
label={`Search or add domain ${permType}`}
/>
<Link to={`${baseUrl}/${filter}`}>
<a className="button">{permTypeUpper}&nbsp;{filter}</a>
<Link
className="button"
to={`/${permType}s/${filter}`}
>
{permTypeUpper}&nbsp;{filter}
</Link>
</form>
<div>

View file

@ -18,17 +18,15 @@
*/
import React from "react";
import { memo, useMemo, useCallback, useEffect } from "react";
import { isValidDomainPermission, hasBetterScope } from "../../lib/util/domain-permission";
import { isValidDomainPermission, hasBetterScope } from "../../../lib/util/domain-permission";
import {
useTextInput,
useBoolInput,
useRadioInput,
useCheckListInput,
} from "../../lib/form";
} from "../../../lib/form";
import {
Select,
@ -36,22 +34,22 @@ import {
RadioGroup,
Checkbox,
TextInput,
} from "../../components/form/inputs";
} from "../../../components/form/inputs";
import useFormSubmit from "../../lib/form/submit";
import useFormSubmit from "../../../lib/form/submit";
import CheckList from "../../components/check-list";
import MutationButton from "../../components/form/mutation-button";
import FormWithData from "../../lib/form/form-with-data";
import CheckList from "../../../components/check-list";
import MutationButton from "../../../components/form/mutation-button";
import FormWithData from "../../../lib/form/form-with-data";
import { useImportDomainPermsMutation } from "../../lib/query/admin/domain-permissions/import";
import { useImportDomainPermsMutation } from "../../../lib/query/admin/domain-permissions/import";
import {
useDomainAllowsQuery,
useDomainBlocksQuery
} from "../../lib/query/admin/domain-permissions/get";
} from "../../../lib/query/admin/domain-permissions/get";
import type { DomainPerm, MappedDomainPerms } from "../../lib/types/domain-permission";
import type { ChecklistInputHook, RadioFormInputHook } from "../../lib/form/types";
import type { DomainPerm, MappedDomainPerms } from "../../../lib/types/domain-permission";
import type { ChecklistInputHook, RadioFormInputHook } from "../../../lib/form/types";
export interface ProcessImportProps {
list: DomainPerm[],
@ -61,16 +59,14 @@ export interface ProcessImportProps {
export const ProcessImport = memo(
function ProcessImport({ list, permType }: ProcessImportProps) {
return (
<div className="without-border">
<FormWithData
dataQuery={permType.value == "allow"
? useDomainAllowsQuery
: useDomainBlocksQuery
}
DataForm={ImportList}
{...{ list, permType }}
/>
</div>
<FormWithData
dataQuery={permType.value == "allow"
? useDomainAllowsQuery
: useDomainBlocksQuery
}
DataForm={ImportList}
{...{ list, permType }}
/>
);
}
);
@ -399,4 +395,4 @@ function DomainEntryIcon({ alreadyExists, suggestion, permTypeString }: DomainEn
<span className="sr-only">{text}</span>
</>
);
}
}

View file

@ -0,0 +1,121 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { MenuItem } from "../../lib/navigation/menu";
import React from "react";
import { useHasPermission } from "../../lib/navigation/util";
/*
EXPORTED COMPONENTS
*/
/**
* - /settings/moderation/reports/overview
* - /settings/moderation/reports/:reportId
* - /settings/moderation/accounts/overview
* - /settings/moderation/accounts/pending
* - /settings/moderation/accounts/:accountID
* - /settings/moderation/domain-permissions/:permType
* - /settings/moderation/domain-permissions/:permType/:domain
* - /settings/moderation/domain-permissions/import-export
* - /settings/moderation/domain-permissions/process
*/
export default function ModerationMenu() {
const permissions = ["moderator"];
const moderator = useHasPermission(permissions);
if (!moderator) {
return null;
}
return (
<MenuItem
name="Moderation"
itemUrl="moderation"
defaultChild="reports"
permissions={permissions}
>
<ModerationReportsMenu />
<ModerationAccountsMenu />
<ModerationDomainPermsMenu />
</MenuItem>
);
}
/*
INTERNAL COMPONENTS
*/
function ModerationReportsMenu() {
return (
<MenuItem
name="Reports"
itemUrl="reports"
icon="fa-flag"
/>
);
}
function ModerationAccountsMenu() {
return (
<MenuItem
name="Accounts"
itemUrl="accounts"
defaultChild="overview"
icon="fa-users"
>
<MenuItem
name="Overview"
itemUrl="overview"
icon="fa-list"
/>
<MenuItem
name="Pending"
itemUrl="pending"
icon="fa-question"
/>
</MenuItem>
);
}
function ModerationDomainPermsMenu() {
return (
<MenuItem
name="Domain Permissions"
itemUrl="domain-permissions"
defaultChild="blocks"
icon="fa-hubzilla"
>
<MenuItem
name="Blocks"
itemUrl="blocks"
icon="fa-close"
/>
<MenuItem
name="Allows"
itemUrl="allows"
icon="fa-check"
/>
<MenuItem
name="Import/Export"
itemUrl="import-export"
icon="fa-floppy-o"
/>
</MenuItem>
);
}

View file

@ -18,40 +18,31 @@
*/
import React, { useState } from "react";
import { useRoute, Redirect } from "wouter";
import FormWithData from "../../lib/form/form-with-data";
import BackButton from "../../components/back-button";
import { useValue, useTextInput } from "../../lib/form";
import useFormSubmit from "../../lib/form/submit";
import { TextArea } from "../../components/form/inputs";
import MutationButton from "../../components/form/mutation-button";
import { useParams } from "wouter";
import FormWithData from "../../../lib/form/form-with-data";
import BackButton from "../../../components/back-button";
import { useValue, useTextInput } from "../../../lib/form";
import useFormSubmit from "../../../lib/form/submit";
import { TextArea } from "../../../components/form/inputs";
import MutationButton from "../../../components/form/mutation-button";
import Username from "./username";
import { useBaseUrl } from "../../lib/navigation/util";
import { useGetReportQuery, useResolveReportMutation } from "../../lib/query/admin/reports";
import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
import { useBaseUrl } from "../../../lib/navigation/util";
export default function ReportDetail({ }) {
const baseUrl = useBaseUrl();
let [_match, params] = useRoute(`${baseUrl}/:reportId`);
if (params?.reportId == undefined) {
return <Redirect to={baseUrl} />;
} else {
return (
<div className="report-detail">
<h1>
<BackButton to={baseUrl} /> Report Details
</h1>
<FormWithData
dataQuery={useGetReportQuery}
queryArg={params.reportId}
DataForm={ReportDetailForm}
/>
</div>
);
}
const params = useParams();
return (
<div className="reports">
<h1><BackButton to={`~${baseUrl}`}/> Report Details</h1>
<FormWithData
dataQuery={useGetReportQuery}
queryArg={params.reportId}
DataForm={ReportDetailForm}
/>
</div>
);
}
function ReportDetailForm({ data: report }) {
@ -61,7 +52,15 @@ function ReportDetailForm({ data: report }) {
return (
<div className="report detail">
<div className="usernames">
<Username user={from} /> reported <Username user={target} />
<Username
user={from}
link={`~/settings/moderation/accounts/${from.id}`}
/>
<> reported </>
<Username
user={target}
link={`~/settings/moderation/accounts/${target.id}`}
/>
</div>
{report.action_taken &&

View file

@ -18,57 +18,48 @@
*/
import React from "react";
import { Link, Switch, Route } from "wouter";
import FormWithData from "../../lib/form/form-with-data";
import ReportDetail from "./detail";
import { Link } from "wouter";
import FormWithData from "../../../lib/form/form-with-data";
import Username from "./username";
import { useBaseUrl } from "../../lib/navigation/util";
import { useListReportsQuery } from "../../lib/query/admin/reports";
import { useListReportsQuery } from "../../../lib/query/admin/reports";
export default function Reports({ baseUrl }) {
export function ReportOverview({ }) {
return (
<div className="reports">
<Switch>
<Route path={`${baseUrl}/:reportId`}>
<ReportDetail />
</Route>
<ReportOverview />
</Switch>
</div>
);
}
function ReportOverview({ }) {
return (
<>
<h1>Reports</h1>
<div>
<p>
Here you can view and resolve reports made to your instance, originating from local and remote users.
</p>
</div>
<FormWithData
dataQuery={useListReportsQuery}
DataForm={ReportsList}
/>
</>
<FormWithData
dataQuery={useListReportsQuery}
DataForm={ReportsList}
/>
);
}
function ReportsList({ data: reports }) {
return (
<div className="list">
{reports.map((report) => (
<ReportEntry key={report.id} report={report} />
))}
<div className="reports">
<div className="form-section-docs">
<h1>Reports</h1>
<p>
Here you can view and resolve reports made to your
instance, originating from local and remote users.
</p>
<a
href="https://docs.gotosocial.org/en/latest/admin/settings/#reports"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about this (opens in a new tab)
</a>
</div>
<div className="list">
{reports.map((report) => (
<ReportEntry key={report.id} report={report} />
))}
</div>
</div>
);
}
function ReportEntry({ report }) {
const baseUrl = useBaseUrl();
const from = report.account;
const target = report.target_account;
@ -77,11 +68,14 @@ function ReportEntry({ report }) {
: report.comment;
return (
<Link to={`${baseUrl}/${report.id}`}>
<a className={`report entry${report.action_taken ? " resolved" : ""}`}>
<Link
to={`/${report.id}`}
className="nounderline"
>
<div className={`report entry${report.action_taken ? " resolved" : ""}`}>
<div className="byline">
<div className="usernames">
<Username user={from} link={false} /> reported <Username user={target} link={false} />
<Username user={from} /> reported <Username user={target} />
</div>
<h3 className="report-status">
{report.action_taken ? "Resolved" : "Open"}
@ -97,7 +91,7 @@ function ReportEntry({ report }) {
: <i className="no-comment">none provided</i>
}
</div>
</a>
</div>
</Link>
);
}

View file

@ -19,8 +19,14 @@
import React from "react";
import { Link } from "wouter";
import { AdminAccount } from "../../../lib/types/account";
export default function Username({ user, link = true }) {
interface UsernameProps {
user: AdminAccount;
link?: string;
}
export default function Username({ user, link }: UsernameProps) {
let className = "user";
let isLocal = user.domain == null;
@ -36,19 +42,25 @@ export default function Username({ user, link = true }) {
? { fa: "fa-home", info: "Local user" }
: { fa: "fa-external-link-square", info: "Remote user" };
let Element: any = "div";
let href: any = null;
if (link) {
Element = Link;
href = `/settings/admin/accounts/${user.id}`;
}
return (
<Element className={className} to={href}>
const content = (
<>
<span className="acct">@{user.account.acct}</span>
<i className={`fa fa-fw ${icon.fa}`} aria-hidden="true" title={icon.info} />
<span className="sr-only">{icon.info}</span>
</Element>
</>
);
if (link) {
return (
<Link className={className} to={link}>
{content}
</Link>
);
} else {
return (
<div className={className}>
{content}
</div>
);
}
}

View file

@ -0,0 +1,149 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { BaseUrlContext, useBaseUrl, useHasPermission } from "../../lib/navigation/util";
import { Redirect, Route, Router, Switch } from "wouter";
import { ReportOverview } from "./reports/overview";
import ReportDetail from "./reports/detail";
import { ErrorBoundary } from "../../lib/navigation/error";
import ImportExport from "./domain-permissions/import-export";
import DomainPermissionsOverview from "./domain-permissions/overview";
import DomainPermDetail from "./domain-permissions/detail";
import AccountsOverview from "./accounts";
import AccountsPending from "./accounts/pending";
import AccountDetail from "./accounts/detail";
/*
EXPORTED COMPONENTS
*/
/**
* - /settings/moderation/reports/overview
* - /settings/moderation/reports/:reportId
* - /settings/moderation/accounts/overview
* - /settings/moderation/accounts/pending
* - /settings/moderation/accounts/:accountID
* - /settings/moderation/domain-permissions/:permType
* - /settings/moderation/domain-permissions/:permType/:domain
* - /settings/moderation/domain-permissions/import-export
* - /settings/moderation/domain-permissions/process
*/
export default function ModerationRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/moderation";
const absBase = parentUrl + thisBase;
const permissions = ["moderator"];
const moderator = useHasPermission(permissions);
if (!moderator) {
return null;
}
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ModerationReportsRouter />
<ModerationAccountsRouter />
<ModerationDomainPermsRouter />
</Router>
</BaseUrlContext.Provider>
);
}
/*
INTERNAL COMPONENTS
*/
/**
* - /settings/moderation/reports/overview
* - /settings/moderation/reports/:reportId
*/
function ModerationReportsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/reports";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path={"/:reportId"} component={ReportDetail} />
<Route component={ReportOverview}/>
</Switch>
</ErrorBoundary>
</Router>
</BaseUrlContext.Provider>
);
}
/**
* - /settings/moderation/accounts/overview
* - /settings/moderation/accounts/pending
* - /settings/moderation/accounts/:accountID
*/
function ModerationAccountsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/accounts";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/overview" component={AccountsOverview}/>
<Route path="/pending" component={AccountsPending}/>
<Route path="/:accountID" component={AccountDetail}/>
<Route><Redirect to="/overview"/></Route>
</Switch>
</ErrorBoundary>
</Router>
</BaseUrlContext.Provider>
);
}
/**
* - /settings/moderation/domain-permissions/:permType
* - /settings/moderation/domain-permissions/:permType/:domain
* - /settings/moderation/domain-permissions/import-export
* - /settings/moderation/domain-permissions/process
*/
function ModerationDomainPermsRouter() {
const parentUrl = useBaseUrl();
const thisBase = "/domain-permissions";
const absBase = parentUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/import-export" component={ImportExport} />
<Route path="/process" component={ImportExport} />
<Route path="/:permType" component={DomainPermissionsOverview} />
<Route path="/:permType/:domain" component={DomainPermDetail} />
<Route><Redirect to="/blocks"/></Route>
</Switch>
</ErrorBoundary>
</Router>
</BaseUrlContext.Provider>
);
}

View file

@ -17,19 +17,36 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { MenuItem } from "../../lib/navigation/menu";
import React from "react";
import { Switch, Route } from "wouter";
import EmojiOverview from "./overview";
import EmojiDetail from "./detail";
export default function CustomEmoji({ baseUrl }) {
/**
* - /settings/user/profile
* - /settings/user/settings
* - /settings/user/migration
*/
export default function UserMenu() {
return (
<Switch>
<Route path={`${baseUrl}/:emojiId`}>
<EmojiDetail />
</Route>
<EmojiOverview />
</Switch>
<MenuItem
name="User"
itemUrl="user"
defaultChild="profile"
>
<MenuItem
name="Profile"
itemUrl="profile"
icon="fa-user"
/>
<MenuItem
name="Settings"
itemUrl="settings"
icon="fa-cogs"
/>
<MenuItem
name="Migration"
itemUrl="migration"
icon="fa-exchange"
/>
</MenuItem>
);
}

View file

@ -19,16 +19,16 @@
import React from "react";
import FormWithData from "../lib/form/form-with-data";
import FormWithData from "../../lib/form/form-with-data";
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
import { useArrayInput, useTextInput } from "../lib/form";
import { TextInput } from "../components/form/inputs";
import useFormSubmit from "../lib/form/submit";
import MutationButton from "../components/form/mutation-button";
import { useAliasAccountMutation, useMoveAccountMutation } from "../lib/query/user";
import { FormContext, useWithFormContext } from "../lib/form/context";
import { store } from "../redux/store";
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
import { useArrayInput, useTextInput } from "../../lib/form";
import { TextInput } from "../../components/form/inputs";
import useFormSubmit from "../../lib/form/submit";
import MutationButton from "../../components/form/mutation-button";
import { useAliasAccountMutation, useMoveAccountMutation } from "../../lib/query/user";
import { FormContext, useWithFormContext } from "../../lib/form/context";
import { store } from "../../redux/store";
export default function UserMigration() {
return (
@ -81,7 +81,7 @@ function AliasForm({ data: profile }) {
return (
<form className="user-migration-alias" onSubmit={submitForm}>
<div className="form-section-docs without-border">
<div className="form-section-docs">
<h3>Alias Account</h3>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#alias-account"
@ -157,37 +157,33 @@ function MoveForm({ data: profile }) {
return (
<form className="user-migration-move" onSubmit={submitForm}>
<div className="form-section-docs without-border">
<div className="form-section-docs">
<h3>Move Account</h3>
<p>
<p>
For a move to be successful, you must have already set an alias from the
target account back to the account you're moving from (ie., this account),
using the settings panel of the instance on which the target account resides.
</p>
<p>
To do this, provide the following details to the other instance:
</p>
<dl className="migration-details">
<div>
<dt>Account handle/username:</dt>
<dd>@{profile.acct}@{url.host}</dd>
</div>
<div>
<dt>Account URI:</dt>
<dd>{urlStr}/users/{profile.username}</dd>
</div>
</dl>
<br/>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#move-account"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about moving your account (opens in a new tab)
</a>
</p>
<dl className="migration-details">
<div>
<dt>Account handle/username:</dt>
<dd>@{profile.acct}@{url.host}</dd>
</div>
<div>
<dt>Account URI:</dt>
<dd>{urlStr}/users/{profile.username}</dd>
</div>
</dl>
<br/>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#move-account"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about moving your account (opens in a new tab)
</a>
</div>
<TextInput
disabled={false}

View file

@ -25,10 +25,10 @@ import {
useBoolInput,
useFieldArrayInput,
useRadioInput
} from "../lib/form";
} from "../../lib/form";
import useFormSubmit from "../lib/form/submit";
import { useWithFormContext, FormContext } from "../lib/form/context";
import useFormSubmit from "../../lib/form/submit";
import { useWithFormContext, FormContext } from "../../lib/form/context";
import {
TextInput,
@ -36,15 +36,16 @@ import {
FileInput,
Checkbox,
RadioGroup
} from "../components/form/inputs";
} from "../../components/form/inputs";
import FormWithData from "../lib/form/form-with-data";
import FakeProfile from "../components/fake-profile";
import MutationButton from "../components/form/mutation-button";
import FormWithData from "../../lib/form/form-with-data";
import FakeProfile from "../../components/fake-profile";
import MutationButton from "../../components/form/mutation-button";
import { useAccountThemesQuery, useInstanceV1Query } from "../lib/query";
import { useUpdateCredentialsMutation } from "../lib/query/user";
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
import { useAccountThemesQuery } from "../../lib/query/user";
import { useUpdateCredentialsMutation } from "../../lib/query/user";
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
import { useInstanceV1Query } from "../../lib/query/gts-api";
export default function UserProfile() {
return (
@ -276,4 +277,4 @@ function Field({ index, data }) {
/>
</div>
);
}
}

View file

@ -0,0 +1,52 @@
/*
GoToSocial
Copyright (C) GoToSocial Authors admin@gotosocial.org
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { BaseUrlContext, useBaseUrl } from "../../lib/navigation/util";
import { Redirect, Route, Router, Switch } from "wouter";
import { ErrorBoundary } from "../../lib/navigation/error";
import UserProfile from "./profile";
import UserMigration from "./migration";
import UserSettings from "./settings";
/**
* - /settings/user/profile
* - /settings/user/settings
* - /settings/user/migration
*/
export default function UserRouter() {
const baseUrl = useBaseUrl();
const thisBase = "/user";
const absBase = baseUrl + thisBase;
return (
<BaseUrlContext.Provider value={absBase}>
<Router base={thisBase}>
<ErrorBoundary>
<Switch>
<Route path="/profile" component={UserProfile} />
<Route path="/settings" component={UserSettings} />
<Route path="/migration" component={UserMigration} />
<Route><Redirect to="/profile" /></Route>
</Switch>
</ErrorBoundary>
</Router>
</BaseUrlContext.Provider>
);
}

View file

@ -18,23 +18,19 @@
*/
import React from "react";
import query from "../lib/query";
import { useTextInput, useBoolInput } from "../lib/form";
import useFormSubmit from "../lib/form/submit";
import { Select, TextInput, Checkbox } from "../components/form/inputs";
import FormWithData from "../lib/form/form-with-data";
import Languages from "../components/languages";
import MutationButton from "../components/form/mutation-button";
import { useTextInput, useBoolInput } from "../../lib/form";
import useFormSubmit from "../../lib/form/submit";
import { Select, TextInput, Checkbox } from "../../components/form/inputs";
import FormWithData from "../../lib/form/form-with-data";
import Languages from "../../components/languages";
import MutationButton from "../../components/form/mutation-button";
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
import { usePasswordChangeMutation, useUpdateCredentialsMutation } from "../../lib/query/user";
export default function UserSettings() {
return (
<FormWithData
dataQuery={query.useVerifyCredentialsQuery}
dataQuery={useVerifyCredentialsQuery}
DataForm={UserSettingsForm}
/>
);
@ -55,12 +51,23 @@ function UserSettingsForm({ data }) {
statusContentType: useTextInput("source[status_content_type]", { source: data, defaultValue: "text/plain" }),
};
const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation());
const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation());
return (
<>
<h1>Account Settings</h1>
<form className="user-settings" onSubmit={submitForm}>
<h1>Post settings</h1>
<div className="form-section-docs">
<h3>Post Settings</h3>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/posts"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about these settings (opens in a new tab)
</a>
</div>
<Select field={form.language} label="Default post language" options={
<Languages />
}>
@ -72,7 +79,6 @@ function UserSettingsForm({ data }) {
<option value="public">Public</option>
</>
}>
<a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#privacy-settings" target="_blank" className="docslink" rel="noreferrer">Learn more about post privacy settings (opens in a new tab)</a>
</Select>
<Select field={form.statusContentType} label="Default post (and bio) format" options={
<>
@ -80,13 +86,11 @@ function UserSettingsForm({ data }) {
<option value="text/markdown">Markdown</option>
</>
}>
<a href="https://docs.gotosocial.org/en/latest/user_guide/posts/#input-types" target="_blank" className="docslink" rel="noreferrer">Learn more about post format settings (opens in a new tab)</a>
</Select>
<Checkbox
field={form.isSensitive}
label="Mark my posts as sensitive by default"
/>
<MutationButton
disabled={false}
label="Save settings"
@ -120,28 +124,41 @@ function PasswordChange() {
}
});
const [submitForm, result] = useFormSubmit(form, query.usePasswordChangeMutation());
const [submitForm, result] = useFormSubmit(form, usePasswordChangeMutation());
return (
<form className="change-password" onSubmit={submitForm}>
<h1>Change password</h1>
<div className="form-section-docs">
<h3>Change Password</h3>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#password-change"
target="_blank"
className="docslink"
rel="noreferrer"
>
Learn more about this (opens in a new tab)
</a>
</div>
<TextInput
type="password"
name="password"
field={form.oldPassword}
label="Current password"
autoComplete="current-password"
/>
<TextInput
type="password"
name="newPassword"
field={form.newPassword}
label="New password"
autoComplete="new-password"
/>
<TextInput
type="password"
name="confirmNewPassword"
field={verifyNewPassword}
label="Confirm new password"
autoComplete="new-password"
/>
<MutationButton
disabled={false}
@ -150,4 +167,4 @@ function PasswordChange() {
/>
</form>
);
}
}

View file

@ -25,9 +25,9 @@
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"module": "preserve", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
@ -44,7 +44,7 @@
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */

File diff suppressed because it is too large Load diff