Compare commits
6 Commits
6eaf378e92
...
ad7e963b05
Author | SHA1 | Date |
---|---|---|
kim | ad7e963b05 | |
tobi | 578a4e0cf5 | |
kim | f24ce34c3a | |
kim | 707bee5d41 | |
kim | 16cd45586f | |
kim | 83768a9ed9 |
2
go.mod
2
go.mod
|
@ -2,7 +2,7 @@ module github.com/superseriousbusiness/gotosocial
|
|||
|
||||
go 1.22
|
||||
|
||||
replace modernc.org/sqlite => gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.8-concurrency-workaround
|
||||
replace modernc.org/sqlite => gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.9-concurrency-workaround
|
||||
|
||||
toolchain go1.22.2
|
||||
|
||||
|
|
6
go.sum
6
go.sum
|
@ -416,8 +416,6 @@ github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope
|
|||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
|
@ -631,8 +629,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
|||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.8-concurrency-workaround h1:ESobxED9bfE0nOQP/WPv9+tMR8oZoDIWRKlNK2Vs4Ms=
|
||||
gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.8-concurrency-workaround/go.mod h1:lQPm27iqa4UNZpmr4Aor0MH0HkCLbt1huYDfWylLZFk=
|
||||
gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.9-concurrency-workaround h1:gFAlklid3jyXIuZBy5Vy0dhG+F6YBgosRy4syT5CDsg=
|
||||
gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.9-concurrency-workaround/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
|
||||
go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80=
|
||||
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
|
|
|
@ -531,6 +531,11 @@ func (c *Caches) initFilterKeyword() {
|
|||
// See internal/db/bundb/filter.go.
|
||||
filterKeyword2.Filter = nil
|
||||
|
||||
// We specifically DO NOT unset
|
||||
// the regexp field here, as any
|
||||
// regexp.Regexp instance is safe
|
||||
// for concurrent access.
|
||||
|
||||
return filterKeyword2
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
@ -34,12 +35,22 @@ func (f *filterDB) GetFilterKeywordByID(ctx context.Context, id string) (*gtsmod
|
|||
"ID",
|
||||
func() (*gtsmodel.FilterKeyword, error) {
|
||||
var filterKeyword gtsmodel.FilterKeyword
|
||||
err := f.db.
|
||||
|
||||
// Scan from DB.
|
||||
if err := f.db.
|
||||
NewSelect().
|
||||
Model(&filterKeyword).
|
||||
Where("? = ?", bun.Ident("id"), id).
|
||||
Scan(ctx)
|
||||
return &filterKeyword, err
|
||||
Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pre-compile filter keyword regular expression.
|
||||
if err := filterKeyword.Compile(); err != nil {
|
||||
return nil, gtserror.Newf("error compiling filter keyword regex: %w", err)
|
||||
}
|
||||
|
||||
return &filterKeyword, nil
|
||||
},
|
||||
id,
|
||||
)
|
||||
|
@ -57,20 +68,20 @@ func (f *filterDB) GetFilterKeywordByID(ctx context.Context, id string) (*gtsmod
|
|||
return filterKeyword, nil
|
||||
}
|
||||
|
||||
func (f *filterDB) populateFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) error {
|
||||
func (f *filterDB) populateFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) (err error) {
|
||||
if filterKeyword.Filter == nil {
|
||||
// Filter is not set, fetch from the cache or database.
|
||||
filter, err := f.state.DB.GetFilterByID(
|
||||
// Don't populate the filter with all of its keywords and statuses or we'll just end up back here.
|
||||
filterKeyword.Filter, err = f.state.DB.GetFilterByID(
|
||||
|
||||
// Don't populate the filter with all of its keywords
|
||||
// and statuses or we'll just end up back here.
|
||||
gtscontext.SetBarebones(ctx),
|
||||
filterKeyword.FilterID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filterKeyword.Filter = filter
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -84,6 +95,7 @@ func (f *filterDB) GetFilterKeywordsForAccountID(ctx context.Context, accountID
|
|||
|
||||
func (f *filterDB) getFilterKeywords(ctx context.Context, idColumn string, id string) ([]*gtsmodel.FilterKeyword, error) {
|
||||
var filterKeywordIDs []string
|
||||
|
||||
if err := f.db.
|
||||
NewSelect().
|
||||
Model((*gtsmodel.FilterKeyword)(nil)).
|
||||
|
@ -92,6 +104,7 @@ func (f *filterDB) getFilterKeywords(ctx context.Context, idColumn string, id st
|
|||
Scan(ctx, &filterKeywordIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(filterKeywordIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
@ -101,6 +114,8 @@ func (f *filterDB) getFilterKeywords(ctx context.Context, idColumn string, id st
|
|||
filterKeywordIDs,
|
||||
func(uncachedFilterKeywordIDs []string) ([]*gtsmodel.FilterKeyword, error) {
|
||||
uncachedFilterKeywords := make([]*gtsmodel.FilterKeyword, 0, len(uncachedFilterKeywordIDs))
|
||||
|
||||
// Scan from DB.
|
||||
if err := f.db.
|
||||
NewSelect().
|
||||
Model(&uncachedFilterKeywords).
|
||||
|
@ -108,6 +123,16 @@ func (f *filterDB) getFilterKeywords(ctx context.Context, idColumn string, id st
|
|||
Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Compile all the keyword regular expressions.
|
||||
uncachedFilterKeywords = slices.DeleteFunc(uncachedFilterKeywords, func(filterKeyword *gtsmodel.FilterKeyword) bool {
|
||||
if err := filterKeyword.Compile(); err != nil {
|
||||
log.Errorf(ctx, "error compiling filter keyword regex: %v", err)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return uncachedFilterKeywords, nil
|
||||
},
|
||||
)
|
||||
|
@ -125,23 +150,26 @@ func (f *filterDB) getFilterKeywords(ctx context.Context, idColumn string, id st
|
|||
}
|
||||
|
||||
// Populate the filter keywords. Remove any that we can't populate from the return slice.
|
||||
errs := gtserror.NewMultiError(len(filterKeywords))
|
||||
filterKeywords = slices.DeleteFunc(filterKeywords, func(filterKeyword *gtsmodel.FilterKeyword) bool {
|
||||
if err := f.populateFilterKeyword(ctx, filterKeyword); err != nil {
|
||||
errs.Appendf(
|
||||
"error populating filter keyword %s: %w",
|
||||
filterKeyword.ID,
|
||||
err,
|
||||
)
|
||||
log.Errorf(ctx, "error populating filter keyword: %v", err)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return filterKeywords, errs.Combine()
|
||||
return filterKeywords, nil
|
||||
}
|
||||
|
||||
func (f *filterDB) PutFilterKeyword(ctx context.Context, filterKeyword *gtsmodel.FilterKeyword) error {
|
||||
if filterKeyword.Regexp == nil {
|
||||
// Ensure regexp is compiled
|
||||
// before attempted caching.
|
||||
err := filterKeyword.Compile()
|
||||
if err != nil {
|
||||
return gtserror.Newf("error compiling filter keyword regex: %w", err)
|
||||
}
|
||||
}
|
||||
return f.state.Caches.GTS.FilterKeyword.Store(filterKeyword, func() error {
|
||||
_, err := f.db.
|
||||
NewInsert().
|
||||
|
@ -156,7 +184,14 @@ func (f *filterDB) UpdateFilterKeyword(ctx context.Context, filterKeyword *gtsmo
|
|||
if len(columns) > 0 {
|
||||
columns = append(columns, "updated_at")
|
||||
}
|
||||
|
||||
if filterKeyword.Regexp == nil {
|
||||
// Ensure regexp is compiled
|
||||
// before attempted caching.
|
||||
err := filterKeyword.Compile()
|
||||
if err != nil {
|
||||
return gtserror.Newf("error compiling filter keyword regex: %w", err)
|
||||
}
|
||||
}
|
||||
return f.state.Caches.GTS.FilterKeyword.Store(filterKeyword, func() error {
|
||||
_, err := f.db.
|
||||
NewUpdate().
|
||||
|
|
|
@ -17,7 +17,10 @@
|
|||
|
||||
package gtsmodel
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Filter stores a filter created by a local account.
|
||||
type Filter struct {
|
||||
|
@ -39,14 +42,28 @@ type Filter struct {
|
|||
|
||||
// FilterKeyword stores a single keyword to filter statuses against.
|
||||
type FilterKeyword struct {
|
||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
|
||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
|
||||
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
|
||||
AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter keyword.
|
||||
FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_keywords_filter_id_keyword_uniq"` // ID of the filter that this keyword belongs to.
|
||||
Filter *Filter `bun:"-"` // Filter corresponding to FilterID
|
||||
Keyword string `bun:",nullzero,notnull,unique:filter_keywords_filter_id_keyword_uniq"` // The keyword or phrase to filter against.
|
||||
WholeWord *bool `bun:",nullzero,notnull,default:false"` // Should the filter consider word boundaries?
|
||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
|
||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
|
||||
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
|
||||
AccountID string `bun:"type:CHAR(26),notnull,nullzero"` // ID of the local account that created the filter keyword.
|
||||
FilterID string `bun:"type:CHAR(26),notnull,nullzero,unique:filter_keywords_filter_id_keyword_uniq"` // ID of the filter that this keyword belongs to.
|
||||
Filter *Filter `bun:"-"` // Filter corresponding to FilterID
|
||||
Keyword string `bun:",nullzero,notnull,unique:filter_keywords_filter_id_keyword_uniq"` // The keyword or phrase to filter against.
|
||||
WholeWord *bool `bun:",nullzero,notnull,default:false"` // Should the filter consider word boundaries?
|
||||
Regexp *regexp.Regexp `bun:"-"` // pre-prepared regular expression
|
||||
}
|
||||
|
||||
// Compile will compile this FilterKeyword as a prepared regular expression.
|
||||
func (k *FilterKeyword) Compile() (err error) {
|
||||
var wordBreak string
|
||||
if k.WholeWord != nil && *k.WholeWord {
|
||||
wordBreak = `\b`
|
||||
}
|
||||
|
||||
// Compile keyword filter regexp.
|
||||
quoted := regexp.QuoteMeta(k.Keyword)
|
||||
k.Regexp, err = regexp.Compile(`(?i)` + wordBreak + quoted + wordBreak)
|
||||
return // caller is expected to wrap this error
|
||||
}
|
||||
|
||||
// FilterStatus stores a single status to filter.
|
||||
|
|
|
@ -22,7 +22,6 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -746,18 +745,9 @@ func (c *Converter) statusToAPIFilterResults(
|
|||
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) {
|
||||
if filterKeyword.Regexp.MatchString(field) {
|
||||
isMatch = true
|
||||
break
|
||||
}
|
||||
|
|
|
@ -546,6 +546,7 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() {
|
|||
requestingAccount := suite.testAccounts["local_account_1"]
|
||||
expectedMatchingFilter := suite.testFilters["local_account_1_filter_1"]
|
||||
expectedMatchingFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"]
|
||||
suite.NoError(expectedMatchingFilterKeyword.Compile())
|
||||
expectedMatchingFilterKeyword.Filter = expectedMatchingFilter
|
||||
expectedMatchingFilter.Keywords = []*gtsmodel.FilterKeyword{expectedMatchingFilterKeyword}
|
||||
requestingAccountFilters := []*gtsmodel.Filter{expectedMatchingFilter}
|
||||
|
@ -700,6 +701,7 @@ func (suite *InternalToFrontendTestSuite) TestHideFilteredStatusToFrontend() {
|
|||
expectedMatchingFilter := suite.testFilters["local_account_1_filter_1"]
|
||||
expectedMatchingFilter.Action = gtsmodel.FilterActionHide
|
||||
expectedMatchingFilterKeyword := suite.testFilterKeywords["local_account_1_filter_1_keyword_1"]
|
||||
suite.NoError(expectedMatchingFilterKeyword.Compile())
|
||||
expectedMatchingFilterKeyword.Filter = expectedMatchingFilter
|
||||
expectedMatchingFilter.Keywords = []*gtsmodel.FilterKeyword{expectedMatchingFilterKeyword}
|
||||
requestingAccountFilters := []*gtsmodel.Filter{expectedMatchingFilter}
|
||||
|
|
|
@ -228935,4 +228935,3 @@ type Sqlite3_index_info = sqlite3_index_info
|
|||
type Sqlite3_module = sqlite3_module
|
||||
type Sqlite3_vtab = sqlite3_vtab
|
||||
type Sqlite3_vtab_cursor = sqlite3_vtab_cursor
|
||||
|
||||
|
|
|
@ -228490,4 +228490,3 @@ type Sqlite3_index_info = sqlite3_index_info
|
|||
type Sqlite3_module = sqlite3_module
|
||||
type Sqlite3_vtab = sqlite3_vtab
|
||||
type Sqlite3_vtab_cursor = sqlite3_vtab_cursor
|
||||
|
||||
|
|
|
@ -701,7 +701,7 @@ type tx struct {
|
|||
c *conn
|
||||
}
|
||||
|
||||
func newTx(c *conn, opts driver.TxOptions) (*tx, error) {
|
||||
func newTx(ctx context.Context, c *conn, opts driver.TxOptions) (*tx, error) {
|
||||
r := &tx{c: c}
|
||||
|
||||
sql := "begin"
|
||||
|
@ -709,7 +709,7 @@ func newTx(c *conn, opts driver.TxOptions) (*tx, error) {
|
|||
sql = "begin " + c.beginMode
|
||||
}
|
||||
|
||||
if err := r.exec(context.Background(), sql); err != nil {
|
||||
if err := r.exec(ctx, sql); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -1348,7 +1348,7 @@ func (c *conn) Begin() (dt driver.Tx, err error) {
|
|||
}
|
||||
|
||||
func (c *conn) begin(ctx context.Context, opts driver.TxOptions) (t driver.Tx, err error) {
|
||||
return newTx(c, opts)
|
||||
return newTx(ctx, c, opts)
|
||||
}
|
||||
|
||||
// Close invalidates and potentially stops any current prepared statements and
|
||||
|
|
|
@ -1268,7 +1268,7 @@ modernc.org/mathutil
|
|||
# modernc.org/memory v1.8.0
|
||||
## explicit; go 1.18
|
||||
modernc.org/memory
|
||||
# modernc.org/sqlite v1.29.8 => gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.8-concurrency-workaround
|
||||
# modernc.org/sqlite v1.29.8 => gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.9-concurrency-workaround
|
||||
## explicit; go 1.20
|
||||
modernc.org/sqlite
|
||||
modernc.org/sqlite/lib
|
||||
|
@ -1281,4 +1281,4 @@ modernc.org/token
|
|||
# mvdan.cc/xurls/v2 v2.5.0
|
||||
## explicit; go 1.19
|
||||
mvdan.cc/xurls/v2
|
||||
# modernc.org/sqlite => gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.8-concurrency-workaround
|
||||
# modernc.org/sqlite => gitlab.com/NyaaaWhatsUpDoc/sqlite v1.29.9-concurrency-workaround
|
||||
|
|
|
@ -17,7 +17,9 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { SerializedError } from "@reduxjs/toolkit";
|
||||
import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
function ErrorFallback({ error, resetErrorBoundary }) {
|
||||
return (
|
||||
|
@ -44,39 +46,70 @@ function ErrorFallback({ error, resetErrorBoundary }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Error({ error }) {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error("Rendering error:", error);
|
||||
let message;
|
||||
interface GtsError {
|
||||
/**
|
||||
* Error message returned from the API.
|
||||
*/
|
||||
error: string;
|
||||
|
||||
if (error.data != undefined) { // RTK Query error with data
|
||||
if (error.status) {
|
||||
message = (<>
|
||||
<b>{error.status}:</b> {error.data.error}
|
||||
{error.data.error_description &&
|
||||
<p>
|
||||
{error.data.error_description}
|
||||
</p>
|
||||
}
|
||||
</>);
|
||||
} else {
|
||||
message = error.data.error;
|
||||
}
|
||||
} else if (error.name != undefined || error.type != undefined) { // JS error
|
||||
message = (<>
|
||||
<b>{error.type && error.name}:</b> {error.message}
|
||||
</>);
|
||||
} else if (error.status && typeof error.error == "string") {
|
||||
message = (<>
|
||||
<b>{error.status}:</b> {error.error}
|
||||
</>);
|
||||
/**
|
||||
* For OAuth errors: description of the error.
|
||||
*/
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
interface ErrorProps {
|
||||
error: FetchBaseQueryError | SerializedError | Error | undefined;
|
||||
|
||||
/**
|
||||
* Optional function to clear the error.
|
||||
* If provided, rendered error will have
|
||||
* a "dismiss" button.
|
||||
*/
|
||||
reset?: () => void;
|
||||
}
|
||||
|
||||
function Error({ error, reset }: ErrorProps) {
|
||||
if (error === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error("caught error: ", error);
|
||||
|
||||
let message: ReactNode;
|
||||
if ("status" in error) {
|
||||
// RTK Query error with data.
|
||||
const gtsError = error.data as GtsError;
|
||||
const errMsg = gtsError.error_description ?? gtsError.error;
|
||||
message = <>Code {error.status} {errMsg}</>;
|
||||
} else {
|
||||
message = error.message ?? error;
|
||||
// SerializedError or Error.
|
||||
const errMsg = error.message ?? JSON.stringify(error);
|
||||
message = (
|
||||
<>{error.name && `${error.name}: `}{errMsg}</>
|
||||
);
|
||||
}
|
||||
|
||||
let className = "error";
|
||||
if (reset) {
|
||||
className += " with-dismiss";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="error">
|
||||
{message}
|
||||
<div className={className}>
|
||||
<span>{message}</span>
|
||||
{ reset &&
|
||||
<span
|
||||
className="dismiss"
|
||||
onClick={reset}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span>Dismiss</span>
|
||||
<i className="fa fa-fw fa-close" aria-hidden="true" />
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import type {
|
|||
RadioFormInputHook,
|
||||
TextFormInputHook,
|
||||
} from "../../lib/form/types";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export interface TextInputProps extends React.DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
|
@ -92,22 +93,25 @@ export interface FileInputProps extends React.DetailedHTMLProps<
|
|||
|
||||
export function FileInput({ label, field, ...props }: FileInputProps) {
|
||||
const { onChange, ref, infoComponent } = field;
|
||||
const id = nanoid();
|
||||
|
||||
return (
|
||||
<div className="form-field file">
|
||||
<label>
|
||||
<div className="label">{label}</div>
|
||||
<div className="file-input button">Browse</div>
|
||||
{infoComponent}
|
||||
{/* <a onClick={removeFile("header")}>remove</a> */}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={onChange}
|
||||
ref={ref ? ref as RefObject<HTMLInputElement> : undefined}
|
||||
{...props}
|
||||
/>
|
||||
<label className="label-label" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<label className="label-button" htmlFor={id}>
|
||||
<div className="file-input button">Browse</div>
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={onChange}
|
||||
ref={ref ? ref as RefObject<HTMLInputElement> : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{infoComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -51,9 +51,9 @@ export default function MutationButton({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<div className={wrapperClassName ? wrapperClassName : "mutation-button"}>
|
||||
{(showError && targetsThisButton && result.error) &&
|
||||
<Error error={result.error} />
|
||||
<Error error={result.error} reset={result.reset} />
|
||||
}
|
||||
<button
|
||||
type="submit"
|
||||
|
|
|
@ -27,6 +27,7 @@ import type {
|
|||
HookOpts,
|
||||
FileFormInputHook,
|
||||
} from "./types";
|
||||
import { Error as ErrorC } from "../../components/error";
|
||||
|
||||
const _default = undefined;
|
||||
export default function useFileInput(
|
||||
|
@ -41,6 +42,15 @@ export default function useFileInput(
|
|||
const [imageURL, setImageURL] = useState<string>();
|
||||
const [info, setInfo] = useState<React.JSX.Element>();
|
||||
|
||||
function reset() {
|
||||
if (imageURL) {
|
||||
URL.revokeObjectURL(imageURL);
|
||||
}
|
||||
setImageURL(undefined);
|
||||
setFile(undefined);
|
||||
setInfo(undefined);
|
||||
}
|
||||
|
||||
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = e.target.files;
|
||||
if (!files) {
|
||||
|
@ -59,25 +69,18 @@ export default function useFileInput(
|
|||
setImageURL(URL.createObjectURL(file));
|
||||
}
|
||||
|
||||
let size = prettierBytes(file.size);
|
||||
const sizePrettier = prettierBytes(file.size);
|
||||
if (maxSize && file.size > maxSize) {
|
||||
size = <span className="error-text">{size}</span>;
|
||||
const maxSizePrettier = prettierBytes(maxSize);
|
||||
setInfo(
|
||||
<ErrorC
|
||||
error={new Error(`file size ${sizePrettier} is larger than max size ${maxSizePrettier}`)}
|
||||
reset={(reset)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
setInfo(<>{file.name} ({sizePrettier})</>);
|
||||
}
|
||||
|
||||
setInfo(
|
||||
<>
|
||||
{file.name} ({size})
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (imageURL) {
|
||||
URL.revokeObjectURL(imageURL);
|
||||
}
|
||||
setImageURL(undefined);
|
||||
setFile(undefined);
|
||||
setInfo(undefined);
|
||||
}
|
||||
|
||||
const infoComponent = (
|
||||
|
|
|
@ -257,33 +257,37 @@ input, select, textarea {
|
|||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.with-dismiss {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.dismiss {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mutation-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messagebutton, .messagebutton > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
div.padded {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
white-space: nowrap;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.messagebutton > div {
|
||||
button, .button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.notImplemented {
|
||||
border: 2px solid rgb(70, 79, 88);
|
||||
background: repeating-linear-gradient(
|
||||
|
@ -500,12 +504,29 @@ form {
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-field.file label {
|
||||
.form-field.file {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-areas:
|
||||
"label-label label-label"
|
||||
"label-button file-info"
|
||||
;
|
||||
|
||||
.label-label {
|
||||
grid-area: label-label;
|
||||
}
|
||||
|
||||
.label {
|
||||
grid-column: 1 / span 2;
|
||||
.label-button {
|
||||
grid-area: label-button;
|
||||
}
|
||||
|
||||
.form-info {
|
||||
grid-area: file-info;
|
||||
.error {
|
||||
padding: 0.1rem;
|
||||
line-height: 1.4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useEffect } from "react";
|
||||
import React, { useMemo, useEffect, ReactNode } from "react";
|
||||
import { useFileInput, useComboBoxInput } from "../../../../lib/form";
|
||||
import useShortcode from "./use-shortcode";
|
||||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
|
@ -27,52 +27,74 @@ 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";
|
||||
import prettierBytes from "prettier-bytes";
|
||||
|
||||
export default function NewEmojiForm() {
|
||||
const shortcode = useShortcode();
|
||||
|
||||
const { data: instance } = useInstanceV1Query();
|
||||
const emojiMaxSize = useMemo(() => {
|
||||
return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024;
|
||||
}, [instance]);
|
||||
|
||||
const image = useFileInput("image", {
|
||||
withPreview: true,
|
||||
maxSize: emojiMaxSize
|
||||
});
|
||||
const prettierMaxSize = useMemo(() => {
|
||||
return prettierBytes(emojiMaxSize);
|
||||
}, [emojiMaxSize]);
|
||||
|
||||
const category = useComboBoxInput("category");
|
||||
const form = {
|
||||
shortcode: useShortcode(),
|
||||
image: useFileInput("image", {
|
||||
withPreview: true,
|
||||
maxSize: emojiMaxSize
|
||||
}),
|
||||
category: useComboBoxInput("category"),
|
||||
};
|
||||
|
||||
const [submitForm, result] = useFormSubmit({
|
||||
shortcode, image, category
|
||||
}, useAddEmojiMutation());
|
||||
const [submitForm, result] = useFormSubmit(
|
||||
form,
|
||||
useAddEmojiMutation(),
|
||||
{
|
||||
changedOnly: false,
|
||||
// On submission, reset form values
|
||||
// no matter what the result was.
|
||||
onFinish: (_res) => {
|
||||
form.shortcode.reset();
|
||||
form.image.reset();
|
||||
form.category.reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcode.value === undefined || shortcode.value.length == 0) {
|
||||
if (image.value != undefined) {
|
||||
let [name, _ext] = image.value.name.split(".");
|
||||
shortcode.setter(name);
|
||||
}
|
||||
// If shortcode has not been entered yet, but an image file
|
||||
// has been submitted, suggest a shortcode based on filename.
|
||||
if (
|
||||
(form.shortcode.value === undefined || form.shortcode.value.length === 0) &&
|
||||
form.image.value !== undefined
|
||||
) {
|
||||
let [name, _ext] = form.image.value.name.split(".");
|
||||
form.shortcode.setter(name);
|
||||
}
|
||||
|
||||
/* We explicitly don't want to have 'shortcode' as a dependency here
|
||||
because we only want to change the shortcode to the filename if the field is empty
|
||||
at the moment the file is selected, not some time after when the field is emptied
|
||||
*/
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, [image.value]);
|
||||
// We explicitly don't want to have 'shortcode' as a
|
||||
// dependency here because we only want to change the
|
||||
// shortcode to the filename if the field is empty at
|
||||
// the moment the file is selected, not some time after
|
||||
// when the field is emptied.
|
||||
//
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [form.image.value]);
|
||||
|
||||
let emojiOrShortcode;
|
||||
|
||||
if (image.previewValue != undefined) {
|
||||
emojiOrShortcode = <img
|
||||
className="emoji"
|
||||
src={image.previewValue}
|
||||
title={`:${shortcode.value}:`}
|
||||
alt={shortcode.value}
|
||||
/>;
|
||||
} else if (shortcode.value !== undefined && shortcode.value.length > 0) {
|
||||
emojiOrShortcode = `:${shortcode.value}:`;
|
||||
let emojiOrShortcode: ReactNode;
|
||||
if (form.image.previewValue !== undefined) {
|
||||
emojiOrShortcode = (
|
||||
<img
|
||||
className="emoji"
|
||||
src={form.image.previewValue}
|
||||
title={`:${form.shortcode.value}:`}
|
||||
alt={form.shortcode.value}
|
||||
/>
|
||||
);
|
||||
} else if (form.shortcode.value !== undefined && form.shortcode.value.length > 0) {
|
||||
emojiOrShortcode = `:${form.shortcode.value}:`;
|
||||
} else {
|
||||
emojiOrShortcode = `:your_emoji_here:`;
|
||||
}
|
||||
|
@ -87,22 +109,23 @@ export default function NewEmojiForm() {
|
|||
|
||||
<form onSubmit={submitForm} className="form-flex">
|
||||
<FileInput
|
||||
field={image}
|
||||
field={form.image}
|
||||
label={`Image file: png, gif, or static webp; max size ${prettierMaxSize}`}
|
||||
accept="image/png,image/gif,image/webp"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
field={shortcode}
|
||||
field={form.shortcode}
|
||||
label="Shortcode, must be unique among the instance's local emoji"
|
||||
{...{pattern: "^\\w{2,30}$"}}
|
||||
/>
|
||||
|
||||
<CategorySelect
|
||||
field={category}
|
||||
children={[]}
|
||||
field={form.category}
|
||||
/>
|
||||
|
||||
<MutationButton
|
||||
disabled={image.previewValue === undefined}
|
||||
disabled={form.image.previewValue === undefined || form.shortcode.value?.length === 0}
|
||||
label="Upload emoji"
|
||||
result={result}
|
||||
/>
|
||||
|
|
Loading…
Reference in New Issue