Compare commits

...

6 Commits

Author SHA1 Message Date
kim ad7e963b05
Merge 707bee5d41 into 578a4e0cf5 2024-05-07 20:23:29 +02:00
tobi 578a4e0cf5
[bugfix] Reset emoji fields on upload error (#2905) 2024-05-07 19:48:12 +02:00
kim f24ce34c3a
bump modernc.org/sqlite v1.29.8 -> v1.29.9 (concurrency workaround) (#2906) 2024-05-07 14:52:37 +01:00
kim 707bee5d41 fix WholeWord nil check 2024-05-06 15:37:28 +01:00
kim 16cd45586f formatting 2024-05-06 13:42:40 +01:00
kim 83768a9ed9 add caching of filterkeyword regular expressions 2024-05-06 13:39:51 +01:00
17 changed files with 299 additions and 170 deletions

2
go.mod
View File

@ -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
View File

@ -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=

View File

@ -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
}

View File

@ -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().

View File

@ -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.

View File

@ -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
}

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -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

4
vendor/modules.txt vendored
View File

@ -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

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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"

View File

@ -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 = (

View File

@ -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;
}
}
}

View File

@ -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}
/>