mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-05-12 09:32:40 +00:00
Compare commits
12 commits
b57d92be10
...
1d02afb3f2
Author | SHA1 | Date | |
---|---|---|---|
1d02afb3f2 | |||
aecf74951c | |||
7a1e639483 | |||
4c195cf4b7 | |||
7ebbbb4063 | |||
6f37930963 | |||
5f03f630aa | |||
fbd5e44d8f | |||
36a156cc13 | |||
be78b7e701 | |||
125b4331ed | |||
fc2b641a0d |
13
.vscode/settings.json
vendored
13
.vscode/settings.json
vendored
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
34
internal/api/model/filterresult.go
Normal file
34
internal/api/model/filterresult.go
Normal 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"`
|
||||
}
|
106
internal/api/model/filterv2.go
Normal file
106
internal/api/model/filterv2.go
Normal 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"`
|
||||
}
|
|
@ -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).
|
||||
|
|
45
internal/filter/custom/user.go
Normal file
45
internal/filter/custom/user.go
Normal 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"
|
||||
)
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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, "", " ")
|
||||
|
|
|
@ -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({
|
|||
}]]
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>< 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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">< back</a>
|
||||
</Link>
|
||||
<Link className="button" to={to}>< back</Link>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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 };
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
}
|
|
@ -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" />
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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>);
|
89
web/source/settings/index.tsx
Normal file
89
web/source/settings/index.tsx
Normal 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>);
|
|
@ -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
|
||||
};
|
98
web/source/settings/lib/navigation/error.tsx
Normal file
98
web/source/settings/lib/navigation/error.tsx
Normal 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 };
|
|
@ -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
|
||||
};
|
175
web/source/settings/lib/navigation/menu.tsx
Normal file
175
web/source/settings/lib/navigation/menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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"),
|
||||
}),
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
|
@ -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}
|
||||
/>
|
134
web/source/settings/views/admin/emoji/category-select.tsx
Normal file
134
web/source/settings/views/admin/emoji/category-select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>< 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't it cool?
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
173
web/source/settings/views/admin/emoji/local/overview.tsx
Normal file
173
web/source/settings/views/admin/emoji/local/overview.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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 "";
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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 } })
|
|||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
103
web/source/settings/views/admin/instance/ruledetail.tsx
Normal file
103
web/source/settings/views/admin/instance/ruledetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
75
web/source/settings/views/admin/instance/rules.tsx
Normal file
75
web/source/settings/views/admin/instance/rules.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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;
|
129
web/source/settings/views/admin/menu.tsx
Normal file
129
web/source/settings/views/admin/menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
151
web/source/settings/views/admin/router.tsx
Normal file
151
web/source/settings/views/admin/router.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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,
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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} />
|
|
@ -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>
|
|
@ -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"});
|
|
@ -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}
|
|
@ -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);
|
||||
}
|
|
@ -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 (
|
|
@ -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: {
|
|
@ -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("");
|
||||
}}
|
||||
>
|
||||
< 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}
|
|
@ -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} {filter}</a>
|
||||
<Link
|
||||
className="button"
|
||||
to={`/${permType}s/${filter}`}
|
||||
>
|
||||
{permTypeUpper} {filter}
|
||||
</Link>
|
||||
</form>
|
||||
<div>
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
121
web/source/settings/views/moderation/menu.tsx
Normal file
121
web/source/settings/views/moderation/menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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 &&
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
149
web/source/settings/views/moderation/router.tsx
Normal file
149
web/source/settings/views/moderation/router.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
52
web/source/settings/views/user/router.tsx
Normal file
52
web/source/settings/views/user/router.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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'. */
|
||||
|
||||
|
|
1157
web/source/yarn.lock
1157
web/source/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue